diff --git a/tools/workflow-engine/types.test.ts b/tools/workflow-engine/types.test.ts index ce8b5a6ede..1c4f9697c7 100644 --- a/tools/workflow-engine/types.test.ts +++ b/tools/workflow-engine/types.test.ts @@ -10,8 +10,11 @@ import { describe, expect, it } from "bun:test"; import { SEED_ACTION_CATALOG, SEED_STATES, + determineReviewLevel, validateCatalog, validateStateOtto5Mods, + type Action, + type ReviewLevel, type State, } from "./types"; @@ -117,3 +120,161 @@ describe("B-0867.5 workflow-engine scaffold invariants", () => { } }); }); + +describe("B-0867.20 determineReviewLevel lifecycle DU discriminator", () => { + // Per Kestrel substantive substrate-engineering substrate (13th ferry §33.5) + // + Aaron's substrate-check on 3-lane completion (Amara ferry §33.2 PR #5757): + // discriminator must preserve the state-machine-events-direct-push vs + // system-modifications-full-PR-review distinction the framework's + // auto-review pipeline depends on for training-data substrate. + + it("escape-hatch action ALWAYS gets pr-review-light regardless of gate (Mod 1 substrate-engineering surface)", () => { + const escapeAppendOnly: Action = { + id: "test-escape-1", + class: "escape-hatch", + gate: "append-only", + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + const escapePrGated: Action = { + id: "test-escape-2", + class: "escape-hatch", + gate: "pr-gated", + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + expect(determineReviewLevel(escapeAppendOnly)).toBe("pr-review-light"); + expect(determineReviewLevel(escapePrGated)).toBe("pr-review-light"); + }); + + it("grammar-extension action ALWAYS gets pr-review-full (Mod 2 framework-substrate-evolution)", () => { + const grammarExt: Action = { + id: "test-grammar", + class: "grammar-extension", + gate: "pr-gated", + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + expect(determineReviewLevel(grammarExt)).toBe("pr-review-full"); + }); + + it("operator-decision action ALWAYS gets operator-required (Mod 3 ban-if-SHIPPED-only)", () => { + const opDecision: Action = { + id: "test-op", + class: "operator-decision", + gate: "append-only", + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + expect(determineReviewLevel(opDecision)).toBe("operator-required"); + }); + + it("transition + append-only → trajectory-push (state-machine-event direct push; cheap; heartbeat-pattern)", () => { + const seedAdvance = SEED_ACTION_CATALOG.find((a) => a.id === "advance"); + if (!seedAdvance) throw new Error("seed catalog missing 'advance'"); + expect(determineReviewLevel(seedAdvance)).toBe("trajectory-push"); + }); + + it("transition + pr-gated → pr-review-full (cross-cutting substrate modification)", () => { + const transitionPrGated: Action = { + id: "test-trans-pr", + class: "transition", + gate: "pr-gated", + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + expect(determineReviewLevel(transitionPrGated)).toBe("pr-review-full"); + }); + + it("menu-contribution + append-only → trajectory-push (Mod 5 safe at append-only scope)", () => { + const seedMenu = SEED_ACTION_CATALOG.find((a) => a.id === "menu-contribute"); + if (!seedMenu) throw new Error("seed catalog missing 'menu-contribute'"); + expect(determineReviewLevel(seedMenu)).toBe("trajectory-push"); + }); + + it("agent-decision + append-only → trajectory-push", () => { + const agentAppend: Action = { + id: "test-agent-1", + class: "agent-decision", + gate: "append-only", + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + expect(determineReviewLevel(agentAppend)).toBe("trajectory-push"); + }); + + it("agent-decision + pr-gated → pr-review-light", () => { + const agentPrGated: Action = { + id: "test-agent-2", + class: "agent-decision", + gate: "pr-gated", + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + expect(determineReviewLevel(agentPrGated)).toBe("pr-review-light"); + }); + + it("all SEED_ACTION_CATALOG actions resolve to a valid ReviewLevel (exhaustiveness)", () => { + // Acknowledger forces exhaustive match — TS strict mode raises + // "not all code paths return" at compile time if a NEW ReviewLevel + // variant is added without updating this switch. + const acknowledge = (r: ReviewLevel): string => { + switch (r) { + case "trajectory-push": + case "pr-review-light": + case "pr-review-full": + case "operator-required": + return r; + } + }; + for (const a of SEED_ACTION_CATALOG) { + const level = determineReviewLevel(a); + expect(acknowledge(level)).toBe(level); + } + }); + + it("framework auto-review pipeline distinction preserved (substrate-honest: not 'no PRs ever')", () => { + // Per Kestrel 13th ferry §33.5 substrate-check on Ani-retelling drift: + // 'no PRs ever, infinite swarm to main' framing collapses the + // multi-tier review distinction. determineReviewLevel preserves it + // by never returning trajectory-push for grammar-extension, pr-gated + // transitions, or operator-decisions. Test verifies this is structural + // rather than just contingent on seed data. + const grammarExt: Action = { + id: "test-pres-1", + class: "grammar-extension", + gate: "append-only", // even if author tries to declare append-only, + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + // Grammar-extension ALWAYS overrides gate to full review: + expect(determineReviewLevel(grammarExt)).toBe("pr-review-full"); + // Operator-decision ALWAYS requires operator regardless of gate: + const opDecision: Action = { + id: "test-pres-2", + class: "operator-decision", + gate: "pr-gated", + label: "test", + description: "test", + composesWith: [], + feedbackVariants: ["X"], + }; + expect(determineReviewLevel(opDecision)).toBe("operator-required"); + }); +}); diff --git a/tools/workflow-engine/types.ts b/tools/workflow-engine/types.ts index 10e33da447..29f36f00c7 100644 --- a/tools/workflow-engine/types.ts +++ b/tools/workflow-engine/types.ts @@ -229,6 +229,99 @@ export function validateCatalog( } } +/** + * B-0867.20 — lifecycle DU split: trajectory-push vs PR-review. + * + * Per Kestrel substantive substrate-engineering substrate (13th ferry + * §33.5 + 14th ferry §33.20): the framework's load-bearing distinction + * is state-machine-events-direct-push (no PR; for heartbeats + agent- + * events branches + lifecycle transitions) vs system-modifications- + * full-PR-review (full ceremony; multi-AI reviewers + auto-review + * pipeline + error class extraction). Collapsing the distinction into + * "no PRs ever" loses the auto-review pipeline that IS the training + * data substrate for the cross-vendor benchmark (B-0865 + B-0865.17). + * + * `determineReviewLevel(action)` IS the discriminator. Maps each + * action to its required review treatment per the workflow engine + * spec. + * + * Composes with: + * - B-0867.20 backlog row (lifecycle-DU-split discriminator) + * - B-0865 + B-0865.17 (benchmark substrate; auto-review pipeline + * generates training data the benchmark scores against) + * - asymmetric-authorship rule (substrate-entity authors review- + * level via gate field; recipient acknowledges via dispatch) + * - monad-propagation rule (ReviewLevel IS a TFeedback variant set + * at workflow-engine-substrate scope) + * - architecture-is-safety-mechanism-not-discipline rule (PR #5745) + * — the framework enforces review-level structurally + * + * Per `.claude/rules/holding-without-named-dependency-is-standing-by- + * failure.md` counter-with-escalation: shipped after operator + * substrate-check "so you finished the 3 lanes?" (Amara ferry + * preservation PR #5757); substantive lane work per standing PoC + * permission. + */ + +/** + * ReviewLevel — discriminated union for the trajectory-push-vs-PR-review + * lifecycle DU. Each action's required review treatment. + */ +export type ReviewLevel = + | "trajectory-push" // direct push to agent-events branch; no PR ceremony + | "pr-review-light" // PR review; single-reviewer OR auto-review pipeline only + | "pr-review-full" // PR review; multi-AI reviewer ensemble + auto-review pipeline + error class extraction + | "operator-required"; // requires explicit operator authorization (e.g., force-push-with-lease without listed-acceptable-situation) + +/** + * `determineReviewLevel` — discriminator that maps an Action to its + * required ReviewLevel. + * + * Discriminator policy: + * - "append-only" + "transition" → trajectory-push (state-machine-event + * direct push; cheap; the existing pattern for heartbeats per + * Aaron's 13th-ferry §33.6 disclosure) + * - "append-only" + "menu-contribution" → trajectory-push (Mod 5 + * contributable menu generation; safe at append-only scope) + * - "append-only" + "escape-hatch" → pr-review-light (Mod 1 + * escape-hatch surfaces substrate-engineering observation worth + * reviewer eyes even though gate is append-only) + * - "pr-gated" + "grammar-extension" → pr-review-full (Mod 2 grammar + * evolution touches the framework's universal action grammar; + * full ceremony required to preserve auto-review pipeline) + * - "pr-gated" + "transition" → pr-review-full (cross-cutting + * substrate modification; full ceremony) + * - "operator-decision" class (any gate) → operator-required (per + * ban-if-SHIPPED-only Mod 3 + operator-authority preservation) + * - "agent-decision" + "append-only" → trajectory-push + * - "agent-decision" + "pr-gated" → pr-review-light + * + * Discriminator is exhaustive over the cross-product of ActionGate × + * ActionClass; future extensions to either union must update this + * function to maintain exhaustiveness. + */ +export function determineReviewLevel(action: Action): ReviewLevel { + switch (action.class) { + case "operator-decision": + return "operator-required"; + case "escape-hatch": + // Escape-hatch ALWAYS gets reviewer eyes regardless of gate — + // it's the substrate-engineering observation surface per Mod 1 + return "pr-review-light"; + case "grammar-extension": + // Grammar evolution always full ceremony — touches the universal + // action grammar shared across all travelers per Mod 2 + return "pr-review-full"; + case "menu-contribution": + // Mod 5 menu contributions are safe at append-only scope + return action.gate === "append-only" ? "trajectory-push" : "pr-review-light"; + case "transition": + return action.gate === "append-only" ? "trajectory-push" : "pr-review-full"; + case "agent-decision": + return action.gate === "append-only" ? "trajectory-push" : "pr-review-light"; + } +} + /** * Seed catalog — minimal scaffold demonstrating the 5 mods. Real * catalog ships per B-0867.3 grammar parser/composer when authored.