feat(sf-db): record per-task purpose_trace at complete_task (ADR-0000)
Restore the purpose-to-software doctrine at the slice gate: every task the executor closes must name the slice-goal sentence or clause it served. complete-slice now refuses to flip a slice to complete while any of its tasks has a NULL purpose_trace, making "did all tasks actually serve the slice goal" a mechanical check instead of a vibe. Schema migration v70 adds a nullable purpose_trace TEXT to tasks (legacy rows stay valid). complete_task refuses without it and quotes slice.goal in the error so the agent can anchor. insertTask / updateTaskStatus accept the new field, rowToTask exposes it, and a new updateTaskPurposeTrace helper covers later corrections. Restoration of doctrine — see docs/adr/0000-purpose-to-software-compiler.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa657f2523
commit
3c416162b2
10 changed files with 389 additions and 5 deletions
|
|
@ -1879,6 +1879,16 @@ export function registerDbTools(pi) {
|
|||
description:
|
||||
"What was verified and how — commands run, tests passed, behavior confirmed",
|
||||
}),
|
||||
// ── ADR-0000 (SF v70): purpose trace ─────────────────────────────
|
||||
// Required. The sentence or clause of the slice goal this change
|
||||
// served. complete-slice refuses to flip status while any task
|
||||
// has a NULL trace, so omitting this field will fail the call.
|
||||
purposeTrace: Type.String({
|
||||
description:
|
||||
"REQUIRED (ADR-0000): the sentence or clause from the slice goal that this change served. " +
|
||||
"Quote or paraphrase the slice.goal clause in 1-3 sentences. complete-slice will refuse to close " +
|
||||
"the slice if any task is missing this field.",
|
||||
}),
|
||||
// ── Enrichment metadata (optional — defaults to empty) ────────────
|
||||
deviations: Type.Optional(
|
||||
Type.String({
|
||||
|
|
|
|||
|
|
@ -853,6 +853,10 @@ export function rowToTask(row) {
|
|||
escalation_awaiting_review: row["escalation_awaiting_review"] ?? 0,
|
||||
escalation_override_applied: row["escalation_override_applied"] ?? 0,
|
||||
escalation_artifact_path: row["escalation_artifact_path"] ?? null,
|
||||
// ADR-0000 (SF v70): goal-anchored purpose sentence the executor named
|
||||
// when finishing the task. NULL on legacy rows; complete-slice refuses
|
||||
// to close while any row in its slice is still NULL.
|
||||
purpose_trace: row["purpose_trace"] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1030,6 +1030,7 @@ export function initSchema(db, fileBacked, options = {}) {
|
|||
escalation_awaiting_review INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): continueWithDefault=true marker (no pause)
|
||||
escalation_override_applied INTEGER NOT NULL DEFAULT 0, -- SF ADR-011 P2: 1 once carry-forward injected into a downstream prompt
|
||||
escalation_artifact_path TEXT DEFAULT NULL, -- ADR-011 P2 (SF): path to T##-ESCALATION.json
|
||||
purpose_trace TEXT DEFAULT NULL, -- ADR-0000 (SF v70): goal-anchored sentence the executor recorded at complete_task time
|
||||
PRIMARY KEY (milestone_id, slice_id, id),
|
||||
FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id)
|
||||
)
|
||||
|
|
@ -3608,6 +3609,30 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) {
|
|||
if (ok) appliedVersion = 72;
|
||||
}
|
||||
|
||||
if (appliedVersion < 70) {
|
||||
const ok = runMigrationStep("v70", () => {
|
||||
// Schema v70: per-task purpose trace (ADR-0000 restoration).
|
||||
//
|
||||
// complete-slice must mechanically verify every task served the
|
||||
// slice goal before flipping status. To do that we need the
|
||||
// goal-anchored sentence the task agent supplied when it called
|
||||
// complete_task — stored verbatim alongside the row, NULL on
|
||||
// legacy rows so older slices keep loading. complete_task refuses
|
||||
// to record a completion without this field; complete_slice
|
||||
// refuses to close while any task in the slice has NULL.
|
||||
if (!columnExists(db, "tasks", "purpose_trace")) {
|
||||
db.exec(`ALTER TABLE tasks ADD COLUMN purpose_trace TEXT`);
|
||||
}
|
||||
db.prepare(
|
||||
"INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
|
||||
).run({
|
||||
":version": 70,
|
||||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
if (ok) appliedVersion = 70;
|
||||
}
|
||||
|
||||
// Post-migration assertion: ensure critical tables created by historical
|
||||
// migrations are actually present. If a prior migration claimed success but
|
||||
// the table is missing (e.g., due to a rolled-back transaction that failed
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ export function insertTask(t) {
|
|||
milestone_id, slice_id, id, title, status, task_status, one_liner, narrative,
|
||||
verification_result, verification_status, duration, completed_at, blocker_discovered,
|
||||
deviations, known_issues, key_files, key_decisions, full_summary_md,
|
||||
description, estimate, files, verify, inputs, expected_output, observability_impact, sequence
|
||||
description, estimate, files, verify, inputs, expected_output, observability_impact, sequence,
|
||||
purpose_trace
|
||||
) VALUES (
|
||||
:milestone_id, :slice_id, :id, :title, :status, :task_status, :one_liner, :narrative,
|
||||
:verification_result, :verification_status, :duration, :completed_at, :blocker_discovered,
|
||||
:deviations, :known_issues, :key_files, :key_decisions, :full_summary_md,
|
||||
:description, :estimate, :files, :verify, :inputs, :expected_output, :observability_impact, :sequence
|
||||
:description, :estimate, :files, :verify, :inputs, :expected_output, :observability_impact, :sequence,
|
||||
:purpose_trace
|
||||
)
|
||||
ON CONFLICT(milestone_id, slice_id, id) DO UPDATE SET
|
||||
title = CASE WHEN NULLIF(:title, '') IS NOT NULL THEN :title ELSE tasks.title END,
|
||||
|
|
@ -41,7 +43,8 @@ export function insertTask(t) {
|
|||
inputs = CASE WHEN NULLIF(:inputs, '[]') IS NOT NULL THEN :inputs ELSE tasks.inputs END,
|
||||
expected_output = CASE WHEN NULLIF(:expected_output, '[]') IS NOT NULL THEN :expected_output ELSE tasks.expected_output END,
|
||||
observability_impact = CASE WHEN NULLIF(:observability_impact, '') IS NOT NULL THEN :observability_impact ELSE tasks.observability_impact END,
|
||||
sequence = :sequence`)
|
||||
sequence = :sequence,
|
||||
purpose_trace = COALESCE(:purpose_trace, tasks.purpose_trace)`)
|
||||
.run({
|
||||
":milestone_id": t.milestoneId,
|
||||
":slice_id": t.sliceId,
|
||||
|
|
@ -72,6 +75,10 @@ export function insertTask(t) {
|
|||
":expected_output": JSON.stringify(t.planning?.expectedOutput ?? []),
|
||||
":observability_impact": t.planning?.observabilityImpact ?? "",
|
||||
":sequence": t.sequence ?? 0,
|
||||
":purpose_trace":
|
||||
typeof t.purposeTrace === "string" && t.purposeTrace.trim().length > 0
|
||||
? t.purposeTrace.trim()
|
||||
: null,
|
||||
});
|
||||
if (hasTaskSpecIntent(t.planning)) {
|
||||
insertTaskSpecIfAbsent(t.milestoneId, t.sliceId, t.id, t.planning ?? {});
|
||||
|
|
@ -135,26 +142,69 @@ export function updateTaskStatus(
|
|||
taskId,
|
||||
status,
|
||||
completedAt,
|
||||
purposeTrace,
|
||||
) {
|
||||
const currentDb = _getAdapter();
|
||||
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
const taskStatus = normalizeTaskStatus(status) ?? "todo";
|
||||
// ADR-0000 (SF v70): purpose_trace is the goal-anchored sentence the
|
||||
// executor recorded when finishing the task. COALESCE keeps any value
|
||||
// previously written when callers omit it on a no-op status update.
|
||||
const traceParam =
|
||||
typeof purposeTrace === "string" && purposeTrace.trim().length > 0
|
||||
? purposeTrace.trim()
|
||||
: null;
|
||||
currentDb
|
||||
.prepare(`UPDATE tasks SET
|
||||
status = :status,
|
||||
completed_at = :completed_at,
|
||||
task_status = :task_status
|
||||
task_status = :task_status,
|
||||
purpose_trace = COALESCE(:purpose_trace, purpose_trace)
|
||||
WHERE milestone_id = :milestone_id AND slice_id = :slice_id AND id = :id`)
|
||||
.run({
|
||||
":status": status,
|
||||
":completed_at": completedAt ?? null,
|
||||
":task_status": taskStatus,
|
||||
":purpose_trace": traceParam,
|
||||
":milestone_id": milestoneId,
|
||||
":slice_id": sliceId,
|
||||
":id": taskId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the goal-anchored purpose sentence the executor named when finishing
|
||||
* a task. complete-slice consults this column to verify every task actually
|
||||
* served the slice goal before it flips the slice to complete (ADR-0000).
|
||||
*
|
||||
* Purpose: doctrine restoration — without a purpose trace per task, the
|
||||
* slice-level "did we solve the right problem" check has nothing to verify.
|
||||
*
|
||||
* Consumer: complete-task handler (on success) and complete-slice handler
|
||||
* (refuses to close when any row is NULL).
|
||||
*/
|
||||
export function updateTaskPurposeTrace(milestoneId, sliceId, taskId, trace) {
|
||||
const currentDb = _getAdapter();
|
||||
if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
if (typeof trace !== "string" || trace.trim().length === 0) {
|
||||
throw new SFError(
|
||||
SF_STALE_STATE,
|
||||
"updateTaskPurposeTrace: trace must be a non-empty string",
|
||||
);
|
||||
}
|
||||
currentDb
|
||||
.prepare(
|
||||
`UPDATE tasks SET purpose_trace = :trace
|
||||
WHERE milestone_id = :mid AND slice_id = :sid AND id = :tid`,
|
||||
)
|
||||
.run({
|
||||
":trace": trace.trim(),
|
||||
":mid": milestoneId,
|
||||
":sid": sliceId,
|
||||
":tid": taskId,
|
||||
});
|
||||
}
|
||||
|
||||
export function setTaskEscalationPending(
|
||||
milestoneId,
|
||||
sliceId,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ function makeProject() {
|
|||
id: "T01",
|
||||
title: "Task",
|
||||
status: "complete",
|
||||
// ADR-0000 (SF v70): every task must carry a goal-anchored trace
|
||||
// before complete-slice will close the parent slice.
|
||||
purposeTrace: "Served the slice-goal clause covered by this evidence fixture.",
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ test("handleCompleteTask_when_successful_records_completion_summary_evidence", a
|
|||
oneLiner: "Task finished with evidence",
|
||||
narrative: "The behavior is implemented.",
|
||||
verification: "Focused test passed.",
|
||||
// ADR-0000 (SF v70): purpose_trace is required at completion time.
|
||||
purposeTrace:
|
||||
"Served the slice-goal clause about recording structured evidence per task.",
|
||||
verificationEvidence: [
|
||||
{
|
||||
command: "npm test -- complete-task",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* complete-task-purpose-trace.test.mjs — ADR-0000 purpose-trace gate.
|
||||
*
|
||||
* Purpose: prove the SF v70 doctrine restoration:
|
||||
* 1. complete_task without purpose_trace is refused with an actionable
|
||||
* error that names the slice.goal.
|
||||
* 2. complete_task with purpose_trace persists the trace verbatim.
|
||||
* 3. complete_slice with one unmarked task is refused.
|
||||
* 4. complete_slice with all tasks marked passes.
|
||||
*
|
||||
* Consumer: execute-task agents and the complete-slice gate that closes a
|
||||
* slice only after every task served the slice goal (per ADR-0000).
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, test } from "vitest";
|
||||
import {
|
||||
closeDatabase,
|
||||
getTask,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
openDatabase,
|
||||
} from "../sf-db.js";
|
||||
import { handleCompleteSlice } from "../tools/complete-slice.js";
|
||||
import { handleCompleteTask } from "../tools/complete-task.js";
|
||||
|
||||
const tmpDirs = [];
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
while (tmpDirs.length > 0) {
|
||||
const dir = tmpDirs.pop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const SLICE_GOAL =
|
||||
"Restore per-task purpose trace so complete-slice can mechanically verify intent.";
|
||||
|
||||
function makeProject() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-purpose-trace-"));
|
||||
tmpDirs.push(dir);
|
||||
mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01", "tasks"), {
|
||||
recursive: true,
|
||||
});
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({ id: "M001", title: "Doctrine restoration", status: "active" });
|
||||
insertSlice({
|
||||
milestoneId: "M001",
|
||||
id: "S01",
|
||||
title: "Purpose trace slice",
|
||||
status: "pending",
|
||||
planning: {
|
||||
goal: SLICE_GOAL,
|
||||
},
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
function baseTaskParams(overrides = {}) {
|
||||
return {
|
||||
milestoneId: "M001",
|
||||
sliceId: "S01",
|
||||
taskId: "T01",
|
||||
oneLiner: "Add purpose_trace column and gate",
|
||||
narrative: "Wired ALTER TABLE + complete_task gate end to end.",
|
||||
verification: "Focused vitest covering both refusal paths passed.",
|
||||
verificationEvidence: [
|
||||
{
|
||||
command: "npx vitest run complete-task-purpose-trace.test.mjs",
|
||||
exitCode: 0,
|
||||
verdict: "passed",
|
||||
durationMs: 10,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("complete_task refuses without purpose_trace and names the slice goal", async () => {
|
||||
const project = makeProject();
|
||||
|
||||
const result = await handleCompleteTask(baseTaskParams(), project);
|
||||
|
||||
assert.ok(result.error, "expected an error payload");
|
||||
assert.match(
|
||||
result.error,
|
||||
/purpose_trace is required/i,
|
||||
"error must call out purpose_trace requirement",
|
||||
);
|
||||
assert.match(
|
||||
result.error,
|
||||
/ADR-0000/,
|
||||
"error must reference the doctrine",
|
||||
);
|
||||
assert.ok(
|
||||
result.error.includes(SLICE_GOAL),
|
||||
`error must quote slice.goal verbatim so the agent can anchor against it; got: ${result.error}`,
|
||||
);
|
||||
// DB must not have flipped status when the call was refused.
|
||||
const stored = getTask("M001", "S01", "T01");
|
||||
assert.equal(
|
||||
stored,
|
||||
null,
|
||||
"refused complete_task must not insert a row",
|
||||
);
|
||||
});
|
||||
|
||||
test("complete_task persists purpose_trace verbatim when supplied", async () => {
|
||||
const project = makeProject();
|
||||
const trace =
|
||||
"Served slice.goal clause: 'mechanically verify intent' by adding the per-row trace column.";
|
||||
|
||||
const result = await handleCompleteTask(
|
||||
baseTaskParams({ purposeTrace: trace }),
|
||||
project,
|
||||
);
|
||||
|
||||
assert.equal(result.error, undefined, `unexpected error: ${result.error}`);
|
||||
const stored = getTask("M001", "S01", "T01");
|
||||
assert.equal(stored.status, "complete");
|
||||
assert.equal(
|
||||
stored.purpose_trace,
|
||||
trace,
|
||||
"purpose_trace must be persisted verbatim",
|
||||
);
|
||||
});
|
||||
|
||||
test("complete_slice refuses when any task is missing purpose_trace", async () => {
|
||||
const project = makeProject();
|
||||
// T01 done with trace; T02 done WITHOUT trace by bypassing the handler
|
||||
// (writes a row directly to simulate a legacy/migration-leftover state).
|
||||
const trace = "T01 served the trace-column clause of the slice goal.";
|
||||
const okResult = await handleCompleteTask(
|
||||
baseTaskParams({ purposeTrace: trace }),
|
||||
project,
|
||||
);
|
||||
assert.equal(okResult.error, undefined, `T01 setup failed: ${okResult.error}`);
|
||||
|
||||
// Use the low-level insertTask to forge a complete-but-untraced row,
|
||||
// matching the "legacy NULL row" condition the migration explicitly
|
||||
// allows. Importing here keeps the simulation isolated from the main
|
||||
// handler path under test.
|
||||
const { insertTask } = await import("../sf-db.js");
|
||||
insertTask({
|
||||
id: "T02",
|
||||
sliceId: "S01",
|
||||
milestoneId: "M001",
|
||||
title: "Legacy task without trace",
|
||||
status: "complete",
|
||||
oneLiner: "Legacy",
|
||||
narrative: "Legacy",
|
||||
verificationResult: "Legacy",
|
||||
// purposeTrace intentionally omitted to simulate a NULL row
|
||||
});
|
||||
|
||||
const result = await handleCompleteSlice(
|
||||
{
|
||||
milestoneId: "M001",
|
||||
sliceId: "S01",
|
||||
sliceTitle: "Purpose trace slice",
|
||||
oneLiner: "Did the thing.",
|
||||
narrative: "Did the thing for the doctrine.",
|
||||
verification: "All gates passed.",
|
||||
uatContent: "## UAT Type\n\n- UAT mode: artifact-driven\n- Why: tests cover.\n",
|
||||
operationalReadiness: "Ready.",
|
||||
},
|
||||
project,
|
||||
);
|
||||
|
||||
assert.ok(result.error, "expected refusal payload");
|
||||
assert.match(
|
||||
result.error,
|
||||
/purpose_trace/i,
|
||||
"error must name purpose_trace",
|
||||
);
|
||||
assert.match(
|
||||
result.error,
|
||||
/T02/,
|
||||
"error must identify the untraced task id so the operator can fix it",
|
||||
);
|
||||
});
|
||||
|
||||
test("complete_slice passes when every task has a purpose_trace", async () => {
|
||||
const project = makeProject();
|
||||
|
||||
const t01 = await handleCompleteTask(
|
||||
baseTaskParams({
|
||||
taskId: "T01",
|
||||
purposeTrace: "T01 served the trace-column clause.",
|
||||
}),
|
||||
project,
|
||||
);
|
||||
assert.equal(t01.error, undefined, `T01: ${t01.error}`);
|
||||
|
||||
const t02 = await handleCompleteTask(
|
||||
baseTaskParams({
|
||||
taskId: "T02",
|
||||
purposeTrace: "T02 served the gate-refusal clause.",
|
||||
}),
|
||||
project,
|
||||
);
|
||||
assert.equal(t02.error, undefined, `T02: ${t02.error}`);
|
||||
|
||||
const result = await handleCompleteSlice(
|
||||
{
|
||||
milestoneId: "M001",
|
||||
sliceId: "S01",
|
||||
sliceTitle: "Purpose trace slice",
|
||||
oneLiner: "Did the thing.",
|
||||
narrative: "Did the thing for the doctrine.",
|
||||
verification: "All gates passed.",
|
||||
uatContent:
|
||||
"## UAT Type\n\n- UAT mode: artifact-driven\n- Why: tests cover.\n",
|
||||
operationalReadiness: "Ready.",
|
||||
},
|
||||
project,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.error,
|
||||
undefined,
|
||||
`complete_slice should pass when every task is traced; got: ${result.error}`,
|
||||
);
|
||||
assert.equal(result.sliceId, "S01");
|
||||
});
|
||||
|
|
@ -273,7 +273,13 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
|
|||
const version = db
|
||||
.prepare("SELECT MAX(version) AS version FROM schema_version")
|
||||
.get();
|
||||
assert.equal(version.version, 69);
|
||||
assert.equal(version.version, 72);
|
||||
// v70: tasks gained purpose_trace column (ADR-0000 doctrine restoration)
|
||||
const taskColumnsAfter = db.prepare("PRAGMA table_info(tasks)").all();
|
||||
assert.ok(
|
||||
taskColumnsAfter.some((row) => row.name === "purpose_trace"),
|
||||
"purpose_trace column should exist after v70 migration",
|
||||
);
|
||||
// v61: intent_chapters table exists
|
||||
const chaptersTable = db
|
||||
.prepare(
|
||||
|
|
|
|||
|
|
@ -460,6 +460,26 @@ export async function handleCompleteSlice(paramsInput, basePath) {
|
|||
.join(", ");
|
||||
return { error: `incomplete tasks: ${incompleteIds}` };
|
||||
}
|
||||
// ADR-0000 (SF v70) — purpose-trace gate ────────────────────────────────
|
||||
// complete-slice cannot flip a slice to complete unless every task
|
||||
// recorded a goal-anchored purpose_trace at complete_task time. This is
|
||||
// the mechanical check that "did all tasks actually serve the slice
|
||||
// goal" — without it the slice contract is decoupled from per-task
|
||||
// intent and the doctrine collapses to vibes.
|
||||
const tasksMissingTrace = tasks.filter((t) => {
|
||||
const trace = t?.purpose_trace ?? t?.purposeTrace;
|
||||
return typeof trace !== "string" || trace.trim().length === 0;
|
||||
});
|
||||
if (tasksMissingTrace.length > 0) {
|
||||
const missingIds = tasksMissingTrace.map((t) => t.id).join(", ");
|
||||
return {
|
||||
error:
|
||||
`cannot complete slice ${params.sliceId}: ${tasksMissingTrace.length} ` +
|
||||
`task(s) have no purpose_trace (ADR-0000): ${missingIds}. ` +
|
||||
`Reopen each task and re-run complete_task with the slice-goal sentence ` +
|
||||
`that change served, then retry complete_slice.`,
|
||||
};
|
||||
}
|
||||
// Render summary markdown
|
||||
const summaryMd = renderSliceSummaryMarkdown(params);
|
||||
// Resolve and write summary to disk
|
||||
|
|
|
|||
|
|
@ -133,6 +133,17 @@ function normalizeCompleteTaskParams(params) {
|
|||
params.verification,
|
||||
"verification",
|
||||
),
|
||||
// ADR-0000 (SF v70): goal-anchored purpose sentence. Required so
|
||||
// complete-slice can mechanically verify every task served the slice
|
||||
// goal before flipping status. Normalize aggressively but keep raw
|
||||
// validation to the handler so we can surface the slice.goal in the
|
||||
// error message.
|
||||
purposeTrace:
|
||||
typeof params.purposeTrace === "string"
|
||||
? params.purposeTrace.trim()
|
||||
: typeof params.purpose_trace === "string"
|
||||
? params.purpose_trace.trim()
|
||||
: "",
|
||||
keyFiles: normalizeStringListParam(params.keyFiles, "keyFiles"),
|
||||
keyDecisions: normalizeStringListParam(params.keyDecisions, "keyDecisions"),
|
||||
deviations:
|
||||
|
|
@ -305,6 +316,28 @@ export async function handleCompleteTask(paramsInput, basePath) {
|
|||
error: `cannot complete task in a closed slice: ${params.sliceId} (status: ${slice.status})`,
|
||||
};
|
||||
}
|
||||
// ADR-0000 (SF v70) — purpose trace gate ───────────────────────────────
|
||||
// execute-task must record the goal-anchored sentence the change served
|
||||
// before complete_task is accepted. complete-slice consults this column
|
||||
// to mechanically verify every task actually served the slice goal.
|
||||
// Refuse with the slice.goal quoted verbatim so the agent has the
|
||||
// sentence to anchor against.
|
||||
if (
|
||||
typeof params.purposeTrace !== "string" ||
|
||||
params.purposeTrace.trim().length === 0
|
||||
) {
|
||||
const sliceGoal =
|
||||
(slice && typeof slice.goal === "string" && slice.goal.trim()) || "";
|
||||
const goalClause = sliceGoal
|
||||
? `slice.goal = "${sliceGoal}"`
|
||||
: `slice.goal is empty for ${params.sliceId} — record what the slice was supposed to do`;
|
||||
return {
|
||||
error:
|
||||
`purpose_trace is required (ADR-0000): name the sentence or clause of the slice goal this change served. ` +
|
||||
`${goalClause}. ` +
|
||||
`Quote or paraphrase that clause in 1-3 sentences and retry complete_task with purpose_trace set.`,
|
||||
};
|
||||
}
|
||||
const existingTask = getTask(
|
||||
params.milestoneId,
|
||||
params.sliceId,
|
||||
|
|
@ -394,6 +427,8 @@ export async function handleCompleteTask(paramsInput, basePath) {
|
|||
keyDecisions: params.keyDecisions ?? [],
|
||||
fullSummaryMd: summaryMd,
|
||||
verificationStatus,
|
||||
// ADR-0000 (SF v70): persist the goal-anchored sentence.
|
||||
purposeTrace: params.purposeTrace,
|
||||
});
|
||||
for (const evidence of params.verificationEvidence ?? []) {
|
||||
insertVerificationEvidence({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue