From a303b5db292d5e1efe69221c9d4b387211039add Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 18:42:55 +0200 Subject: [PATCH] feat(uok): add purpose-coherence-gate pre-dispatch gate (ADR-0000) Restores the eight-PDD purpose gate at the autonomous-loop boundary required by ADR-0000 (SF is a purpose-to-software compiler). The gate walks milestone vision -> slice.traces_vision_fragment -> task.purpose_trace before every dispatch and refuses to proceed when the purpose chain is broken at the vision root (degraded-vision). - New uok/purpose-coherence.js with a pure verdict function and a DB-backed adapter. Reads vision/trace columns directly via SQL so pre-P2/P3 schema migrations are tolerated. - Wired into auto/phases-pre-dispatch.js alongside resource-version- guard, pre-dispatch-health-gate, and planning-flow-gate. Fires on every pre-dispatch turn and emits to the existing trace JSONL. - Outcome ladder: fail (vision missing -> pause loop), warn (trace columns missing or NULL -> surface but allow dispatch so legacy DBs don't hard-break on day one), pass (full chain). - Tests in tests/uok-purpose-coherence.test.mjs cover the four contracted states plus the column-missing downgrade path on a pre-migration schema. Refs: ADR-0000. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../extensions/sf/auto/phases-pre-dispatch.js | 64 ++++ .../sf/tests/uok-purpose-coherence.test.mjs | 310 ++++++++++++++++++ .../extensions/sf/uok/purpose-coherence.js | 263 +++++++++++++++ 3 files changed, 637 insertions(+) create mode 100644 src/resources/extensions/sf/tests/uok-purpose-coherence.test.mjs create mode 100644 src/resources/extensions/sf/uok/purpose-coherence.js diff --git a/src/resources/extensions/sf/auto/phases-pre-dispatch.js b/src/resources/extensions/sf/auto/phases-pre-dispatch.js index ac0861c33..6e66355d4 100644 --- a/src/resources/extensions/sf/auto/phases-pre-dispatch.js +++ b/src/resources/extensions/sf/auto/phases-pre-dispatch.js @@ -66,6 +66,7 @@ import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js"; import { selectInlineFixCandidates } from "../self-feedback-drain.js"; import { recordSelfFeedback } from "../self-feedback.js"; import { + _getAdapter, checkpointWal, getMilestoneSlices, getSliceTaskCounts, @@ -94,6 +95,10 @@ import { isMissingFinalizedContextResult, } from "../uok/plan.js"; import { buildUokProgressEvent } from "../uok/progress-event.js"; +import { + evaluatePurposeCoherence, + PURPOSE_COHERENCE_GATE_ID, +} from "../uok/purpose-coherence.js"; import { clearUnitRuntimeRecord, writeUnitRuntimeRecord, @@ -342,6 +347,65 @@ export async function runPreDispatch(ic, loopState) { milestoneId: state.activeMilestone?.id ?? undefined, }); } + // ── purpose-coherence-gate (ADR-0000 restoration) ────────────────── + // Walks the purpose chain: milestone vision → slice traces-vision → + // task purpose-trace. Fails (blocks dispatch) only when vision is + // missing/empty; downgrades to "warn" when the trace columns added + // by P2/P3 are absent or NULL so legacy DBs still progress. + if (state.activeMilestone?.id && isDbAvailable()) { + try { + const verdict = evaluatePurposeCoherence({ + state, + db: _getAdapter(), + }); + await runPreDispatchGate({ + gateId: PURPOSE_COHERENCE_GATE_ID, + gateType: "policy", + outcome: verdict.outcome, + failureClass: verdict.failureClass, + rationale: verdict.rationale, + findings: verdict.findings, + milestoneId: state.activeMilestone?.id ?? undefined, + sliceId: state.activeSlice?.id ?? undefined, + taskId: state.activeTask?.id ?? undefined, + }); + if (verdict.outcome === "fail") { + ctx.ui.notify( + `Purpose-coherence gate refused dispatch: ${verdict.rationale}`, + "error", + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { + phase: "exit", + reason: "purpose-coherence-gate-failed", + }); + return { action: "break", reason: "purpose-coherence-gate-failed" }; + } + if (verdict.outcome === "warn") { + // Surface the downgrade so the operator sees the chain gap, but + // do not block — this is the documented day-one allowance. + ctx.ui.notify( + `Purpose-coherence warn: ${verdict.rationale}`, + "warning", + ); + } + } catch (e) { + // Gate evaluation must never break the loop. Record manual-attention + // and continue; the underlying DB issue will surface via other gates. + await runPreDispatchGate({ + gateId: PURPOSE_COHERENCE_GATE_ID, + gateType: "policy", + outcome: "manual-attention", + failureClass: "manual-attention", + rationale: "purpose-coherence gate threw unexpectedly", + findings: String(e), + milestoneId: state.activeMilestone?.id ?? undefined, + }); + logWarning("engine", "Purpose-coherence gate threw unexpectedly", { + error: String(e), + }); + } + } deps.syncCmuxSidebar(prefs, state); let mid = state.activeMilestone?.id; let midTitle = state.activeMilestone?.title; diff --git a/src/resources/extensions/sf/tests/uok-purpose-coherence.test.mjs b/src/resources/extensions/sf/tests/uok-purpose-coherence.test.mjs new file mode 100644 index 000000000..1b8eb3a79 --- /dev/null +++ b/src/resources/extensions/sf/tests/uok-purpose-coherence.test.mjs @@ -0,0 +1,310 @@ +/** + * uok-purpose-coherence.test.mjs — verify the purpose-coherence-gate + * verdict logic (ADR-0000 restoration, P4). + * + * Two layers: + * 1. computePurposeCoherenceVerdict — pure function over the + * milestone/slice/task trace inputs. Covers the four states + * named in the parent contract: missing vision (fail), + * vision+slice-trace-null (warn), vision+slice-ok+task-null + * (warn), full chain (pass). + * 2. evaluatePurposeCoherence — DB-backed adapter. Exercises the + * "column doesn't exist on legacy DB" downgrade path that the + * gate must survive while P2/P3 migrations land in parallel. + */ +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + _getAdapter, + closeDatabase, + openDatabase, +} from "../sf-db.js"; +import { + computePurposeCoherenceVerdict, + evaluatePurposeCoherence, +} from "../uok/purpose-coherence.js"; + +const tmpRoots = []; + +afterEach(() => { + closeDatabase(); + for (const dir of tmpRoots.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const root = mkdtempSync(join(tmpdir(), "sf-purpose-coherence-")); + mkdirSync(join(root, ".sf"), { recursive: true }); + tmpRoots.push(root); + return root; +} + +// ─── computePurposeCoherenceVerdict (pure) ──────────────────────────── + +describe("computePurposeCoherenceVerdict", () => { + test("missing milestone returns fail", () => { + const verdict = computePurposeCoherenceVerdict({ milestone: null }); + expect(verdict.outcome).toBe("fail"); + expect(verdict.rationale).toMatch(/milestone vision missing/); + }); + + test("milestone with empty vision returns fail", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "" }, + }); + expect(verdict.outcome).toBe("fail"); + expect(verdict.failureClass).toBe("policy"); + expect(verdict.rationale).toMatch(/milestone vision missing/); + expect(verdict.findings).toMatch(/M001/); + }); + + test("milestone with whitespace-only vision returns fail", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: " \n " }, + }); + expect(verdict.outcome).toBe("fail"); + }); + + test("vision present, no active slice or task returns pass", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "Deliver X" }, + }); + expect(verdict.outcome).toBe("pass"); + expect(verdict.failureClass).toBe("none"); + }); + + test("vision present, slice trace NULL returns warn (allow)", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "Deliver X" }, + slice: { id: "S01", traces_vision_fragment: null }, + }); + expect(verdict.outcome).toBe("warn"); + expect(verdict.rationale).toMatch( + /slice goal does not trace to milestone vision/, + ); + expect(verdict.findings).toMatch(/S01/); + }); + + test("vision present, slice trace empty string returns warn", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "Deliver X" }, + slice: { id: "S01", traces_vision_fragment: "" }, + }); + expect(verdict.outcome).toBe("warn"); + }); + + test("vision present, slice trace column missing returns warn", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "Deliver X" }, + slice: { id: "S01" }, + schema: { sliceTraceColumnMissing: true }, + }); + expect(verdict.outcome).toBe("warn"); + expect(verdict.rationale).toMatch( + /slice\.traces_vision_fragment column not yet present/, + ); + }); + + test("vision present, slice trace ok, task trace NULL returns warn", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "Deliver X" }, + slice: { id: "S01", traces_vision_fragment: "frag-1" }, + task: { id: "T01", purpose_trace: null }, + }); + expect(verdict.outcome).toBe("warn"); + expect(verdict.rationale).toMatch(/task does not trace to slice goal/); + expect(verdict.findings).toMatch(/T01/); + }); + + test("vision present, slice trace ok, task trace column missing returns warn", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "Deliver X" }, + slice: { id: "S01", traces_vision_fragment: "frag-1" }, + task: { id: "T01" }, + schema: { taskTraceColumnMissing: true }, + }); + expect(verdict.outcome).toBe("warn"); + expect(verdict.rationale).toMatch( + /task\.purpose_trace column not yet present/, + ); + }); + + test("full chain present returns pass", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "Deliver X" }, + slice: { id: "S01", traces_vision_fragment: "frag-1" }, + task: { id: "T01", purpose_trace: "task-trace-1" }, + }); + expect(verdict.outcome).toBe("pass"); + expect(verdict.failureClass).toBe("none"); + expect(verdict.rationale).toMatch(/purpose chain intact/); + }); + + // Belt-and-braces: even if a task has a trace, a NULL slice trace still + // breaks the chain at the slice level — we must report the slice gap. + test("task trace present but slice trace null still warns about slice", () => { + const verdict = computePurposeCoherenceVerdict({ + milestone: { id: "M001", vision: "Deliver X" }, + slice: { id: "S01", traces_vision_fragment: null }, + task: { id: "T01", purpose_trace: "task-trace-1" }, + }); + expect(verdict.outcome).toBe("warn"); + expect(verdict.rationale).toMatch(/slice/); + }); +}); + +// ─── evaluatePurposeCoherence (DB-backed) ──────────────────────────── + +/** + * Open a vanilla SF DB. This is the *legacy* shape for our purposes: + * the schema created by sf-db.openDatabase() does NOT yet include + * `slices.traces_vision_fragment` or `tasks.purpose_trace` — those + * are added by the parallel P2/P3 migrations. Opening unaltered + * exercises the missing-column downgrade path the gate must survive. + */ +function makeLegacyDb() { + const base = makeProject(); + openDatabase(join(base, ".sf", "sf.db")); + const db = _getAdapter(); + return { base, db }; +} + +/** + * Open an SF DB and bolt on the columns the parallel migrations will + * eventually add. Mirrors the post-P2/P3 schema so we can exercise the + * "trace column present, value NULL" and "full chain present" branches. + */ +function makeForwardDb() { + const base = makeProject(); + openDatabase(join(base, ".sf", "sf.db")); + const db = _getAdapter(); + db.exec( + "ALTER TABLE slices ADD COLUMN traces_vision_fragment TEXT DEFAULT NULL", + ); + db.exec("ALTER TABLE tasks ADD COLUMN purpose_trace TEXT DEFAULT NULL"); + return { base, db }; +} + +describe("evaluatePurposeCoherence (DB-backed)", () => { + test("vision present, columns missing on legacy DB, slice active → warn (allow)", () => { + const { db } = makeLegacyDb(); + db.prepare( + "INSERT INTO milestones (id, vision) VALUES (:id, :v)", + ).run({ ":id": "M001", ":v": "Deliver X" }); + db.prepare( + "INSERT INTO slices (milestone_id, id) VALUES (:mid, :sid)", + ).run({ ":mid": "M001", ":sid": "S01" }); + const verdict = evaluatePurposeCoherence({ + state: { + activeMilestone: { id: "M001" }, + activeSlice: { id: "S01" }, + activeTask: null, + }, + db, + }); + expect(verdict.outcome).toBe("warn"); + expect(verdict.rationale).toMatch(/traces_vision_fragment column/); + }); + + test("vision missing on legacy DB → fail", () => { + const { db } = makeLegacyDb(); + db.prepare( + "INSERT INTO milestones (id, vision) VALUES (:id, :v)", + ).run({ ":id": "M001", ":v": "" }); + const verdict = evaluatePurposeCoherence({ + state: { + activeMilestone: { id: "M001" }, + activeSlice: null, + activeTask: null, + }, + db, + }); + expect(verdict.outcome).toBe("fail"); + }); + + test("full chain present on forward DB → pass", () => { + const { db } = makeForwardDb(); + db.prepare( + "INSERT INTO milestones (id, vision) VALUES (:id, :v)", + ).run({ ":id": "M001", ":v": "Deliver X" }); + db.prepare( + "INSERT INTO slices (milestone_id, id, traces_vision_fragment) VALUES (:mid, :sid, :tv)", + ).run({ ":mid": "M001", ":sid": "S01", ":tv": "frag-1" }); + db.prepare( + "INSERT INTO tasks (milestone_id, slice_id, id, purpose_trace) VALUES (:mid, :sid, :tid, :pt)", + ).run({ + ":mid": "M001", + ":sid": "S01", + ":tid": "T01", + ":pt": "trace-1", + }); + const verdict = evaluatePurposeCoherence({ + state: { + activeMilestone: { id: "M001" }, + activeSlice: { id: "S01" }, + activeTask: { id: "T01" }, + }, + db, + }); + expect(verdict.outcome).toBe("pass"); + }); + + test("vision present, slice trace NULL on forward DB → warn (slice gap)", () => { + const { db } = makeForwardDb(); + db.prepare( + "INSERT INTO milestones (id, vision) VALUES (:id, :v)", + ).run({ ":id": "M001", ":v": "Deliver X" }); + db.prepare( + "INSERT INTO slices (milestone_id, id, traces_vision_fragment) VALUES (:mid, :sid, NULL)", + ).run({ ":mid": "M001", ":sid": "S01" }); + const verdict = evaluatePurposeCoherence({ + state: { + activeMilestone: { id: "M001" }, + activeSlice: { id: "S01" }, + activeTask: null, + }, + db, + }); + expect(verdict.outcome).toBe("warn"); + expect(verdict.rationale).toMatch(/slice goal does not trace/); + }); + + test("vision + slice ok, task trace NULL on forward DB → warn (task gap)", () => { + const { db } = makeForwardDb(); + db.prepare( + "INSERT INTO milestones (id, vision) VALUES (:id, :v)", + ).run({ ":id": "M001", ":v": "Deliver X" }); + db.prepare( + "INSERT INTO slices (milestone_id, id, traces_vision_fragment) VALUES (:mid, :sid, :tv)", + ).run({ ":mid": "M001", ":sid": "S01", ":tv": "frag-1" }); + db.prepare( + "INSERT INTO tasks (milestone_id, slice_id, id, purpose_trace) VALUES (:mid, :sid, :tid, NULL)", + ).run({ ":mid": "M001", ":sid": "S01", ":tid": "T01" }); + const verdict = evaluatePurposeCoherence({ + state: { + activeMilestone: { id: "M001" }, + activeSlice: { id: "S01" }, + activeTask: { id: "T01" }, + }, + db, + }); + expect(verdict.outcome).toBe("warn"); + expect(verdict.rationale).toMatch(/task does not trace/); + }); + + test("no active milestone → fail (no purpose root)", () => { + const { db } = makeLegacyDb(); + const verdict = evaluatePurposeCoherence({ + state: { + activeMilestone: null, + activeSlice: null, + activeTask: null, + }, + db, + }); + expect(verdict.outcome).toBe("fail"); + }); +}); diff --git a/src/resources/extensions/sf/uok/purpose-coherence.js b/src/resources/extensions/sf/uok/purpose-coherence.js new file mode 100644 index 000000000..9032ad41a --- /dev/null +++ b/src/resources/extensions/sf/uok/purpose-coherence.js @@ -0,0 +1,263 @@ +/** + * uok/purpose-coherence.js — pure verdict logic for the + * `purpose-coherence-gate` pre-dispatch gate. + * + * Doctrine (ADR-0000): SF is a purpose-to-software compiler. Before every + * autonomous dispatch we walk the purpose chain — milestone vision → + * slice goal trace → task purpose trace — and refuse to dispatch when + * the chain is broken at the milestone-vision root (degraded-vision). + * + * Restoration, not new work: the eight-PDD purpose gate ADR-0000 + * already requires this; the autonomous loop just stopped enforcing it. + * + * # Outcome ladder + * + * - "fail" — milestone vision missing/empty. SF cannot dispatch + * without a purpose root; this is a hard stop. + * - "warn" — vision present, but the slice or task trace columns + * (added by parallel agents in P2/P3) are missing or NULL. We + * surface the gap to the operator but allow dispatch so legacy + * DBs / pre-migration projects don't hard-break on day one. This + * is the documented downgrade path in the parent contract. + * - "pass" — full chain present (vision + slice traces vision + + * task traces slice). No active slice / task is also a "pass": + * the gate is permissive when there is nothing to trace yet + * (e.g. dispatch is about to create the slice). + * + * # Schema-coupling guard + * + * `slice.traces_vision_fragment` lands in P2 and `task.purpose_trace` + * lands in P3 — both in parallel to this gate. The gate must run + * cleanly against legacy DBs from before those migrations apply. We + * detect the missing-column case via the better-sqlite3 "no such + * column" error and treat it the same as a NULL value (warn, do not + * fail). The columnExists() helper that schema migrations use sits in + * sf-db-schema.js and is not exported; we do not import it here both + * to avoid coupling and because catching the SqliteError is sufficient + * and survives future column moves. + */ + +/** + * Compute the verdict for the purpose-coherence-gate, given a fetcher + * that already knows how to read the relevant rows. + * + * Pure function for unit-testability. The runtime adapter + * (evaluatePurposeCoherence below) wires this up to sf-db. + * + * @param {object} input + * @param {{ id: string; vision?: string | null } | null | undefined} input.milestone + * @param {{ id: string; traces_vision_fragment?: string | null } | null | undefined} input.slice + * @param {{ id: string; purpose_trace?: string | null } | null | undefined} input.task + * @param {{ sliceTraceColumnMissing?: boolean; taskTraceColumnMissing?: boolean }} [input.schema] + * + * @returns {{ + * outcome: "pass" | "warn" | "fail", + * failureClass: "none" | "policy", + * rationale: string, + * findings?: string, + * }} + */ +export function computePurposeCoherenceVerdict(input) { + const milestone = input?.milestone ?? null; + const slice = input?.slice ?? null; + const task = input?.task ?? null; + const schema = input?.schema ?? {}; + + // 1. Milestone vision is the purpose root. Without it, SF cannot dispatch. + const vision = + typeof milestone?.vision === "string" ? milestone.vision.trim() : ""; + if (!milestone || vision.length === 0) { + return { + outcome: "fail", + failureClass: "policy", + rationale: + "milestone vision missing; SF cannot dispatch without purpose", + findings: milestone?.id + ? `milestone=${milestone.id} vision=` + : "no active milestone", + }; + } + + // 2. Slice trace. If slice is active, it should trace back to a vision + // fragment. NULL or missing column → warn (downgrade path for + // pre-migration DBs and pre-restoration slices). + if (slice) { + const sliceTrace = + typeof slice.traces_vision_fragment === "string" + ? slice.traces_vision_fragment.trim() + : ""; + if (schema.sliceTraceColumnMissing) { + return { + outcome: "warn", + failureClass: "policy", + rationale: + "slice.traces_vision_fragment column not yet present in this DB; allowing dispatch but the purpose chain is unverified", + findings: `milestone=${milestone.id} slice=${slice.id} column=traces_vision_fragment status=missing`, + }; + } + if (sliceTrace.length === 0) { + return { + outcome: "warn", + failureClass: "policy", + rationale: + "slice goal does not trace to milestone vision (run plan-milestone with vision-aware prompt)", + findings: `milestone=${milestone.id} slice=${slice.id} traces_vision_fragment=`, + }; + } + } + + // 3. Task trace. Same downgrade for missing column / NULL. + if (task) { + const taskTrace = + typeof task.purpose_trace === "string" ? task.purpose_trace.trim() : ""; + if (schema.taskTraceColumnMissing) { + return { + outcome: "warn", + failureClass: "policy", + rationale: + "task.purpose_trace column not yet present in this DB; allowing dispatch but the purpose chain is unverified", + findings: `milestone=${milestone.id} task=${task.id} column=purpose_trace status=missing`, + }; + } + if (taskTrace.length === 0) { + return { + outcome: "warn", + failureClass: "policy", + rationale: "task does not trace to slice goal", + findings: `milestone=${milestone.id} task=${task.id} purpose_trace=`, + }; + } + } + + return { + outcome: "pass", + failureClass: "none", + rationale: "purpose chain intact (milestone vision → slice → task)", + }; +} + +function isMissingColumnError(err, columnName) { + if (!err) return false; + const msg = String(err.message ?? err); + return msg.includes("no such column") && msg.includes(columnName); +} + +/** + * Read a single column for a slice; return { value, columnMissing }. + * + * Best-effort wrapper that distinguishes "column doesn't exist on this + * legacy DB" from "row missing entirely" from "value is NULL". The gate + * only differentiates the column-missing case; the rest collapse to + * NULL semantics in computePurposeCoherenceVerdict. + */ +function readSliceTrace(db, milestoneId, sliceId) { + try { + const row = db + .prepare( + "SELECT traces_vision_fragment FROM slices WHERE milestone_id = :mid AND id = :sid", + ) + .get({ ":mid": milestoneId, ":sid": sliceId }); + return { + value: row?.["traces_vision_fragment"] ?? null, + columnMissing: false, + }; + } catch (err) { + if (isMissingColumnError(err, "traces_vision_fragment")) { + return { value: null, columnMissing: true }; + } + throw err; + } +} + +function readTaskTrace(db, milestoneId, sliceId, taskId) { + try { + const row = db + .prepare( + "SELECT purpose_trace FROM tasks WHERE milestone_id = :mid AND slice_id = :sid AND id = :tid", + ) + .get({ ":mid": milestoneId, ":sid": sliceId, ":tid": taskId }); + return { value: row?.["purpose_trace"] ?? null, columnMissing: false }; + } catch (err) { + if (isMissingColumnError(err, "purpose_trace")) { + return { value: null, columnMissing: true }; + } + throw err; + } +} + +/** + * Adapter from pre-dispatch state to a verdict. Reads the DB directly + * for `vision` / `traces_vision_fragment` / `purpose_trace` because the + * legacy row mappers (rowToMilestone/Slice/Task) don't surface the new + * columns; this also lets us trap the missing-column SqliteError on + * pre-P2/P3 databases without forcing a schema bump here. + * + * @param {object} opts + * @param {{ activeMilestone: { id: string } | null, + * activeSlice: { id: string } | null, + * activeTask: { id: string } | null }} opts.state + * @param {{ prepare: Function } | null | undefined} opts.db better-sqlite3 handle + */ +export function evaluatePurposeCoherence(opts) { + const state = opts?.state; + if (!state) { + return computePurposeCoherenceVerdict({ milestone: null }); + } + const db = opts?.db ?? null; + const milestoneId = state.activeMilestone?.id ?? null; + const sliceId = state.activeSlice?.id ?? null; + const taskId = state.activeTask?.id ?? null; + + if (!milestoneId || !db) { + return computePurposeCoherenceVerdict({ + milestone: milestoneId ? { id: milestoneId, vision: null } : null, + }); + } + + let milestoneRow = null; + try { + milestoneRow = db + .prepare("SELECT id, vision FROM milestones WHERE id = :id") + .get({ ":id": milestoneId }); + } catch { + // If even the milestone row read fails (e.g. db closed mid-flight) + // degrade to "no active milestone" — the outer pre-dispatch flow + // will surface the underlying DB issue separately. + milestoneRow = null; + } + + let sliceInfo = null; + let sliceColumnMissing = false; + if (sliceId) { + const { value, columnMissing } = readSliceTrace(db, milestoneId, sliceId); + sliceInfo = { id: sliceId, traces_vision_fragment: value }; + sliceColumnMissing = columnMissing; + } + + let taskInfo = null; + let taskColumnMissing = false; + if (sliceId && taskId) { + const { value, columnMissing } = readTaskTrace( + db, + milestoneId, + sliceId, + taskId, + ); + taskInfo = { id: taskId, purpose_trace: value }; + taskColumnMissing = columnMissing; + } + + return computePurposeCoherenceVerdict({ + milestone: milestoneRow + ? { id: milestoneRow.id, vision: milestoneRow.vision } + : { id: milestoneId, vision: null }, + slice: sliceInfo, + task: taskInfo, + schema: { + sliceTraceColumnMissing: sliceColumnMissing, + taskTraceColumnMissing: taskColumnMissing, + }, + }); +} + +export const PURPOSE_COHERENCE_GATE_ID = "purpose-coherence-gate";