diff --git a/src/resources/extensions/sf/bootstrap/db-tools.js b/src/resources/extensions/sf/bootstrap/db-tools.js index e4b201970..17633bb4e 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.js +++ b/src/resources/extensions/sf/bootstrap/db-tools.js @@ -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({ diff --git a/src/resources/extensions/sf/sf-db/sf-db-core.js b/src/resources/extensions/sf/sf-db/sf-db-core.js index 6116a34e3..5d7355d88 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-core.js +++ b/src/resources/extensions/sf/sf-db/sf-db-core.js @@ -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, }); } diff --git a/src/resources/extensions/sf/sf-db/sf-db-schema.js b/src/resources/extensions/sf/sf-db/sf-db-schema.js index ac319aa6b..225776669 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-schema.js +++ b/src/resources/extensions/sf/sf-db/sf-db-schema.js @@ -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 diff --git a/src/resources/extensions/sf/sf-db/sf-db-tasks.js b/src/resources/extensions/sf/sf-db/sf-db-tasks.js index e960f0c67..5570a385b 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-tasks.js +++ b/src/resources/extensions/sf/sf-db/sf-db-tasks.js @@ -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, diff --git a/src/resources/extensions/sf/tests/complete-slice-evidence.test.mjs b/src/resources/extensions/sf/tests/complete-slice-evidence.test.mjs index 93ee1a7b8..4b44be716 100644 --- a/src/resources/extensions/sf/tests/complete-slice-evidence.test.mjs +++ b/src/resources/extensions/sf/tests/complete-slice-evidence.test.mjs @@ -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; } diff --git a/src/resources/extensions/sf/tests/complete-task-evidence.test.mjs b/src/resources/extensions/sf/tests/complete-task-evidence.test.mjs index 794b62ce3..1f352928f 100644 --- a/src/resources/extensions/sf/tests/complete-task-evidence.test.mjs +++ b/src/resources/extensions/sf/tests/complete-task-evidence.test.mjs @@ -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", diff --git a/src/resources/extensions/sf/tests/complete-task-purpose-trace.test.mjs b/src/resources/extensions/sf/tests/complete-task-purpose-trace.test.mjs new file mode 100644 index 000000000..e30c60898 --- /dev/null +++ b/src/resources/extensions/sf/tests/complete-task-purpose-trace.test.mjs @@ -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"); +}); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index 5a8740b67..6b5abce63 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -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( diff --git a/src/resources/extensions/sf/tools/complete-slice.js b/src/resources/extensions/sf/tools/complete-slice.js index 430228b99..47e6ecfd0 100644 --- a/src/resources/extensions/sf/tools/complete-slice.js +++ b/src/resources/extensions/sf/tools/complete-slice.js @@ -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 diff --git a/src/resources/extensions/sf/tools/complete-task.js b/src/resources/extensions/sf/tools/complete-task.js index 7304155a8..1332de16f 100644 --- a/src/resources/extensions/sf/tools/complete-task.js +++ b/src/resources/extensions/sf/tools/complete-task.js @@ -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({