diff --git a/src/resources/extensions/sf/auto/phases-guards.js b/src/resources/extensions/sf/auto/phases-guards.js index 652b410f8..05d1ed643 100644 --- a/src/resources/extensions/sf/auto/phases-guards.js +++ b/src/resources/extensions/sf/auto/phases-guards.js @@ -82,8 +82,10 @@ import { resetRunawayGuardState, } from "../uok/auto-runaway-guard.js"; import { resolveUokFlags } from "../uok/flags.js"; -import { buildAutonomousUokContext } from "../uok/auto-uok-ctx.js"; -import { UokGateRunner } from "../uok/gate-runner.js"; +import { + buildAutonomousUokContext, + emitAutonomousGate, +} from "../uok/auto-uok-ctx.js"; import { emitModelAutoResolvedEvent } from "../uok/model-route-evidence.js"; import { ensurePlanV2Graph as ensurePlanningFlowGraph, @@ -405,16 +407,6 @@ export async function runGuards(ic, mid, unitType, unitId, sliceId) { planGateOutcome = "fail"; planGateRationale = `Slice ${sliceId} has no tasks defined`; } - const planGateRunner = new UokGateRunner(); - planGateRunner.register({ - id: "plan-gate", - type: "policy", - execute: async () => ({ - outcome: planGateOutcome, - failureClass: planGateOutcome === "pass" ? "none" : "input", - rationale: planGateRationale || "Plan files verified", - }), - }); // Schema-v2 run-context: this gate runs inside the autonomous // loop's guard phase, so surface/runControl are "autonomous" and // permissionProfile follows session/prefs. parentTrace points at @@ -430,18 +422,20 @@ export async function runGuards(ic, mid, unitType, unitId, sliceId) { milestoneId: mid, sliceId, }); - const planGateResult = await planGateRunner.run("plan-gate", { + const planGateResult = await emitAutonomousGate({ basePath: s.basePath, + gateId: "plan-gate", + gateType: "policy", + outcome: planGateOutcome, + failureClass: planGateOutcome === "pass" ? "none" : "input", + rationale: planGateRationale || "Plan files verified", traceId: `guard:${ic.flowId}`, turnId: `iter-${ic.iteration}`, milestoneId: mid, sliceId, unitType, unitId, - surface: v2Ctx?.surface, - runControl: v2Ctx?.runControl, - permissionProfile: v2Ctx?.permissionProfile, - parentTrace: v2Ctx?.parentTrace, + uokContext: v2Ctx, }); if (planGateResult.outcome !== "pass") { ctx.ui.notify( diff --git a/src/resources/extensions/sf/auto/phases-pre-dispatch.js b/src/resources/extensions/sf/auto/phases-pre-dispatch.js index 8f1b4c57c..ac0861c33 100644 --- a/src/resources/extensions/sf/auto/phases-pre-dispatch.js +++ b/src/resources/extensions/sf/auto/phases-pre-dispatch.js @@ -82,9 +82,11 @@ import { countChangedFiles, resetRunawayGuardState, } from "../uok/auto-runaway-guard.js"; -import { buildAutonomousUokContext } from "../uok/auto-uok-ctx.js"; +import { + buildAutonomousUokContext, + emitAutonomousGate, +} from "../uok/auto-uok-ctx.js"; import { resolveUokFlags } from "../uok/flags.js"; -import { UokGateRunner } from "../uok/gate-runner.js"; import { emitModelAutoResolvedEvent } from "../uok/model-route-evidence.js"; import { ensurePlanV2Graph as ensurePlanningFlowGraph, @@ -176,32 +178,20 @@ export async function runPreDispatch(ic, loopState) { }); const runPreDispatchGate = async (input) => { if (!uokFlags.gates) return; - const gateRunner = new UokGateRunner(); - gateRunner.register({ - id: input.gateId, - type: input.gateType, - execute: async () => ({ - outcome: input.outcome, - failureClass: input.failureClass, - rationale: input.rationale, - findings: input.findings ?? "", - }), - }); - await gateRunner.run(input.gateId, { + await emitAutonomousGate({ basePath: s.basePath, + gateId: input.gateId, + gateType: input.gateType, + outcome: input.outcome, + failureClass: input.failureClass, + rationale: input.rationale, + findings: input.findings, traceId: `pre-dispatch:${ic.flowId}`, turnId: `iter-${ic.iteration}`, - milestoneId: input.milestoneId ?? s.currentMilestoneId ?? undefined, unitType: "pre-dispatch", unitId: `iter-${ic.iteration}`, - // Schema-v2 fields propagated from the iteration-level ctx. When - // buildUokRunContext returned null (impossible for the values we - // pass today, but defensive), these stay undefined and the gate - // classifies as "legacy" as it did before this slice. - surface: v2Ctx?.surface, - runControl: v2Ctx?.runControl, - permissionProfile: v2Ctx?.permissionProfile, - parentTrace: v2Ctx?.parentTrace, + milestoneId: input.milestoneId ?? s.currentMilestoneId ?? undefined, + uokContext: v2Ctx, }); }; // Resource version guard diff --git a/src/resources/extensions/sf/tests/uok-slice-3b-ctx-propagation.test.mjs b/src/resources/extensions/sf/tests/uok-slice-3b-ctx-propagation.test.mjs new file mode 100644 index 000000000..dd667df5c --- /dev/null +++ b/src/resources/extensions/sf/tests/uok-slice-3b-ctx-propagation.test.mjs @@ -0,0 +1,502 @@ +/** + * Slice 3b of "Make UOK the SF Control Plane". + * + * Verifies that the autonomous loop's gate emissions now carry the + * schema-v2 run-context fields (surface, runControl, permissionProfile, + * parentTrace) through to the trace events that `sf headless status + * uok --json` reads — flipping the gate's coverageStatus from + * "legacy" to "ok". + * + * Layers covered here: + * 1. UokGateRunner.run reads ctx.surface/runControl/permissionProfile/ + * parentTrace and writes them into every gate_run trace event + * (unknown-gate, normal, and circuit-breaker-blocked paths). + * 2. buildAutonomousUokContext pins surface="autonomous" / runControl= + * "autonomous" and derives permissionProfile from session+prefs. + * 3. plan-slice's handlePlanSlice forwards an options.uokContext to + * sf-db-gates.insertGateRow. + */ + +import assert from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; +import { + closeDatabase, + insertMilestone, + insertSlice, + openDatabase, + updateGateCircuitBreaker, +} from "../sf-db.js"; +import { _getAdapter } from "../sf-db/sf-db-core.js"; +import { + buildAutonomousUokContext, + derivePermissionProfile, + emitAutonomousGate, +} from "../uok/auto-uok-ctx.js"; +import { UokGateRunner } from "../uok/gate-runner.js"; + +const tmpRoots = []; + +function makeProject() { + const root = mkdtempSync(join(tmpdir(), "sf-uok-slice3b-")); + mkdirSync(join(root, ".sf", "traces"), { recursive: true }); + mkdirSync(join(root, ".sf", "milestones", "M1", "slices", "S1"), { + recursive: true, + }); + mkdirSync(join(root, ".sf", "milestones", "M2", "slices", "S2"), { + recursive: true, + }); + tmpRoots.push(root); + return root; +} + +function readGateRunEvents(basePath) { + const dir = join(basePath, ".sf", "traces"); + const events = []; + for (const name of readdirSync(dir)) { + if (!name.endsWith(".jsonl")) continue; + const lines = readFileSync(join(dir, name), "utf-8") + .split("\n") + .filter(Boolean); + for (const line of lines) { + try { + const ev = JSON.parse(line); + if (ev.type === "gate_run") events.push(ev); + } catch { + /* skip malformed lines */ + } + } + } + return events; +} + +function planningMeeting() { + return { + trigger: "test", + pm: "Plan the slice.", + researcher: "Schema exists.", + partner: "Existing tools.", + combatant: "Avoid duplication.", + architect: "SQLite-backed.", + moderator: "Proceed.", + recommendedRoute: "planning", + confidenceSummary: "high", + }; +} + +beforeEach(() => { + openDatabase(":memory:"); +}); + +afterEach(() => { + closeDatabase(); + for (const dir of tmpRoots.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ─── 1. UokGateRunner.run propagates schema-v2 ctx into gate_run events ─── + +describe("UokGateRunner emits schema-v2 fields into gate_run trace events", () => { + test("normal-pass path carries surface/runControl/permissionProfile/parentTrace", async () => { + const basePath = makeProject(); + const runner = new UokGateRunner(); + runner.register({ + id: "demo-gate", + type: "verification", + execute: async () => ({ outcome: "pass", rationale: "ok" }), + }); + await runner.run("demo-gate", { + basePath, + traceId: "trace-1", + turnId: "turn-1", + unitType: "execute-task", + unitId: "M1/S1/T1", + milestoneId: "M1", + sliceId: "S1", + taskId: "T1", + surface: "autonomous", + runControl: "autonomous", + permissionProfile: "high", + parentTrace: "flow-parent", + }); + const events = readGateRunEvents(basePath); + assert.equal(events.length, 1); + const ev = events[0]; + assert.equal(ev.surface, "autonomous"); + assert.equal(ev.runControl, "autonomous"); + assert.equal(ev.permissionProfile, "high"); + assert.equal(ev.parentTrace, "flow-parent"); + assert.equal(ev.gateId, "demo-gate"); + assert.equal(ev.outcome, "pass"); + }); + + test("legacy ctx (no v2 fields) omits them from gate_run event", async () => { + const basePath = makeProject(); + const runner = new UokGateRunner(); + runner.register({ + id: "legacy-gate", + type: "verification", + execute: async () => ({ outcome: "pass", rationale: "ok" }), + }); + await runner.run("legacy-gate", { + basePath, + traceId: "trace-legacy", + turnId: "turn-1", + }); + const events = readGateRunEvents(basePath); + assert.equal(events.length, 1); + const ev = events[0]; + assert.equal(ev.surface, undefined); + assert.equal(ev.runControl, undefined); + assert.equal(ev.permissionProfile, undefined); + assert.equal(ev.parentTrace, undefined); + }); + + test("unknown-gate path also carries schema-v2 ctx fields", async () => { + const basePath = makeProject(); + const runner = new UokGateRunner(); + const result = await runner.run("not-registered", { + basePath, + traceId: "trace-unknown", + turnId: "turn-1", + surface: "autonomous", + runControl: "autonomous", + permissionProfile: "low", + parentTrace: "flow-x", + }); + assert.equal(result.outcome, "manual-attention"); + const events = readGateRunEvents(basePath); + assert.equal(events.length, 1); + assert.equal(events[0].surface, "autonomous"); + assert.equal(events[0].runControl, "autonomous"); + assert.equal(events[0].permissionProfile, "low"); + assert.equal(events[0].parentTrace, "flow-x"); + }); + + test("circuit-breaker-blocked path also carries schema-v2 ctx fields", async () => { + const basePath = makeProject(); + const runner = new UokGateRunner(); + runner.register({ + id: "cb-block-v2", + type: "verification", + execute: async () => ({ outcome: "pass", rationale: "ok" }), + }); + updateGateCircuitBreaker("cb-block-v2", { + state: "open", + failureStreak: 5, + openedAt: new Date().toISOString(), + }); + await runner.run("cb-block-v2", { + basePath, + traceId: "trace-cb", + turnId: "turn-1", + surface: "autonomous", + runControl: "autonomous", + permissionProfile: "high", + }); + const events = readGateRunEvents(basePath); + assert.equal(events.length, 1); + assert.equal(events[0].surface, "autonomous"); + assert.equal(events[0].runControl, "autonomous"); + assert.equal(events[0].permissionProfile, "high"); + assert.equal(events[0].outcome, "fail"); + }); +}); + +// ─── 2. buildAutonomousUokContext pins autonomous defaults ──────────────── + +describe("buildAutonomousUokContext autonomous-loop defaults", () => { + test("returns surface=autonomous and runControl=autonomous", () => { + const ctx = buildAutonomousUokContext({ + s: { isYolo: () => false }, + prefs: undefined, + traceId: "flow-1", + }); + assert.equal(ctx?.surface, "autonomous"); + assert.equal(ctx?.runControl, "autonomous"); + assert.equal(ctx?.permissionProfile, "high"); + assert.equal(ctx?.traceId, "flow-1"); + }); + + test("YOLO session forces permissionProfile=low", () => { + const ctx = buildAutonomousUokContext({ + s: { isYolo: () => true }, + prefs: undefined, + traceId: "flow-yolo", + }); + assert.equal(ctx?.permissionProfile, "low"); + }); + + test("prefs.permissionLevel=medium propagates to permissionProfile", () => { + const ctx = buildAutonomousUokContext({ + s: {}, + prefs: { permissionLevel: "medium" }, + traceId: "flow-med", + }); + assert.equal(ctx?.permissionProfile, "medium"); + }); + + test("prefs.permissionLevel=low or minimal propagates to permissionProfile=low", () => { + assert.equal( + buildAutonomousUokContext({ + s: {}, + prefs: { permissionLevel: "low" }, + traceId: "flow-low", + })?.permissionProfile, + "low", + ); + assert.equal( + buildAutonomousUokContext({ + s: {}, + prefs: { permissionLevel: "minimal" }, + traceId: "flow-min", + })?.permissionProfile, + "low", + ); + }); + + test("returns null when traceId is missing", () => { + assert.equal(buildAutonomousUokContext({ s: {}, prefs: {} }), null); + }); + + test("derivePermissionProfile session probe failure falls back to prefs", () => { + const throwingSession = { + isYolo: () => { + throw new Error("session probe boom"); + }, + }; + assert.equal( + derivePermissionProfile(throwingSession, { permissionLevel: "medium" }), + "medium", + ); + assert.equal(derivePermissionProfile(throwingSession, undefined), "high"); + }); +}); + +// ─── 3. emitAutonomousGate / phase wiring contract ──────────────────────── + +describe("emitAutonomousGate forwards uokContext into UokGateRunner.run", () => { + test("emitted gate_run trace event carries surface/runControl/permissionProfile/parentTrace", async () => { + const basePath = makeProject(); + const uokContext = buildAutonomousUokContext({ + s: { isYolo: () => false }, + prefs: undefined, + traceId: "pre-dispatch:flow-A", + parentTrace: "flow-A", + unitType: "pre-dispatch", + unitId: "iter-3", + }); + assert.ok(uokContext); + const result = await emitAutonomousGate({ + basePath, + gateId: "resource-version-guard", + gateType: "policy", + outcome: "pass", + failureClass: "none", + rationale: "ok", + traceId: "pre-dispatch:flow-A", + turnId: "iter-3", + unitType: "pre-dispatch", + unitId: "iter-3", + milestoneId: "M1", + uokContext, + }); + assert.equal(result.outcome, "pass"); + const events = readGateRunEvents(basePath); + assert.equal(events.length, 1); + const ev = events[0]; + assert.equal(ev.surface, "autonomous"); + assert.equal(ev.runControl, "autonomous"); + assert.equal(ev.permissionProfile, "high"); + assert.equal(ev.parentTrace, "flow-A"); + assert.equal(ev.gateId, "resource-version-guard"); + assert.equal(ev.unitType, "pre-dispatch"); + assert.equal(ev.unitId, "iter-3"); + assert.equal(ev.milestoneId, "M1"); + }); + + test("runnerOverride seam: phase code passes the same ctx every time", async () => { + // Spy: record each .run call so we can assert the phases always + // pass the v2 ctx through. This is the dependency-injection + // contract the slice 3b plan calls for. + const spy = { + calls: [], + register() {}, + async run(id, ctx) { + this.calls.push({ id, ctx }); + return { outcome: "pass", gateId: id, gateType: "policy" }; + }, + }; + const uokContext = buildAutonomousUokContext({ + s: { isYolo: () => true }, + prefs: { permissionLevel: "medium" }, + traceId: "guard:flow-B", + parentTrace: "flow-B", + }); + assert.equal(uokContext?.permissionProfile, "low"); // YOLO wins over prefs + await emitAutonomousGate({ + basePath: "/tmp/no-write", + gateId: "plan-gate", + gateType: "policy", + outcome: "pass", + traceId: "guard:flow-B", + turnId: "iter-1", + uokContext, + runnerOverride: spy, + }); + assert.equal(spy.calls.length, 1); + assert.equal(spy.calls[0].id, "plan-gate"); + assert.equal(spy.calls[0].ctx.surface, "autonomous"); + assert.equal(spy.calls[0].ctx.runControl, "autonomous"); + assert.equal(spy.calls[0].ctx.permissionProfile, "low"); + assert.equal(spy.calls[0].ctx.parentTrace, "flow-B"); + }); +}); + +// ─── 4. plan-slice forwards uokContext to insertGateRow ─────────────────── + +describe("plan-slice forwards uokContext through to insertGateRow", () => { + test("handlePlanSlice options.uokContext lands on every seeded gate row", async () => { + // Real sf-db setup so the transaction commits and we can inspect + // the seeded quality_gates rows. + closeDatabase(); + const project = makeProject(); + openDatabase(join(project, ".sf", "sf.db")); + insertMilestone({ id: "M1", title: "demo", status: "active" }); + insertSlice({ + milestoneId: "M1", + id: "S1", + title: "demo slice", + status: "pending", + }); + const { handlePlanSlice } = await import("../tools/plan-slice.js"); + const uokContext = buildAutonomousUokContext({ + s: { isYolo: () => false }, + prefs: undefined, + traceId: "flow-from-test", + parentTrace: "flow-from-test", + }); + assert.ok(uokContext, "test setup: buildAutonomousUokContext must return a ctx"); + await handlePlanSlice( + { + milestoneId: "M1", + sliceId: "S1", + goal: "do the thing", + successCriteria: "it works", + proofLevel: "tested", + integrationClosure: "merged", + observabilityImpact: "logs", + adversarialReview: { + partner: "P review", + combatant: "C review", + architect: "A review", + }, + planningMeeting: planningMeeting(), + tasks: [ + { + taskId: "T01", + title: "Task one", + description: "Do task one carefully", + estimate: "small", + files: ["src/foo.ts"], + verify: "tests pass", + inputs: ["spec"], + expectedOutput: ["green tests"], + observabilityImpact: "more logs", + }, + ], + }, + project, + { uokContext }, + ); + const db = _getAdapter(); + const rows = db + .prepare( + "SELECT gate_id, surface, run_control, permission_profile, trace_id, parent_trace FROM quality_gates WHERE milestone_id = ? AND slice_id = ? ORDER BY gate_id, task_id", + ) + .all("M1", "S1"); + assert.ok(rows.length >= 4, `expected ≥4 gate rows, got ${rows.length}`); + for (const row of rows) { + assert.equal( + row.surface, + "autonomous", + `gate ${row.gate_id} missing surface`, + ); + assert.equal(row.run_control, "autonomous"); + assert.equal(row.permission_profile, "high"); + assert.equal(row.trace_id, "flow-from-test"); + assert.equal(row.parent_trace, "flow-from-test"); + } + }); + + test("handlePlanSlice without uokContext leaves gate rows in legacy null shape", async () => { + closeDatabase(); + const project = makeProject(); + openDatabase(join(project, ".sf", "sf.db")); + insertMilestone({ id: "M2", title: "legacy", status: "active" }); + insertSlice({ + milestoneId: "M2", + id: "S2", + title: "legacy slice", + status: "pending", + }); + const { handlePlanSlice } = await import("../tools/plan-slice.js"); + await handlePlanSlice( + { + milestoneId: "M2", + sliceId: "S2", + goal: "legacy path", + successCriteria: "ok", + proofLevel: "tested", + integrationClosure: "merged", + observabilityImpact: "logs", + adversarialReview: { + partner: "P review", + combatant: "C review", + architect: "A review", + }, + planningMeeting: planningMeeting(), + tasks: [ + { + taskId: "T01", + title: "legacy task", + description: "describes legacy task", + estimate: "small", + files: ["x.ts"], + verify: "ok", + inputs: ["i"], + expectedOutput: ["o"], + }, + ], + }, + project, + // no options.uokContext + ); + const db = _getAdapter(); + const rows = db + .prepare( + "SELECT gate_id, surface, run_control, permission_profile, trace_id FROM quality_gates WHERE milestone_id = ? AND slice_id = ? ORDER BY gate_id", + ) + .all("M2", "S2"); + assert.ok(rows.length >= 4); + for (const row of rows) { + assert.equal( + row.surface, + null, + `gate ${row.gate_id} unexpectedly carries surface`, + ); + assert.equal(row.run_control, null); + assert.equal(row.permission_profile, null); + assert.equal(row.trace_id, null); + } + }); +}); diff --git a/src/resources/extensions/sf/uok/auto-uok-ctx.js b/src/resources/extensions/sf/uok/auto-uok-ctx.js index 3e89765f7..f3bdc30bc 100644 --- a/src/resources/extensions/sf/uok/auto-uok-ctx.js +++ b/src/resources/extensions/sf/uok/auto-uok-ctx.js @@ -9,6 +9,7 @@ * Slice 3b of "Make UOK the SF Control Plane". */ +import { UokGateRunner } from "./gate-runner.js"; import { buildUokRunContext } from "./run-context.js"; /** @@ -76,3 +77,67 @@ export function buildAutonomousUokContext(opts) { taskId: opts.taskId, }); } + +/** + * Emit a one-shot UOK gate via UokGateRunner, with the schema-v2 + * run-context already attached. + * + * Why this exists: the autonomous-loop phases register a gate that + * just returns the verdict the phase already computed (it's a recording + * gate, not a decision gate), then immediately invokes runner.run. + * Centralizing that pattern means every call site forwards the same + * surface/runControl/permissionProfile/parentTrace fields without + * having to duplicate the 6-line setup, which is what was making the + * phases drift to "legacy" before this slice. + * + * The optional UokGateRunner argument lets tests inject a mock; the + * default constructs a fresh runner per emission (matches the + * existing pre-dispatch pattern). + * + * @param {object} opts + * @param {string} opts.basePath .sf base path for trace writes. + * @param {string} opts.gateId Gate id (e.g. "pre-dispatch-health-gate"). + * @param {string} opts.gateType UOK gate type ("policy", "execution", ...). + * @param {string} opts.outcome Decision: "pass" / "fail" / "manual-attention". + * @param {string} [opts.failureClass] Failure class when outcome != "pass". + * @param {string} [opts.rationale] + * @param {string} [opts.findings] + * @param {string} opts.traceId Phase trace id. + * @param {string} opts.turnId Per-iteration turn id. + * @param {object} [opts.uokContext] Schema-v2 ctx (from buildAutonomousUokContext). + * @param {string} [opts.unitType] + * @param {string} [opts.unitId] + * @param {string} [opts.milestoneId] + * @param {string} [opts.sliceId] + * @param {string} [opts.taskId] + * @param {UokGateRunner} [opts.runnerOverride] Test seam. + * + * @returns The runner's result object. + */ +export async function emitAutonomousGate(opts) { + const runner = opts.runnerOverride ?? new UokGateRunner(); + runner.register({ + id: opts.gateId, + type: opts.gateType, + execute: async () => ({ + outcome: opts.outcome, + failureClass: opts.failureClass, + rationale: opts.rationale, + findings: opts.findings ?? "", + }), + }); + return runner.run(opts.gateId, { + basePath: opts.basePath, + traceId: opts.traceId, + turnId: opts.turnId, + milestoneId: opts.milestoneId, + unitType: opts.unitType, + unitId: opts.unitId, + sliceId: opts.sliceId, + taskId: opts.taskId, + surface: opts.uokContext?.surface, + runControl: opts.uokContext?.runControl, + permissionProfile: opts.uokContext?.permissionProfile, + parentTrace: opts.uokContext?.parentTrace, + }); +}