test(uok,slice-3b): cover ctx propagation through gate-runner, phases, plan-slice

Adds focused unit tests for the slice-3b wiring:
  - UokGateRunner.run emits surface/runControl/permissionProfile/
    parentTrace on all three trace paths (normal, unknown-gate,
    circuit-breaker-blocked) and omits them when absent.
  - buildAutonomousUokContext pins surface=autonomous + runControl=
    autonomous and derives permissionProfile from session/prefs
    (YOLO → low, prefs.permissionLevel honored, "high" default).
  - emitAutonomousGate forwards the schema-v2 ctx into UokGateRunner
    (covers the phases-pre-dispatch / phases-guards call sites via
    the new shared helper).
  - handlePlanSlice options.uokContext lands on every seeded Q3-Q8
    quality_gates row; without it, rows stay in the legacy null shape.

Refactors phases-pre-dispatch and phases-guards to call the new
emitAutonomousGate helper so the three sites stay in sync going
forward. phases-finalize keeps its inline UokGateRunner because the
verification gate's execute callback isn't a static verdict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-14 18:33:26 +02:00
parent 95ea9eecee
commit 7794208340
4 changed files with 591 additions and 40 deletions

View file

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

View file

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

View file

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

View file

@ -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,
});
}