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:
Mikael Hugo 2026-05-15 18:43:26 +02:00
parent fa657f2523
commit 3c416162b2
10 changed files with 389 additions and 5 deletions

View file

@ -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({

View file

@ -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,
});
}

View file

@ -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

View file

@ -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,

View file

@ -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;
}

View file

@ -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",

View file

@ -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");
});

View file

@ -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(

View file

@ -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

View file

@ -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({