merge(P4): purpose-coherence-gate before every dispatch (ADR-0000)

This commit is contained in:
Mikael Hugo 2026-05-15 18:45:17 +02:00
commit 3b83f0898b
3 changed files with 637 additions and 0 deletions

View file

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

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

View 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";