// ------------------------------------------------------------------------- // Decision panels — the SHARED, host-agnostic core of the panel feature. // // A panel is a VIEW over the queue store fed by buildPanelDoc (one per // status: proposals, human-review) plus a set of already-validated MUTATIONS // applied by applyPanelDecision. Panels add a human-scale decision surface // over the SAME store the queue_* tools use — zero new state, zero new // transitions. Every action maps 1:1 to an existing validated transition // (STATUS_TRANSITIONS + the item-specification contract in // framework/item-contract.ts). Hosts (pi TUI overlay, opencode TUI route) // are thin renderers over this doc and decision result. // // Deterministic transitions (approve/reject/defer/redispatch) are applied // directly, emit `orch:human-decision` (reused by the decision-tick builder // below), and result in a one-line `[orch-tick: decision] : // → ` so the orchestrator always knows the move — fresh at // application time. Refine (scope edits) is the one action that records the // human's words WITHOUT a status move (no judgment/dispatch follows it). // Re-dispatch DOES move the item (human-review → active) in the SAME write // that records the findings — the findings ride BOTH as a durable notes // entry and as a structured `pendingRedispatchFindings` marker the harness's // sweep-time consumer (dispatchPendingHumanRedispatch, auto-dispatch.ts) // reads to spawn the redo (KEY: AUTOPILOT-59; the same findings-carrying // path — auto-dispatch's autoRedispatch — a review FAIL uses). The status // move IS the single point of truth against double-dispatch: once the write // lands the item is no longer human-review, so a late/duplicate re-dispatch // event for the same key is refused here, before it ever reaches the harness. // ------------------------------------------------------------------------- import { loadStoreOrNew, mutateStore, updateItem, resolveSeries, unmetDependencies, type QueueItem, type QueueStore } from "../queue-store.ts"; import { renameProvisionalKey } from "../tools/queue-ops.ts"; import { formatContractRefusal, validateTransitionContract } from "./item-contract.ts"; import { buildReviewTargets, noReviewTargetsFoundLine } from "./review-targets.ts"; import { loadAutopilotConfig, staleProvisionalProposals, underSpecifiedProposals } from "../config.ts"; import { reviewNotesLines, type ReviewNotes } from "../review-notes.ts"; import { specCompleteness, specGapTag, type SpecGapId } from "./spec-completeness.ts"; export type PanelKind = "proposals" | "human-review"; export type PanelActionId = "approve" | "reject" | "defer" | "refine" | "redispatch" | "spec-pass"; export interface PanelTarget { label: string; /** Optional navigation hint (e.g. the branch-scoped view command). */ hint?: string; } /** Full item metadata for the detail view — nothing here is a render; * hosts decide how much of it to draw (and how much to truncate). */ export interface PanelItemMeta { createdAt: string; updatedAt: string; evidence: string; value: string; urgency: string; risk: string; blocker: string | null; runId: string | null; reviewerRunId: string | null; } export interface PanelItem { key: string; title: string; status: string; risk: string | null; cwd: string | null; updatedAt: string; /** The human-readable summary: scope first line for a proposal, title + * scope head for a review item. */ summary: string; /** Budget-governance row facts: the item's recorded wall-clock budget * (timeoutMs, null = unset → runtime default) and whether a previous run * FAILED because it hit that budget (failCause budget-capped) rather than * on a verdict. Rows render these so a bigger-budget re-dispatch is the * obvious next step, never a silent uniform cap. */ timeoutMs: number | null; budgetCapped: boolean; /** Where to navigate to act on this item (repo, branch@tip diffs, files). */ targets: PanelTarget[]; /** The actions this item currently supports (derived from its status). */ actions: PanelActionId[]; /** Proposal view only: what happens to the item's KEY. Provisional Q- * handles are renamed at approval into the repo's real series (resolved * registry → history → slug once a cwd is set); non-provisional keys carry * no hint. Hosts render it so the human sees the rename coming. * Absent (undefined) on the human-review view — keys there are final. */ seriesHint?: string | null; /** PROVISIONAL-LINGER (AUTOPILOT-3): age in whole days when this is a * Q- PROVISIONAL proposal past the linger threshold — a provisional * handle must not be mistaken for a real key. Absent otherwise. */ staleProvisionalDays?: number; /** SPEC-COMPLETENESS (AUTOPILOT-26): the elements missing from this * proposal's scope, in report order (see specCompleteness). ADVISORY: the * approval gate does not read it — a tagged proposal approves exactly like * an untagged one. Absent when nothing is missing, and on the human-review * view (spec quality is a TRIAGE-time question). */ specGaps?: SpecGapId[]; /** The FULL untruncated scope (the worker prompt). Truncation is a RENDER * choice — hosts show this verbatim in detail/expand views. */ fullScope: string; /** The FULL untruncated free-form notes. */ fullNotes: string; /** EVERY navigation target — no caps here; `targets` stays the host's * convenience projection, hosts choose how many to draw. */ fullTargets: PanelTarget[]; /** Full item metadata (timestamps + the free-form triage fields). */ meta: PanelItemMeta; /** WHAT THE REVIEWER COULD NOT VERIFY (AUTOPILOT-27) — the bounded caveats * captured at verdict parse. The approve decision is made on this screen, * so the material the review produced has to be ON it. Human-review view * only (a proposal has had no review yet); null when the reviewer left * nothing detectable. */ reviewNotes?: ReviewNotes | null; /** The same material as ready-to-draw lines — one spelling for both * renderers (empty when there is nothing to show). */ reviewNotesLines?: string[]; } export interface PanelSection { title: string; items: PanelItem[]; } export interface PanelDocument { kind: PanelKind; generatedAt: string; sections: PanelSection[]; } /** The action catalog — one source of labels for both hosts. */ export const PANEL_ACTIONS: Record = { approve: { label: "Approve", description: "proposal → approved (dispatchable) / human-review → done" }, reject: { label: "Reject", description: "drop the item (proposal/human-review → rejected)" }, defer: { label: "Defer", description: "park the proposal (blocked) instead of approving it now" }, refine: { label: "Refine", description: "edit the scope (proposal) — the worker prompt", needsInput: true }, redispatch: { label: "Re-dispatch", description: "send the reviewed work back with your findings", needsInput: true }, "spec-pass": { label: "Spec pass", description: "dispatch a bounded, read-only worker to complete an under-specified proposal's scope — NEVER approves; the item stays a proposal", }, }; /** The actions available per status. Proposals: approve/reject/defer/refine/ * spec-pass. Human-review: approve/reject/redispatch (findings = the refine * channel). */ export function actionsForStatus(status: string): PanelActionId[] { if (status === "proposal") return ["approve", "reject", "defer", "refine", "spec-pass"]; if (status === "human-review") return ["approve", "redispatch", "reject"]; return []; } function scopeHead(scope: string | null | undefined, cap = 120): string { return (scope ?? "").split(/\n/)[0].trim().slice(0, cap); } /** DEPENDENCY GATE (KEY: AUTOPILOT-81): the `⌧ after X` prefix shown on a * panel row whose `after` still names an unfinished dependency — advisory * (this is a VIEW, no action changes because of it), but visible at triage * time so a proposal/human-review item's coming wait is not a surprise once * it is approved. Empty string when nothing is unmet. */ function waitingTag(item: QueueItem, store: QueueStore): string { const unmet = unmetDependencies(item, store); return unmet.length ? `\u29d7 after ${unmet.join(", ")} — ` : ""; } /** The destined-series hint for the proposals feed — resolved against the * SAME store + registry the approved transition renames with, so the panel * can never predict a different series than the one the rename produces. * - provisional key with no repo yet: the rename is coming — announced up * front (and the hint resolves live once a cwd is set, because the panel * rebuilds from the store after every decision). * - cwd-bearing item: real-series keys stay put (allocated from cwd at * queue_add); provisional keys rename into the resolved series. * - otherwise (legacy/odd items): no hint. */ export function seriesHintFor(stateDir: string, item: QueueItem): string | null { if (item.provisionalKey) { if (!item.cwd) { return `${item.key} is a provisional handle (repo-less proposal — no cwd yet): at approval the key is renamed into the repo's real series (registry → history → slug). Expect the key to change.`; } const series = resolveSeries(stateDir, item.cwd, { excludeKey: item.key }); return `repo set — approval renames ${item.key} into series ${series}`; } if (item.cwd) { const series = resolveSeries(stateDir, item.cwd); return `real series key — cwd ${item.cwd} resolves to series ${series || "Q"} (registry → history → slug); approval does NOT rename.`; } return null; } function toTargets(lines: string[]): PanelTarget[] { return lines.map((l) => { const label = l; // targets carry their navigation hint in prose (diff/view commands) return { label }; }); } /** Project the FULL item metadata for the detail view — free-form text is * carried verbatim (evidence/value/urgency/risk/notes are schema-free). */ function metaFor(i: QueueItem): PanelItemMeta { return { createdAt: i.createdAt, updatedAt: i.updatedAt, evidence: i.evidence ?? "", value: i.value ?? "", urgency: i.urgency ?? "", risk: i.risk ?? "", blocker: i.blocker ?? null, runId: i.runId ?? null, reviewerRunId: i.reviewerRunId ?? null, }; } /** Build the panel document for one view, fed directly from queue state. */ export function buildPanelDoc(stateDir: string, kind: PanelKind): PanelDocument { const store = loadStoreOrNew(stateDir); const items = Object.values(store.items).filter((i) => i.status === (kind === "proposals" ? "proposal" : "human-review")); items.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt)); // oldest first — the ones waiting longest const document: PanelDocument = { kind, generatedAt: new Date().toISOString(), sections: [] }; if (kind === "proposals") { // Provisional-linger surface (AUTOPILOT-3): old Q- provisional handles // look like real keys — the proposals feed tags them so no one mistakes a // provisional (cwd-less, unrenamed) candidate for dispatched work. const stale = new Map( staleProvisionalProposals(stateDir, { thresholdDays: loadAutopilotConfig(stateDir).provisionalLingerDays }).map((s) => [s.key, s.days]), ); // Spec-completeness surface (AUTOPILOT-26): under-specified proposals are // tagged HERE, at triage, instead of surfacing as an approval refusal. // Read through the same config helper /autopilot status uses, so the two // counts cannot drift. The tag never changes what approve does. const thin = new Map(underSpecifiedProposals(stateDir).map((t) => [t.key, t.gaps])); document.sections.push({ title: `Proposals — awaiting your call (approve / reject / defer / refine)` + (stale.size ? ` · ⚠ ${stale.size} stale provisional (Q-) — resolve/reject, NOT real keys` : "") + (thin.size ? ` · ⚠ ${thin.size} need spec — refine (e) to fill the template; advisory, you can still approve` : ""), items: items.map((i) => ({ key: i.key, title: i.title, status: i.status, risk: i.risk, cwd: i.cwd, updatedAt: i.updatedAt, summary: `${waitingTag(i, store)}${scopeHead(i.scope) || i.title}`, timeoutMs: i.timeoutMs ?? null, budgetCapped: i.failCause === "budget-capped", targets: [{ label: i.cwd ?? "no repo yet — refine to set cwd" }], seriesHint: seriesHintFor(stateDir, i), actions: actionsForStatus(i.status), staleProvisionalDays: stale.get(i.key), specGaps: thin.get(i.key), fullScope: i.scope ?? "", fullNotes: i.notes ?? "", fullTargets: [{ label: i.cwd ?? "no repo yet — refine to set cwd" }], meta: metaFor(i), })), }); } else { document.sections.push({ title: "Human review — approved work awaiting YOU (approve / re-dispatch with findings / reject)" + (items.some((i) => reviewNotesLines(i.reviewNotes).length) ? " · ⚠ reviewer caveats below — read what it could NOT verify before approving" : ""), items: items.map((i) => { // REVIEW TARGETS (AUTOPILOT-57): the STORED, durable field when the // automatic handover already built it (survives a session, a reload, // a scroll) — falling back to a live build only for items that never // went through that handover (pre-feature items, direct test/store // writes). Empty either way → the plain honest line, never a // fabricated pointer — the SAME wording the tick handover uses. const rawTargets = i.reviewTargets != null ? i.reviewTargets : buildReviewTargets(stateDir, i.key, i, i.reviewNotes); const allTargets = toTargets(rawTargets.length ? rawTargets : [noReviewTargetsFoundLine(i, i.key)]); return { key: i.key, title: i.title, status: i.status, risk: i.risk, cwd: i.cwd, updatedAt: i.updatedAt, summary: `${waitingTag(i, store)}${scopeHead(i.scope) || i.title}${i.notes ? ` — ${scopeHead(i.notes, 120)}` : ""}`, targets: allTargets, timeoutMs: i.timeoutMs ?? null, budgetCapped: i.failCause === "budget-capped", actions: actionsForStatus(i.status), fullScope: i.scope ?? "", fullNotes: i.notes ?? "", fullTargets: allTargets, meta: metaFor(i), reviewNotes: i.reviewNotes ?? null, reviewNotesLines: reviewNotesLines(i.reviewNotes), }; }), }); } return document; } export interface HumanDecisionEvent { name: "orch:human-decision"; data: { key: string; action: PanelActionId; from: string; to: string | null; findings?: string; /** set when the decision renamed a provisional key (approve) — the event * carries the FINAL key so consumers dispatch the live item. */ renamedFrom?: string; /** Short annotation the decision tick appends to the target — e.g. * "dispatchable" (approve a proposal) or the defer blocker * ("decision", "parked"). */ note?: string; }; } export interface PanelDecisionResult { ok: boolean; text: string; event?: HumanDecisionEvent; /** The one-line decision tick (`[orch-tick: decision] : * → `), built AT APPLICATION TIME from the event data — fresh * by construction, never a stale snapshot. Present only for real status * moves (approve/reject/defer/redispatch): refine records words without * flipping a status, so it carries no tick. */ tick?: string; } /** The past-tense verbs the decision tick shows per action. */ const DECISION_VERB: Record = { approve: "approved", reject: "rejected", defer: "deferred", refine: "refined", redispatch: "re-dispatched", "spec-pass": "spec-pass-queued", }; /** Build the one-line decision tick: `[orch-tick: decision] : * → [ ()]`. SHARED by the panel hosts (from the returned * orch:human-decision event) and harness-applied moves (zombie flips pass * their own verb + note — e.g. action "failed", note "zombie"). Returns null * when no status moved (from === to or no target): a non-move is an event, * not a tick. One line, by construction. */ export function decisionTick(input: { key: string; action: string; from: string; to: string | null; note?: string }): string | null { if (input.to === null || input.from === input.to) return null; const to = input.note ? `${input.to} (${input.note})` : input.to; return `[orch-tick: decision] ${input.key} ${input.action}: ${input.from} → ${to}`; } function result(text: string, event?: HumanDecisionEvent): PanelDecisionResult { if (!event) return { ok: false, text }; const tick = decisionTick({ key: event.data.key, action: DECISION_VERB[event.data.action], from: event.data.from, to: event.data.to, note: event.data.note }); return { ok: true, text: `${text} (human decision recorded)`, event, ...(tick ? { tick } : {}) }; } /** Apply one panel action. Deterministic transitions (including redispatch, * human-review → active) are applied directly and validated by the same * STATUS_TRANSITIONS map + item-specification contract the queue_* tools use; * redispatch additionally * records the human's findings for the harness's sweep-time consumer to * spawn the redo (KEY: AUTOPILOT-59; see dispatchPendingHumanRedispatch). */ export function applyPanelDecision(stateDir: string, key: string, action: PanelActionId, payload?: { scope?: string; cwd?: string; findings?: string; blocker?: string }): PanelDecisionResult { try { return applyPanelDecisionOrThrow(stateDir, key, action, payload); } catch (e) { // The decision is validated against the CURRENT store inside mutateStore, so // an item another writer moved/renamed between this panel's read and its // write is REFUSED there. Report that as a failed decision — a panel // keypress must never throw into the host's TUI handler. return { ok: false, text: `panel: '${key}' could not be applied — ${e instanceof Error ? e.message : String(e)}` }; } } function applyPanelDecisionOrThrow(stateDir: string, key: string, action: PanelActionId, payload?: { scope?: string; cwd?: string; findings?: string; blocker?: string }): PanelDecisionResult { const store = loadStoreOrNew(stateDir); const item: QueueItem | undefined = store.items[key]; if (!item) return { ok: false, text: `panel: no item '${key}'` }; const base = { key, action, from: item.status }; switch (action) { case "approve": { if (item.status === "proposal") { // THE ITEM-SPECIFICATION CONTRACT (AUTOPILOT-128) — the SAME table and // validator queue_add/queue_update run (every attribute the framework // depends on: scope + cwd + evidence + value/urgency + risk), applied // here so a human approving in the panel gets the identical protection // (and the identical refusal, naming EVERY gap). A panel decision has // no `queue_update` call to route through, so it validates directly. // The spec-completeness tag on the feed (specGaps) is a DIFFERENT check // and stays ADVISORY, NOT consulted here: approving a thin-but- // non-empty scope is the human's call, and a second gate would make // approving expensive — the one property this queue depends on. const gaps = validateTransitionContract( { scope: item.scope, cwd: item.cwd, evidence: item.evidence, value: item.value, urgency: item.urgency, risk: item.risk }, item.status, "approved", ); if (gaps.length) { return { ok: false, text: formatContractRefusal("panel", item.status, "approved", gaps, key) }; } // A provisional Q- handle gets its REAL series here — the same // rename queue_update performs at approval, so the panel and the tool // cannot drift. The key stays Q- (identity) until this moment. // Both steps are ONE mutateStore mutation (load → apply → write). const renamedTo = mutateStore(stateDir, (s) => { updateItem(s, key, { status: "approved" }); return renameProvisionalKey(stateDir, s, key); }); if (renamedTo) { const text = `approved '${key}' → renamed to '${renamedTo}' (provisional handle → real series)`; return result(text, { name: "orch:human-decision", data: { ...base, key: renamedTo, renamedFrom: key, to: "approved", note: "dispatchable" } }); } return result(`approved '${key}' (proposal → approved, dispatchable)`, { name: "orch:human-decision", data: { ...base, to: "approved", note: "dispatchable" } }); } if (item.status === "human-review") { mutateStore(stateDir, (s) => updateItem(s, key, { status: "done" })); return result(`approved '${key}' (human-review → done)`, { name: "orch:human-decision", data: { ...base, to: "done" } }); } return { ok: false, text: `panel: '${key}' is ${item.status} — approve is only for proposal / human-review items` }; } case "reject": { const to = item.status === "proposal" ? "rejected" : item.status === "human-review" ? "rejected" : null; if (!to) return { ok: false, text: `panel: '${key}' is ${item.status} — reject is only for proposal / human-review items` }; mutateStore(stateDir, (s) => updateItem(s, key, { status: to as never })); return result(`rejected '${key}'`, { name: "orch:human-decision", data: { ...base, to } }); } case "defer": { if (item.status !== "proposal") return { ok: false, text: `panel: '${key}' is ${item.status} — only proposals can be deferred` }; const blocker = payload?.blocker && (["parked", "serialized", "merge", "decision"] as const).includes(payload.blocker as never) ? payload.blocker : "decision"; mutateStore(stateDir, (s) => updateItem(s, key, { status: "blocked", blocker: blocker as never })); return result(`deferred '${key}' (blocked: ${blocker})`, { name: "orch:human-decision", data: { ...base, to: "blocked", note: blocker } }); } case "refine": { if (item.status !== "proposal") return { ok: false, text: `panel: '${key}' is ${item.status} — refine edits a proposal's scope` }; const scope = (payload?.scope ?? "").trim(); const cwd = (payload?.cwd ?? "").trim(); if (!scope && !cwd) return { ok: false, text: `panel: refine needs new scope text or a repo (cwd) for '${key}'` }; const patch: { scope?: string; cwd?: string } = {}; if (scope) patch.scope = scope; if (cwd) patch.cwd = cwd; mutateStore(stateDir, (s) => updateItem(s, key, patch)); const changed = [scope ? "scope" : null, cwd ? "repo (cwd)" : null].filter(Boolean).join(" + "); return result(`refined '${key}' ${changed}`, { name: "orch:human-decision", data: { ...base, to: "proposal" } }); } case "spec-pass": { // KEY: AUTOPILOT-36 — this NEVER approves anything and NEVER moves // status (the event below carries `to: item.status`, so decisionTick's // from===to rule produces no tick, exactly like refine). It only sets // the PENDING MARKER the harness's sweep-time dispatcher // (dispatchPendingSpecPass, auto-dispatch.ts) consumes — the SAME // handoff shape AUTOPILOT-59's human redispatch uses, so a spec pass is // an ordinary dispatch through the existing worker/worktree machinery, // never a bespoke approval path. if (item.status !== "proposal") return { ok: false, text: `panel: '${key}' is ${item.status} — spec pass is only for proposals` }; if (!(item.cwd ?? "").trim()) { return { ok: false, text: `panel: ${key} has no repo (cwd) — refine it with a repo first; a spec pass needs somewhere to read the code` }; } if (item.pendingSpecPass || item.specPassRunId) { return { ok: false, text: `panel: ${key} already has a spec pass ${item.specPassRunId ? "running" : "queued"} — wait for it to complete` }; } const gaps = specCompleteness(item); if (!gaps.length) return { ok: false, text: `panel: ${key} is already spec-complete — no spec pass needed` }; mutateStore(stateDir, (s) => updateItem(s, key, { pendingSpecPass: true })); return result( `queued a spec pass for '${key}' (${specGapTag(gaps)}) — a read-only worker will read the code and complete the scope; ${key} STAYS a proposal, approval is still yours`, { name: "orch:human-decision", data: { ...base, to: "proposal", note: "spec-pass-queued" } }, ); } case "redispatch": { if (item.status !== "human-review") return { ok: false, text: `panel: '${key}' is ${item.status} — re-dispatch is only for human-review items` }; const findings = (payload?.findings ?? "").trim(); if (!findings) return { ok: false, text: `panel: re-dispatch needs your findings for '${key}'` }; // THE TRANSITION IS THE SINGLE POINT OF TRUTH (KEY: AUTOPILOT-59): the // move to active + the findings land in ONE mutateStore write, gated on // the FRESH status (never the snapshot read above) — a second/duplicate // re-dispatch event for the same key sees status "active" here and is // refused, so at most one write ever moves an item out of human-review. // Findings are recorded TWICE, for two different readers: appended to // `notes` (durable, human-readable audit trail) and set on // `pendingRedispatchFindings` (structured — the harness's sweep-time // consumer reads THIS one and builds the redo task via the SAME // workerTask(item, findings) a review-FAIL re-dispatch uses; see // dispatchPendingHumanRedispatch in auto-dispatch.ts). The review cap // (`attempts`) is deliberately NOT touched here — a human's correction // is not a review failure, so it never consumes a cap attempt. mutateStore(stateDir, (s) => { const fresh = s.items[key]; if (!fresh || fresh.status !== "human-review") { throw new Error(`'${key}' is no longer in human-review — it was already re-dispatched or moved`); } updateItem(s, key, { status: "active", notes: fresh.notes ? `${fresh.notes}\n\n[human re-dispatch findings] ${findings}` : `[human re-dispatch findings] ${findings}`, pendingRedispatchFindings: findings, }); }); return result(`re-dispatching '${key}' with your findings (human-review → active)`, { name: "orch:human-decision", data: { ...base, to: "active", findings } }); } default: return { ok: false, text: `panel: unknown action '${String(action)}'` }; } }