// framework/item-contract.ts — THE item-specification contract (AUTOPILOT-128), // as DATA, not prose. // // For each status TRANSITION, the attributes the framework depends on and the // form each must take. Two kinds, handled DIFFERENTLY: // // * REQUIRED — the transition is REFUSED when the attribute is absent or // is not in the allowed form. EVERY gap is named in the // refusal (formatContractRefusal), never just the first. // * DEFAULTABLE — the framework writes a default ONTO THE ITEM at the // transition and states it in the response; never applied // silently. // // This table is the ONE source of truth. It drives: // (i) the WRITE VALIDATION — validateTransitionContract, called by the // queue_* tools (queueAdd/queueUpdate/queueDispatch) and the decision // panel; // (ii) the DOCUMENTED CONTRACT TEXT — tools/contracts.ts renders // renderTransitionList()/renderSpecContract() FROM HERE, so the // documented transition list is GENERATED FROM the transition map and // cannot drift from it. (The drift this fixes: contracts.ts documented // `approved→active` while the map also allowed `approved→blocked`.) A // test (test/framework/item-contract.test.ts) FAILS if the two // disagree, so it cannot drift again; and // (iii) the OPERATOR SURFACE — config.ts's /autopilot status scan and the // panel's approve refusal read the same requirements. // // INTAKE IS NOT GATED (spec §01): a `proposal` may lack scope/cwd — that is // exactly how intake proposes before scoping. The contract bites at `→ approved` // and at DISPATCH (`→ active`), never at creation-of-a-proposal. The table says // which transition each requirement belongs to; a proposal transition simply // has none. // // CLOSED SETS ARE CLOSED: an enumerated field (risk/value/urgency) REFUSES a // non-enum value at the load-bearing transition instead of accepting it and // quietly excluding the item downstream. The vocabulary and the parse live HERE, // once (RISK_VALUES/isRisk, VALUE_VALUES, URGENCY_VALUES) — every reader // (isAutoDispatchable, the domain docs, the operator surface) shares them. // // value/urgency/evidence: REQUIRED, not advisory (the AUTOPILOT-128 decision). // These were advisory-only before (spec-completeness.ts's approvalGaps family // reported them but never refused) — exactly the half-state that let an empty // `risk` silently skip an approved item for an hour. A field the approval gate // NAMES in the item template is part of the contract: leaving it advisory while // the documented gate says "evidence + scope + value/urgency + risk" is a // documented-contract/validation disagreement. The prompt template already // required them, so this makes code meet doc. They are CLOSED sets (H|M|L) with // the rationale in notes, not free-form prose. This removes the advisory-only // trap rather than widening it. // // SCOPE: this table is the SPECIFICATION contract (the attributes the framework // reads to decide whether an item may be approved/dispatched). The lifecycle // gates that already refuse with their own messages — `blocked` needs a blocker // reason (and `serialized` a non-empty `after`), human-review→active needs // findings, blocked→ai-review needs completed-run evidence — stay in // queue-store.ts's updateItem; they are lifecycle preconditions, not item // attributes. The transition list here still documents every edge. import { DEFAULT_DISPATCH_BUDGET_MS } from "./run-budget.ts"; // --------------------------------------------------------------------------- // Statuses and the transition map // --------------------------------------------------------------------------- export const STATUS_LIST = [ "proposal", "approved", "blocked", "active", "ai-review", "human-review", "failed", "done", "rejected", "no-change", ] as const; /** The item statuses, in report/transition order. */ export type QueueStatus = (typeof STATUS_LIST)[number]; /** Is `s` one of the known statuses? The ONE status parser. */ export function isValidStatus(s: unknown): s is QueueStatus { return typeof s === "string" && (STATUS_LIST as readonly string[]).includes(s); } /** THE transition map: from a status, the statuses it may move to. This is the * single source the documented contract list is GENERATED FROM (see * renderTransitionList) — never hand-write the list beside it again. */ export const STATUS_TRANSITIONS: Record = { // A proposal may be BLOCKED directly (deferred: parked/serialized/decision) // without an approval — blocking resolves it from the pending-proposal set // (intake re-arms) without a false approval record. proposal: ["approved", "rejected", "blocked"], approved: ["blocked", "active", "rejected"], // `blocked` may also hand a COMPLETED deliverable straight to REVIEW // (`ai-review`) — the ONE route into review that does NOT transit a // lane-actionable state (KEY: AUTOPILOT-93). `approved` is auto-dispatchable // and `active` is in-flight, so the only pre-existing chain // (blocked → approved → active → ai-review) left a window in which // auto-dispatch could spawn a worker at FINISHED work (observed live on // ACTUATOR-16 / ACTUATOR-51 / AUTOPILOT-90). This edge is EVIDENCE-GATED in // updateItem (see completedDeliverableEvidence) — it is NOT an unconditional // transition, so the table entry alone is not permissive. blocked: ["approved", "rejected", "ai-review"], // unblock (approved), drop (rejected), or review a completed deliverable (ai-review) active: ["ai-review", "failed"], // event-driven (extension): worker done → AI review "ai-review": ["human-review", "failed", "active", "no-change"], // PASS→human-review; FAIL→active re-dispatch; cap→failed; NO-CHANGE→no-change (terminal, non-deliverable) "human-review": ["done", "active", "rejected"], // you approve→done; find issues→active re-dispatch; drop→rejected // A FAILED item may be PARKED (KEY: AUTOPILOT-49). `blocked` used to be // reachable only from proposal/approved, so once an item had been dispatched // there was NO expressible state for "this failed for a reason retrying // cannot fix — hold it for a human" (an interactive prompt no headless run // can answer, a missing credential, a pending decision). The orchestrator // tried exactly that, the store refused the transition, and auto-recovery — // which reads STATUS, not the prose note beside it — re-dispatched the item // straight back into the same wall. Notes are not a gate; a status is. // `blocked` still requires a blocker reason, so a park always says WHY, and // the release path is the one that already exists: blocked → approved. failed: ["active", "done", "blocked"], // recovery re-dispatch; done = verified-complete despite the failure record; blocked = parked for a human done: ["approved"], // human re-open: the user found issues after approval rejected: [], // NON-DELIVERABLE TERMINAL (KEY: AUTOPILOT-108): the correct outcome was no // change. Terminal WITH RECORD: the item and the reviewer's rationale stay, // nothing re-dispatches — a deliberate human re-open goes through `approved` // (the same one-way exit `done` has), never automatically. "no-change": ["approved"], }; export function validTransition(from: QueueStatus, to: QueueStatus): boolean { return STATUS_TRANSITIONS[from]?.includes(to) ?? false; } // --------------------------------------------------------------------------- // Closed value sets — the vocabulary and the parse, in ONE place // --------------------------------------------------------------------------- /** `risk` is a CLOSED three-value set. `med`, `low-risk`, and a prose value * like `low — behaviour-preserving` are all UNCLASSIFIABLE and are REFUSED at * the approval/dispatch transition (AUTOPILOT-128) instead of being stored and * silently skipped by isAutoDispatchable. The rationale belongs in * evidence/notes, not the enum. */ export const RISK_VALUES = ["low", "medium", "high"] as const; export type Risk = (typeof RISK_VALUES)[number]; export function isRisk(v: unknown): v is Risk { return typeof v === "string" && (RISK_VALUES as readonly string[]).includes(v); } /** The risk values that auto-dispatch may act on. Deliberately a SUBSET of * RISK_VALUES: `high` needs the orchestrator's checkpoint. Shared by * isAutoDispatchable so the allow-list can never drift from the vocabulary. */ export const AUTO_DISPATCH_RISK: readonly Risk[] = ["low", "medium"]; /** `value`/`urgency` are CLOSED H/M/L sets (the documented proposal template), * not free-form prose — a value outside the set is refused at approval. */ export const VALUE_VALUES = ["H", "M", "L"] as const; export const URGENCY_VALUES = ["H", "M", "L"] as const; // --------------------------------------------------------------------------- // The contract table — attributes required/defaultable per transition // --------------------------------------------------------------------------- export type RequirementKind = "required" | "defaultable"; export interface FieldRequirement { /** The item attribute this requirement is about. */ field: string; kind: RequirementKind; /** The allowed form, used VERBATIM in every refusal (one spelling, one * place). */ allowed: string; /** The closed value set, when the field is enumerated. */ values?: readonly string[]; /** Where an offending value belongs instead — named in the refusal so the * caller is told what to do, not merely what is wrong. */ belongsInstead?: string; /** DEFAULTABLE only: the value written onto the item at the transition. */ default?: string | number; /** DEFAULTABLE only: how the default is described in the response. */ defaultNote?: string; } /** A transition with no requirements is simply absent from the table (a * proposal transition needs nothing — see the module header). */ export type TransitionKey = `${QueueStatus}->${QueueStatus}`; const APPROVAL_REQUIREMENTS: readonly FieldRequirement[] = [ { field: "scope", kind: "required", allowed: "a non-empty scope (the worker prompt)" }, { field: "cwd", kind: "required", allowed: "a non-empty repo path (the worktree target)" }, { field: "evidence", kind: "required", allowed: "a non-empty evidence note (what proves the problem is real)" }, { field: "value", kind: "required", allowed: "exactly one of H|M|L", values: VALUE_VALUES, belongsInstead: "notes (the reasoning behind the estimate)" }, { field: "urgency", kind: "required", allowed: "exactly one of H|M|L", values: URGENCY_VALUES, belongsInstead: "notes (the reasoning behind the estimate)" }, { field: "risk", kind: "required", allowed: "exactly one of low|medium|high", values: RISK_VALUES, belongsInstead: "evidence or notes" }, ]; const DISPATCH_REQUIREMENTS: readonly FieldRequirement[] = [ { field: "scope", kind: "required", allowed: "a non-empty scope (the worker prompt)" }, { field: "cwd", kind: "required", allowed: "a non-empty repo path (the worktree target)" }, { field: "risk", kind: "required", allowed: "exactly one of low|medium|high", values: RISK_VALUES, belongsInstead: "evidence or notes" }, { field: "timeoutMs", kind: "defaultable", allowed: "a positive number of milliseconds, or omitted to accept the default", default: DEFAULT_DISPATCH_BUDGET_MS, defaultNote: "the default dispatch budget", }, ]; /** * THE contract table: for each status transition, the attributes the framework * depends on. Fully-qualified by transition so the table itself says WHERE each * requirement belongs (requirement 5 of AUTOPILOT-128). `approved->approved` is * the re-statement invariant (a metadata-only write to an approved item must * still leave it fully specified) — every other `-> approved` edge is an * approval. */ export const TRANSITION_CONTRACT: Partial> = { "proposal->approved": APPROVAL_REQUIREMENTS, "approved->approved": APPROVAL_REQUIREMENTS, "blocked->approved": APPROVAL_REQUIREMENTS, "done->approved": APPROVAL_REQUIREMENTS, "no-change->approved": APPROVAL_REQUIREMENTS, // DISPATCH — the framework reads scope/cwd/risk to run the work; an item that // lacks any of them must be refused at the transition where they become // load-bearing, not three transitions later. "approved->active": DISPATCH_REQUIREMENTS, "failed->active": DISPATCH_REQUIREMENTS, "ai-review->active": DISPATCH_REQUIREMENTS, }; /** The requirements for one transition (empty when the table declares none). */ export function requirementsFor(from: QueueStatus, to: QueueStatus): readonly FieldRequirement[] { return TRANSITION_CONTRACT[`${from}->${to}`] ?? []; } /** The APPROVAL transition's field names, in table order — the ONE place the * approval gate's attribute list is written. tools/contracts.ts's * `approval_gate_param` renders this (never a hand-written list), and the * item-contract test asserts the rendered text names EXACTLY these fields, so * a host parameter can never again name a subset (the stale "scope + cwd" * form this replaced). */ export function approvalFieldNames(): readonly string[] { return requirementsFor("proposal", "approved").map((r) => r.field); } // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- export interface ContractGap { field: string; /** The value received, rendered for a refusal ("missing" when absent). */ received: string; allowed: string; belongsInstead?: string; } function renderReceived(raw: unknown): string { if (raw === undefined || raw === null || (typeof raw === "string" && raw.trim() === "")) return "missing"; return JSON.stringify(raw); } /** Validate `item` against the requirements of the `from → to` transition. * Returns EVERY gap (in table order), never just the first. DEFAULTABLE * requirements never produce a gap — the caller records the default. */ export function validateTransitionContract( item: Record, from: QueueStatus, to: QueueStatus, ): ContractGap[] { const gaps: ContractGap[] = []; for (const req of requirementsFor(from, to)) { if (req.kind !== "required") continue; const raw = item[req.field]; const received = renderReceived(raw); if (received === "missing") { gaps.push({ field: req.field, received, allowed: req.allowed, ...(req.belongsInstead ? { belongsInstead: req.belongsInstead } : {}) }); continue; } // EXACT membership, NO trim — this is the SAME comparison the rest of the // framework reads with (`isRisk` here, `AUTO_DISPATCH_RISK` in // auto-dispatch.ts). Trimming here would ADMIT a padded value (`" low "`, // `"low\n"`), store it un-normalized, and hand a lane the value its own // parse refuses — the approved-but-never-dispatchable item this contract // exists to abolish, which the operator surface (sharing THIS validator) // would not even name. Refuse the raw value instead. if (req.values && !(typeof raw === "string" && req.values.includes(raw))) { gaps.push({ field: req.field, received, allowed: req.allowed, ...(req.belongsInstead ? { belongsInstead: req.belongsInstead } : {}) }); } } return gaps; } /** The DEFAULTABLE requirements of a transition — the caller records each one * on the item and states it in the response. */ export function defaultableRequirementsFor(from: QueueStatus, to: QueueStatus): readonly FieldRequirement[] { return requirementsFor(from, to).filter((r) => r.kind === "defaultable"); } /** One refusal line per gap: field, value received, allowed form, and where the * value belongs instead. */ function gapLine(g: ContractGap): string { const belongs = g.belongsInstead ? `; the rationale belongs in ${g.belongsInstead}` : ""; return ` • ${g.field}: ${g.received} — must be ${g.allowed}${belongs}`; } /** Build the refusal an operator/agent reads: a named transition, EVERY gap, * and the instruction to fix them all. `op` is the tool name prefix. */ export function formatContractRefusal(op: string, from: QueueStatus, to: QueueStatus, gaps: readonly ContractGap[], key?: string): string { const where = from === to ? `a '${to}' item` : `'${from}' → '${to}'`; const subject = key ? ` for '${key}'` : ""; return ( `${op}: REFUSED — ${where}${subject} requires every attribute the framework depends on; ` + `${gaps.length} gap(s), nothing was written:\n${gaps.map(gapLine).join("\n")}\n` + `Fix all of them and retry (the contract is generated from the transition table — see the queue_update contract text).` ); } /** Validate an item against the APPROVAL contract, from its current status. * Used by the operator surface (config.ts) and any caller that needs the * approved-state invariant without a concrete transition. Always the canonical * `proposal → approved` requirement set. */ export function validateApprovalContract(item: Record): ContractGap[] { return validateTransitionContract(item, "proposal", "approved"); } // --------------------------------------------------------------------------- // The documented contract, GENERATED from the table (never hand-written) // --------------------------------------------------------------------------- /** Render the transition map as the documented list. This is the ONLY source * of the documented transition list — tools/contracts.ts calls it, and the * item-contract test compares what the doc says back to the map. */ export function renderTransitionList(map: Record = STATUS_TRANSITIONS): string { return Object.entries(map) .map(([from, tos]) => `${from}\u2192${tos.length ? tos.join("/") : "(terminal)"}`) .join(" | "); } /** The INVERSE of renderTransitionList — parse a documented transition list * back into `{ from: [to...] }`. Returns the shapes that name no destination * as `[]`, so `parse(render(map))` equals the map. Used by the drift test. */ export function parseTransitionList(text: string): Record { const marker = "validated transitions:"; const idx = text.indexOf(marker); const seg = idx >= 0 ? text.slice(idx + marker.length) : text; const stop = seg.search(/[;\n]/); const body = (stop >= 0 ? seg.slice(0, stop) : seg).trim(); const out: Record = {}; for (const part of body.split("|").map((s) => s.trim()).filter(Boolean)) { const [from, tosRaw] = part.split("\u2192").map((s) => s.trim()); if (!from || !tosRaw) continue; const tos = tosRaw .split("/") .map((s) => s.trim().replace(/^\(|\)$/g, "")) .filter((s) => s && s !== "terminal" && s !== "none"); out[from] = tos; } return out; } function renderRequirements(reqs: readonly FieldRequirement[]): string { return reqs .map((r) => (r.kind === "defaultable" ? `${r.field} (DEFAULTABLE: ${r.defaultNote ?? "a default"} = ${r.default})` : `${r.field} (${r.allowed})`)) .join(", "); } /** The documented spec contract — the REQUIRED attributes for approval and for * dispatch, plus the DEFAULTABLE ones, rendered from the table so the text and * the enforcement share one source. */ export function renderSpecContract(): string { return ( `APPROVAL REQUIRES: ${renderRequirements(TRANSITION_CONTRACT["proposal->approved"]!)}. ` + `DISPATCH REQUIRES: ${renderRequirements(TRANSITION_CONTRACT["approved->active"]!)}. ` + `A REQUIRED gap REFUSES the transition and names EVERY gap, the value received and where a stray value belongs; a DEFAULTABLE attribute is RECORDED ON THE ITEM at the transition and stated in the response, never applied silently.` ); }