merge(P4): purpose-coherence-gate before every dispatch (ADR-0000)
This commit is contained in:
commit
3b83f0898b
3 changed files with 637 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
310
src/resources/extensions/sf/tests/uok-purpose-coherence.test.mjs
Normal file
310
src/resources/extensions/sf/tests/uok-purpose-coherence.test.mjs
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
263
src/resources/extensions/sf/uok/purpose-coherence.js
Normal file
263
src/resources/extensions/sf/uok/purpose-coherence.js
Normal file
|
|
@ -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=<empty>`
|
||||
: "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=<null>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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=<null>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
Loading…
Add table
Reference in a new issue