/** * Phase Tracker Extension * * Tracks workflow phase progress: brainstorm → plan → implement → verify → ship. * State is stored in tool result details for proper branching support. * Shows a persistent single-line TUI widget alongside plan-tracker. * * Distinct from plan-tracker (per-task progress within a phase). * Use phase_tracker for "what stage am I in?", plan_tracker for "which task?". */ import { execSync } from "node:child_process"; import { existsSync, globSync, readdirSync, readFileSync, readFileSync as readFileBytes } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { mainLoopModel, resolveClosureReview, resolveEscalationLoop, resolveFlowGuards, resolveSpecCouncil, settingsErrorWarning, } from "./lib/gauntlet-settings.ts"; import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts"; import { checkPlan, sha256, type FsPort, type PlanCheckFinding } from "./lib/plan-check.ts"; import { checkSubstep, findMarkerFile, flowGuardApplies, implementExemptDirs, implementWriteGuardApplies, markerBlockReason, nextGauntletEntered, parseGitCommit, phaseLabel, recoverableEdge, resolveRepoDir, STMT_START, transitionPhaseState, type RecoveryEdge, } from "./lib/phase-tracker-helpers.ts"; const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const; type Phase = (typeof PHASES)[number]; type PhaseStatus = "pending" | "in_progress" | "complete" | "skipped"; interface PhaseState { status: PhaseStatus; reason?: string; substep?: string; } type PhaseMap = Record; interface PhaseTrackerDetails { action: "start" | "complete" | "skip" | "status" | "reset" | "substep" | "grant_fix_rounds"; phases: PhaseMap; error?: string; rounds?: number; reason?: string; } interface PlanCheckStamp { planPath: string; // absolute planSha256: string; specPath: string; // absolute specSha256: string; } interface PlanCheckDetails { status: "pass" | "fail"; planPath: string; planSha256?: string; specPath?: string; specSha256?: string; findings?: PlanCheckFinding[]; } // Qualification per spec "Qualifying dispatch": a successful conformance-reviewer // child run in the result details (pi-cohort SingleResult: { agent, exitCode }). // Management mode and async dispatches return results: [] and fail this check. const qualifiesAsClosureDispatch = (details: unknown): boolean => { const d = details as { results?: { agent?: unknown; exitCode?: unknown }[] } | undefined; if (!d || !Array.isArray(d.results)) return false; return d.results.some((r) => r?.agent === "conformance-reviewer" && r?.exitCode === 0); }; // One conformance fix round = one non-error subagent result carrying at least one // implementer child, regardless of dispatch mode or per-child exit code (a failed // implementer still spent the round; its retry is the next one). Async and // management dispatches return results: [] and never count. const isImplementerWave = (details: unknown, isError: boolean | undefined): boolean => { if (isError === true) return false; const d = details as { results?: { agent?: unknown }[] } | undefined; if (!d || !Array.isArray(d.results) || d.results.length === 0) return false; return d.results.some((r) => r?.agent === "implementer"); }; // Review-cadence guard (spec 2026-08-12-execution-fidelity-hardening): presence-only // advisory ledger of the most recent completed implementer / spec-reviewer / // code-reviewer dispatch. Agents completing in the SAME dispatch share a sequence // number, so a fused implementer+reviewer result never reads as reviewer-stale. const CADENCE_AGENTS = ["implementer", "spec-reviewer", "code-reviewer"] as const; type CadenceAgent = (typeof CADENCE_AGENTS)[number]; const completedCadenceAgents = (details: unknown): CadenceAgent[] => { const d = details as { results?: { agent?: unknown; exitCode?: unknown }[] } | undefined; if (!d || !Array.isArray(d.results)) return []; const seen = new Set(); for (const r of d.results) { if (r?.exitCode === 0 && (CADENCE_AGENTS as readonly unknown[]).includes(r?.agent)) { seen.add(r.agent as CadenceAgent); } } return [...seen]; }; const REVIEW_CADENCE_WARNING = "⚠️ Reminder: no spec-reviewer or code-reviewer observed since the last implementer.\n" + "SR is required per task; CR per wave for code waves (doc-only waves are SR-only).\n" + "Dispatch the missing review(s) before committing integrated work."; const CLOSURE_GATE_ERROR = "Error: cannot complete 'verify': no conformance-reviewer dispatch observed.\n" + "The closing loop is required before verify completes. Either:\n" + '- dispatch subagent({ agent: "conformance-reviewer", ... }) with the spec, the\n' + " user's verbatim request, and the full diff (model from\n" + " piGauntlet.closureReview.model), then complete verify after its verdict, or\n" + "- if the user explicitly waived closure review, record it:\n" + ' phase_tracker({ action: "skip", phase: "verify", reason: "" })'; const SHIP_ADVISORY = "Verify is complete and ship is pending. Verify may complete when the conformance\n" + "verdict is CONFORMS, or every gap is either fixed or carried open as a deferred\n" + "accept/rescope/unauthorized decision. Invoke /skill:finishing-a-development-branch\n" + "now - do not add a 'ready to finish?' prompt; its disposition + squash/PR/keep/discard\n" + "menu is the human gate that resolves any carried-open decision. Only reopen verify if\n" + "a `fix` gap was left unresolved (neither fixed nor deferred)."; const RECOVERY_CUSTOM_TYPE = "pi-gauntlet-transition-recovery"; const RECOVERY_MESSAGES: Record = { "plan-implement": "Continue the approved workflow now. Invoke /skill:subagent-driven-development.", "verify-ship": "Continue the approved workflow now. Invoke /skill:finishing-a-development-branch.", }; const recoveryEdgeFromDetails = (details: unknown): RecoveryEdge | undefined => { if (!details || typeof details !== "object") return undefined; const edge = (details as { piGauntletRecoveryEdge?: unknown }).piGauntletRecoveryEdge; return edge === "plan-implement" || edge === "verify-ship" ? edge : undefined; }; // --- Flow guards (spec 2026-06-17-gauntlet-flow-guards) --- const GUARD_PHASES: Phase[] = ["brainstorm", "plan", "implement"]; // Guard 2 — branch ops in place. `git switch` never targets a file path; // `git checkout -b/-B` is explicit branch creation. Bare `git checkout ` // is excluded (ambiguous with file checkout). `git worktree ...` is exempt. const BRANCH_SWITCH = new RegExp(STMT_START + "git\\s+switch\\b"); const BRANCH_CHECKOUT = new RegExp(STMT_START + "git\\s+checkout\\s+-[bB]\\b"); const GIT_WORKTREE = /\bgit\s+worktree\b/; // Guard 3 — bash mutation forms during brainstorm. const BASH_REDIRECT = /(?:^|\s)>>?\s*([^\s|&]+)/; // capture the redirect target const BASH_TEE = /\btee\b/; const BASH_SED_I = /\bsed\s+-i\b/; const BASH_GIT_APPLY = /\bgit\s+apply\b/; const TEMP_TARGET = /^(\/tmp\/|\/var\/folders\/|\/dev\/)/; // scratch paths are not project mutations const SCRATCH_MENTION = /\/tmp\/|\/var\/folders\/|\/dev\//; const branchBlockReason = (phase: Phase): string => `Branch switch/creation in the primary checkout is blocked during the ${phase} phase. ` + "Gauntlet flows run in a dedicated worktree (git worktree add ... is allowed here); " + "create/enter one with /skill:using-git-worktrees and run this there. " + "To override, set piGauntlet.flowGuards.enforce: false."; // Walk the single / tasks / chain / parallel dispatch shapes and return every // entry whose `agent` matches `name` (the raw node, so callers can read its model). const collectAgents = (input: unknown, name: string): Record[] => { const hits: Record[] = []; const collect = (node: unknown): void => { if (!node || typeof node !== "object") return; const o = node as Record; if (o.agent === name) hits.push(o); for (const key of ["tasks", "chain", "parallel"] as const) { const v = o[key]; if (Array.isArray(v)) for (const item of v) collect(item); else if (v && typeof v === "object") collect(v); } }; collect(input); return hits; }; const conformanceModels = (input: unknown): (string | undefined)[] => collectAgents(input, "conformance-reviewer").map((o) => typeof o.model === "string" && o.model.trim() ? o.model.trim() : undefined, ); // Conformance fix-loop dispatch guard (spec 2026-09-13-conformance-dispatch-guard): // after the first audit, pi-cohort honours worktree: true only in tasks mode, so a // lone implementer runs unisolated and yields no patch for the integrate step. const loneImplementerBlockReason = 'Conformance fix loop: dispatch implementers as a one-task tasks wave (tasks: [{ agent: "implementer", worktree: true, ... }]); ' + "a lone agent call runs unisolated and produces no worktree diff. " + "To disable this gate, set piGauntlet.closureReview.enforce: false."; const fixRoundCapBlockReason = (used: number, cap: number, settingsPath: string): string => `Conformance fix loop: ${used} fix round(s) used against a cap of ${cap} (granted rounds included); ` + "escalate to the human with the verdict trail instead of re-looping.\n" + "If the human explicitly approves more rounds, record it and retry as a tasks wave: " + 'phase_tracker({ action: "grant_fix_rounds", rounds: , reason: "" }).\n' + `Last resort: set piGauntlet.closureReview.enforce: false in ${settingsPath} (disables all closure guards; ` + "applies on the next tool call, no restart - a gauntlet_setting read in the same message sees the write; " + "a repo closureReview block replaces the preset's whole block, so restate model and maxFixRounds alongside)."; const closureModelBlockReason = (model: string, missing: number, total: number): string => `Blocked: ${missing} of ${total} conformance-reviewer ${total === 1 ? "dispatch" : "entries"} ` + `omitted a model while piGauntlet.closureReview.model is set to "${model}".\n` + `The persona ships model-free, so a bare omission silently inherits this session's ` + `builder model - the closing gate would then run on the same model that built the work, ` + `not the independent one configured (repo .pi/settings.json, else the preset). Re-dispatch ` + `with model: "${model}" injected call-site on every conformance-reviewer entry. If that ` + `model is unreachable, pass an explicit fallback model (the documented one-retry escape ` + `hatch) - only a bare omission is blocked.\n` + `To disable this gate, set piGauntlet.closureReview.enforce: false.`; const closureModelMismatchWarning = (configured: string, mismatched: string[]): string => `⚠️ conformance-reviewer dispatched with ${mismatched.length === 1 ? "model" : "models"} ` + `${mismatched.map((m) => `"${m}"`).join(", ")} while ` + `piGauntlet.closureReview.model is set to "${configured}".\n` + `If this is the documented one-retry fallback (the configured model was ` + `unreachable), ignore this. Otherwise the closing gate is running on an ` + `unintended model - re-dispatch with model: "${configured}".`; const brainstormWriteWarning = (specDirs: string[]): string => "⚠️ Writing outside the spec directory during the brainstorm phase.\n" + `Brainstorming may only edit the spec under ${specDirs.join(", ")}. Implementation\n` + "waits for the spec approval gate. If this edit IS the spec, place it under the spec dir."; const implementWriteWarning = (): string => "⚠️ Parent write during the implement phase.\n" + "During implement the main loop orchestrates; subagents write the code - dispatch\n" + "this via /skill:subagent-driven-development instead of editing directly.\n" + "If this edit is merge-conflict resolution between parallel waves, proceed."; // Contiguous-subsequence match of a configured spec dir against path components. const pathInSpecDirs = (rawPath: string, specDirs: string[]): boolean => { const comps = rawPath.split("/").filter((c) => c.length > 0 && c !== "."); return specDirs.some((dir) => { const dcomps = dir.split("/").filter((c) => c.length > 0); if (dcomps.length === 0) return false; for (let i = 0; i + dcomps.length <= comps.length; i++) { if (dcomps.every((dc, j) => comps[i + j] === dc)) return true; } return false; }); }; const PhaseTrackerParams = Type.Object({ action: StringEnum(["start", "complete", "skip", "status", "reset", "substep", "grant_fix_rounds"] as const, { description: "Action to perform", }), phase: Type.Optional( StringEnum(PHASES, { description: "Phase name (required for start, complete, skip)", }), ), reason: Type.Optional( Type.String({ description: "Reason (required for skip and grant_fix_rounds; for grant, the human's approval quoted)", }), ), rounds: Type.Optional( Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Extra fix rounds the human explicitly approved (grant_fix_rounds only)", }), ), force: Type.Optional( Type.Boolean({ description: "Reset and re-start a phase that is already complete or skipped (rare; default false)", }), ), substep: Type.Optional( Type.Union([Type.String(), Type.Null()], { description: "Substep label for action=substep on an in_progress phase; null or omitted clears it", }), ), }); export type PhaseTrackerInput = Static; function emptyPhases(): PhaseMap { return Object.fromEntries(PHASES.map((p) => [p, { status: "pending" as PhaseStatus }])) as PhaseMap; } function phaseIcon(status: PhaseStatus, theme: Theme): string { switch (status) { case "complete": return theme.fg("success", "✓"); case "in_progress": return theme.fg("warning", "→"); case "skipped": return theme.fg("dim", "⊘"); default: return theme.fg("dim", "○"); } } function hasActivity(phases: PhaseMap): boolean { return PHASES.some((p) => phases[p].status !== "pending"); } function formatWidget(phases: PhaseMap, theme: Theme): string { const parts = PHASES.map((p) => { const icon = phaseIcon(phases[p].status, theme); const labeled = phaseLabel(p, phases[p].status === "in_progress" ? phases[p].substep : undefined); const name = phases[p].status === "skipped" ? theme.fg("dim", labeled) : labeled; return `${icon} ${name}`; }); return `${theme.fg("muted", "Phases:")} ${parts.join(theme.fg("dim", " → "))}`; } function formatStatus(phases: PhaseMap): string { const lines: string[] = ["Phases:"]; for (const p of PHASES) { const s = phases[p]; const icon = s.status === "complete" ? "✓" : s.status === "in_progress" ? "→" : s.status === "skipped" ? "⊘" : "○"; const suffix = s.reason ? ` (${s.reason})` : ""; lines.push(` ${icon} ${phaseLabel(p, s.status === "in_progress" ? s.substep : undefined)}${suffix}`); } return lines.join("\n"); } export default function (pi: ExtensionAPI) { let phases: PhaseMap = emptyPhases(); let conformanceDispatched = false; let fixRounds = 0; let fixRoundCredits = 0; let gauntletEntered = false; // Shared by replay and the live tool_result hook so a resumed session enforces // the same budget. Evaluated BEFORE the same result may set the latch, so the // R0 audit result itself never counts as a wave. const observeFixWave = (details: unknown, isError: boolean | undefined, ctx: ExtensionContext) => { if ( !isSubagentChild && gauntletEntered && phases.verify.status === "in_progress" && conformanceDispatched && isImplementerWave(details, isError) && resolveClosureReview(loadGauntletSettings(ctx.cwd).gauntlet).enforce ) { if (fixRoundCredits > 0) fixRoundCredits -= 1; fixRounds += 1; } }; let planCheckStamp: PlanCheckStamp | undefined; const attemptedRecoveryEdges = new Set(); let cadenceSeq = 0; const cadenceLastSeen: Record = { implementer: 0, "spec-reviewer": 0, "code-reviewer": 0, }; const observeCadence = (details: unknown) => { const agents = completedCadenceAgents(details); if (agents.length === 0) return; cadenceSeq++; for (const a of agents) cadenceLastSeen[a] = cadenceSeq; }; // Warn-once-per-phase ledger; cleared on every phase transition and on reconstruct. const firedGuards = new Map(); // Warnings stashed at tool_call, prepended at tool_result (verify-before-ship pattern). // Relies on tool_result firing for every tool_call; reconstructState clears any stragglers. const pendingGuardWarnings = new Map(); const activeGuardPhase = (): Phase | undefined => GUARD_PHASES.find((p) => phases[p].status === "in_progress"); // Guard 2 is active only when pi was launched in the primary checkout, not a // linked worktree. Computed once: a linked worktree has --git-dir != --git-common-dir. // (Inside a submodule the comparison is git-version-dependent and irrelevant here - // gauntlet flows do not run inside submodule git internals; whichever way it // resolves, the guard merely staying off in that edge case is harmless.) // pi-cohort sets PI_SUBAGENT_DEPTH >= 1 in every spawned child (getSubagentDepthEnv). // Implementer forks replay the parent's phase state; without this gate every // implementer's first write would trip a spurious advisory. const isSubagentChild = Number(process.env.PI_SUBAGENT_DEPTH ?? "0") > 0; const inPrimaryCheckout = (() => { try { const lines = execSync("git rev-parse --git-dir --git-common-dir", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, }) .trim() .split("\n"); return lines.length === 2 && lines[0] === lines[1]; } catch { return false; } })(); // Auto-complete the implement phase from plan_tracker once every task is done, // but only when a skill has explicitly started it (TDD, or the SDD // execution preamble). The tracker never *fabricates* implement from task activity: // outside a gauntlet flow (ad-hoc plan_tracker use) no phase is ever started, so // the phase widget stays dormant. Phases are entered explicitly by the phase-owning // skills; plan-tracker tracks tasks independently. const applyPlanActivity = (tasks?: { status: string }[]) => { if (!tasks || tasks.length === 0) return; if ( phases.implement.status === "in_progress" && tasks.every((t) => t.status === "complete" || t.status === "skipped") ) { phases = { ...phases, implement: { status: "complete" } }; firedGuards.clear(); } }; const branchLatestAssistantStopReason = (ctx: ExtensionContext): string | undefined => { let stopReason: string | undefined; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "assistant") stopReason = entry.message.stopReason; } return stopReason; }; const reconstructState = (ctx: ExtensionContext) => { phases = emptyPhases(); conformanceDispatched = false; fixRounds = 0; fixRoundCredits = 0; gauntletEntered = false; planCheckStamp = undefined; attemptedRecoveryEdges.clear(); firedGuards.clear(); pendingGuardWarnings.clear(); cadenceSeq = 0; cadenceLastSeen.implementer = 0; cadenceLastSeen["spec-reviewer"] = 0; cadenceLastSeen["code-reviewer"] = 0; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom_message") { if (entry.customType === RECOVERY_CUSTOM_TYPE) { const edge = recoveryEdgeFromDetails(entry.details); if (edge) attemptedRecoveryEdges.add(edge); } continue; } if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "toolResult") continue; if (msg.toolName === "phase_tracker") { const details = msg.details as PhaseTrackerDetails | undefined; if (details && !details.error) { phases = details.phases; if (details.action === "grant_fix_rounds" && typeof details.rounds === "number") fixRoundCredits = details.rounds; gauntletEntered = nextGauntletEntered(gauntletEntered, details.action, details.phases.brainstorm.status); if (details.action === "start" && details.phases.implement.status === "in_progress") { conformanceDispatched = false; fixRounds = 0; fixRoundCredits = 0; } if (details.action === "reset") { conformanceDispatched = false; fixRounds = 0; fixRoundCredits = 0; planCheckStamp = undefined; } } } else if (msg.toolName === "plan_check") { const d = msg.details as PlanCheckDetails | undefined; if (gauntletEntered && d?.status === "pass" && d.planPath && d.planSha256 && d.specPath && d.specSha256) { planCheckStamp = { planPath: d.planPath, planSha256: d.planSha256, specPath: d.specPath, specSha256: d.specSha256 }; } else if (d?.status === "fail") { planCheckStamp = undefined; } } else if (msg.toolName === "subagent") { observeFixWave(msg.details, msg.isError, ctx); if (qualifiesAsClosureDispatch(msg.details)) conformanceDispatched = true; observeCadence(msg.details); } else if (msg.toolName === "plan_tracker") { const details = msg.details as { tasks?: { status: string }[]; error?: string } | undefined; if (details && !details.error) applyPlanActivity(details.tasks); } } }; const updateWidget = (ctx: ExtensionContext) => { if (!ctx.hasUI) return; if (!hasActivity(phases)) { ctx.ui.setWidget("phase_tracker", undefined); } else { ctx.ui.setWidget("phase_tracker", (_tui, theme) => { return new Text(formatWidget(phases, theme), 0, 0); }); } }; for (const event of ["session_start", "session_switch", "session_fork", "session_tree"] as const) { pi.on(event, async (_event, ctx) => { reconstructState(ctx); updateWidget(ctx); }); } pi.on("agent_settled", (_event, ctx) => { const latestAssistantStopReason = branchLatestAssistantStopReason(ctx); if (!gauntletEntered || latestAssistantStopReason === "aborted") return; const edge = recoverableEdge(phases); if (!edge || attemptedRecoveryEdges.has(edge) || !ctx.isIdle()) return; attemptedRecoveryEdges.add(edge); pi.sendMessage( { customType: RECOVERY_CUSTOM_TYPE, content: RECOVERY_MESSAGES[edge], display: true, details: { piGauntletRecoveryEdge: edge }, }, { triggerTurn: true }, ); }); pi.on("tool_call", async (event, ctx) => { // D3.b: one fresh settings read per event, lazily, only if a guard needs it. const addGuardWarning = (id: string, text: string) => { const prior = pendingGuardWarnings.get(id); pendingGuardWarnings.set(id, prior ? prior + "\n\n" + text : text); }; let gauntletCache: ReturnType | undefined; const g = () => { if (!gauntletCache) { gauntletCache = loadGauntletSettings(ctx.cwd); if (gauntletCache.errors.length > 0) addGuardWarning(event.toolCallId, settingsErrorWarning(gauntletCache.errors)); } return gauntletCache.gauntlet; }; const closureEnforced = () => resolveClosureReview(g()).enforce; const closureReviewModel = () => resolveClosureReview(g()).model; const flowGuardsEnforced = () => resolveFlowGuards(g()).enforce; const specDirs = () => resolveFlowGuards(g()).specDirs; // Closure-review model guard - independent of flowGuards, gated by closureReview.enforce. // gating contract: closureModelGuardApplies (see helpers). Marker-first: gauntletEntered // short-circuits before closureEnforced()'s settings load, so an ad-hoc (out-of-flow) // subagent dispatch never loads settings or leaks a settingsErrorWarning onto its result. // Inline-matched (not called) so closureEnforced() stays a lazy second conjunct. if (event.toolName === "subagent" && gauntletEntered && closureEnforced()) { // Only execution-mode dispatches carry a model or run agents; management/control // modes (action: list/get/create/update/delete/status/...) execute nothing, so skip them. const hasAction = !!(event.input as { action?: unknown })?.action; // Fix-loop window: verify in progress and the R0 audit observed. Read directly, // not via activeGuardPhase() - verify is not a GUARD_PHASES member. const fixLoopWindow = !isSubagentChild && !hasAction && phases.verify.status === "in_progress" && conformanceDispatched; if (fixLoopWindow && (event.input as { agent?: unknown })?.agent === "implementer") { return { block: true, reason: loneImplementerBlockReason }; } if (fixLoopWindow && collectAgents(event.input, "implementer").length > 0) { const cap = resolveClosureReview(g()).maxFixRounds; if (fixRounds >= cap) { const funded = fixRoundCredits > 0 && (event.input as { async?: unknown })?.async !== true; if (!funded) { return { block: true, reason: fixRoundCapBlockReason(fixRounds, cap, join(ctx.cwd, ".pi", "settings.json")) }; } } } const model = closureReviewModel(); if (model && !hasAction) { const configured = model.trim(); const models = conformanceModels(event.input); if (models.length > 0) { const missing = models.filter((m) => m === undefined).length; if (missing > 0) { return { block: true, reason: closureModelBlockReason(configured, missing, models.length) }; } const mismatched = [...new Set((models as string[]).filter((m) => m !== configured))]; if (mismatched.length > 0) { addGuardWarning(event.toolCallId, closureModelMismatchWarning(configured, mismatched)); } } } } // Flow guards apply only to write/edit/bash while a guard phase is active. // Cheap in-memory gate BEFORE any settings load: phase state is in-memory, so an // event no guard inspects (any read-only tool, or any tool in a dormant session) // returns here without touching disk. Only genuinely guardable events pay for g(). const brainstormActive = phases.brainstorm.status === "in_progress"; const guardableWrite = (event.toolName === "write" || event.toolName === "edit") && (flowGuardApplies(brainstormActive, gauntletEntered) || implementWriteGuardApplies(phases.plan.status, phases.implement.status, gauntletEntered, isSubagentChild)); const guardableBash = event.toolName === "bash" && flowGuardApplies(activeGuardPhase() !== undefined, gauntletEntered); if (!guardableWrite && !guardableBash) return undefined; if (!flowGuardsEnforced()) return undefined; // Guard 3 — write/edit outside the spec dir during brainstorm. // Guard 4 — parent write/edit in the armed implement window (spec // 2026-08-07-resume-spec-in-hand): advisory, warn-once, plans dir also exempt. if (event.toolName === "write" || event.toolName === "edit") { const p = (event.input as { path?: unknown } | undefined)?.path; if (phases.brainstorm.status === "in_progress") { if (firedGuards.get("brainstorm-write")) return undefined; if (typeof p !== "string" || pathInSpecDirs(p, specDirs())) return undefined; firedGuards.set("brainstorm-write", true); addGuardWarning(event.toolCallId, brainstormWriteWarning(specDirs())); return undefined; } if (firedGuards.get("implement-write")) return undefined; if (typeof p !== "string" || pathInSpecDirs(p, implementExemptDirs(specDirs()))) return undefined; firedGuards.set("implement-write", true); addGuardWarning(event.toolCallId, implementWriteWarning()); return undefined; } if (event.toolName !== "bash") return undefined; const command = (event.input as { command?: unknown } | undefined)?.command; if (typeof command !== "string" || !command) return undefined; const warnings: string[] = []; // Guard 2 — branch op in place (primary checkout only, brainstorm/plan/implement). const gphase = activeGuardPhase(); if ( inPrimaryCheckout && gphase && !GIT_WORKTREE.test(command) && (BRANCH_SWITCH.test(command) || BRANCH_CHECKOUT.test(command)) ) { return { block: true, reason: branchBlockReason(gphase) }; } // Marker commit guard — the context draft (brainstorming gather step) must be // overwritten by the real spec before any commit lands. Backstop to the skill's // own post-write check. Blocked, like Guard 2; skipped entirely when // flowGuards.enforce is false (checked above). // gating contract: markerGuardApplies (see helpers) - enforce checked once above // (flowGuardsEnforced()); re-checking it here would be belt-and-suspenders. if (phases.brainstorm.status === "in_progress") { const commit = parseGitCommit(command); if (commit) { const repoDir = resolveRepoDir(commit, ctx.cwd); const listFiles = (dir: string): string[] => { const walk = (d: string): string[] => { return readdirSync(d, { withFileTypes: true }).flatMap((e) => { const p = join(d, e.name); return e.isDirectory() ? walk(p) : e.isFile() ? [p] : []; }); }; try { return walk(dir); } catch { return []; } }; const readFirstLine = (file: string): string | undefined => { try { return readFileSync(file, "utf8").split("\n", 1)[0]; } catch { return undefined; } }; const hit = findMarkerFile(repoDir, specDirs(), listFiles, readFirstLine); if (hit) return { block: true, reason: markerBlockReason(hit) }; } } // Guard 5 — review-cadence reminder (presence-only, advisory). Parent-session // commits during implement only; child sessions (PI_SUBAGENT_DEPTH >= 1) are // the implementers' own task commits and are exempt by design. if ( !isSubagentChild && phases.implement.status === "in_progress" && parseGitCommit(command) && cadenceLastSeen.implementer > 0 && cadenceLastSeen.implementer > cadenceLastSeen["spec-reviewer"] && cadenceLastSeen.implementer > cadenceLastSeen["code-reviewer"] ) { warnings.push(REVIEW_CADENCE_WARNING); } // Guard 3 — bash mutation outside the spec dir during brainstorm. if (phases.brainstorm.status === "in_progress" && !firedGuards.get("brainstorm-write")) { // Redirect target is cleanly extractable: judge it directly against the spec dirs, // so `cat doc/specs/x.md > src/foo.ts` (writes OUTSIDE the spec dir) still fires // even though the command text mentions the spec dir. const target = command.match(BASH_REDIRECT)?.[1]; const redirectOutsideSpec = target !== undefined && !TEMP_TARGET.test(target) && !pathInSpecDirs(target, specDirs()); // tee / sed -i / git apply targets are not cleanly extractable; fall back to a // best-effort whole-command spec-dir mention check (accepted heuristic, advisory + warn-once). const otherMutation = BASH_TEE.test(command) || BASH_SED_I.test(command) || BASH_GIT_APPLY.test(command); const otherOutsideSpec = otherMutation && !specDirs().some((d) => command.includes(d)) && !SCRATCH_MENTION.test(command); if (redirectOutsideSpec || otherOutsideSpec) { firedGuards.set("brainstorm-write", true); warnings.push(brainstormWriteWarning(specDirs())); } } if (warnings.length > 0) addGuardWarning(event.toolCallId, warnings.join("\n\n")); return undefined; }); pi.on("tool_result", async (event, ctx) => { if (event.toolName === "subagent") { observeFixWave(event.details, event.isError, ctx); if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true; observeCadence(event.details); const warning = pendingGuardWarnings.get(event.toolCallId); if (warning) { pendingGuardWarnings.delete(event.toolCallId); return { content: [{ type: "text" as const, text: warning }, ...event.content] }; } return undefined; } // Guards 2/3 — prepend the stashed advisory warning to the bash/write/edit result. if (event.toolName === "bash" || event.toolName === "write" || event.toolName === "edit") { const warning = pendingGuardWarnings.get(event.toolCallId); if (!warning) return undefined; pendingGuardWarnings.delete(event.toolCallId); return { content: [{ type: "text" as const, text: warning }, ...event.content] }; } return undefined; }); pi.on("tool_execution_end", async (event, ctx) => { if (event.toolName !== "plan_tracker" || event.isError) return; const details = (event.result as { details?: { tasks?: { status: string }[]; error?: string } } | undefined) ?.details; if (!details || details.error) return; applyPlanActivity(details.tasks); updateWidget(ctx); }); const GauntletSettingParams = Type.Object({ key: StringEnum(["specCouncil", "closureReview", "escalationLoop"] as const, { description: "Which gauntlet setting to resolve (merged repo-over-preset).", }), }); pi.registerTool({ name: "gauntlet_setting", label: "Gauntlet Setting", description: "Resolve merged piGauntlet.* settings (repo over preset); for skill use only.", parameters: GauntletSettingParams, executionMode: "sequential", async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { gauntlet, errors } = loadGauntletSettings(ctx.cwd); const payload = params.key === "specCouncil" ? { key: "specCouncil" as const, ...resolveSpecCouncil(gauntlet), errors } : params.key === "closureReview" ? { key: "closureReview" as const, ...resolveClosureReview(gauntlet), errors } : { key: "escalationLoop" as const, ...resolveEscalationLoop(gauntlet, mainLoopModel(ctx.model, ctx.thinkingLevel)), errors, }; return { content: [{ type: "text", text: "```json\n" + JSON.stringify(payload, null, 2) + "\n```" }], details: payload, }; }, }); const PlanCheckParams = Type.Object({ planPath: Type.String({ description: "Path to the implementation plan (absolute, or relative to cwd)" }), }); pi.registerTool({ name: "plan_check", label: "Plan Check", description: "Deterministically verify an implementation plan against its spec (mechanical checks); " + "a pass stamps the plan for implement-start.", parameters: PlanCheckParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const fail = (findings: PlanCheckFinding[], planAbs: string) => { planCheckStamp = undefined; const lines = findings.map((f) => `${f.check} @ line ${f.line}: ${f.reason}\n ${f.text}`); return { content: [{ type: "text" as const, text: `FAIL (${findings.length} findings)\n${lines.join("\n")}` }], details: { status: "fail", planPath: planAbs, findings } as PlanCheckDetails, }; }; const planAbs = isAbsolute(params.planPath) ? params.planPath : resolve(ctx.cwd, params.planPath); try { let planBytes: Buffer; try { planBytes = readFileBytes(planAbs); } catch { return fail([{ check: "input", line: 0, text: "", reason: `plan file unreadable at ${planAbs}` }], planAbs); } const planText = planBytes.toString("utf8"); const planLines = planText.split("\n"); const planSeparatorIdx = planLines.findIndex((l) => l.trim() === "---"); const planHeaderLines = planSeparatorIdx === -1 ? planLines : planLines.slice(0, planSeparatorIdx); const specLine = planHeaderLines.find((l) => /^\*\*Spec:\*\*/.test(l) && !l.includes("\u00a7")); const specPathRaw = specLine ?.replace(/^\*\*Spec:\*\*/, "") .trim() .replace(/^`|`$/g, ""); if (!specPathRaw) { return fail( [{ check: "input", line: 0, text: "", reason: "no path-only **Spec:** header line found in plan" }], planAbs, ); } const specAbs = isAbsolute(specPathRaw) ? specPathRaw : resolve(ctx.cwd, specPathRaw); let specBytes: Buffer; try { specBytes = readFileBytes(specAbs); } catch { return fail([{ check: "input", line: 0, text: "", reason: `spec file unreadable at ${specAbs}` }], planAbs); } const specText = specBytes.toString("utf8"); const port: FsPort = { exists: (p) => existsSync(resolve(ctx.cwd, p)), glob: (pattern) => globSync(pattern, { cwd: ctx.cwd }), }; const findings = checkPlan(planText, specText, port); if (findings.length > 0) return fail(findings, planAbs); const planSha256 = sha256(planBytes); const specSha256 = sha256(specBytes); const details: PlanCheckDetails = { status: "pass", planPath: planAbs, planSha256, specPath: specAbs, specSha256, }; if (gauntletEntered) { planCheckStamp = { planPath: planAbs, planSha256, specPath: specAbs, specSha256 }; return { content: [{ type: "text" as const, text: "PASS \u2014 plan verified and stamped for implement-start" }], details, }; } return { content: [{ type: "text" as const, text: "PASS (no flow to stamp)" }], details }; } catch (err) { return fail([{ check: "internal", line: 0, text: "", reason: String(err) }], planAbs); } }, }); pi.registerTool({ name: "phase_tracker", label: "Phase Tracker", description: "Track workflow phase progress (brainstorm → plan → implement → verify → ship); " + "ad-hoc calls do not arm gates. Not for ad-hoc use.", parameters: PhaseTrackerParams, executionMode: "sequential", async execute(_toolCallId, params, _signal, _onUpdate, ctx) { switch (params.action) { case "start": { if (!params.phase) { return { content: [{ type: "text", text: "Error: phase required for start" }], details: { action: "start", phases: { ...phases }, error: "phase required" } as PhaseTrackerDetails, }; } const current = phases[params.phase].status; if ((current === "complete" || current === "skipped") && !params.force) { return { content: [ { type: "text", text: `Error: phase_tracker start: phase '${params.phase}' is already ${current}. Pass force: true to restart it intentionally.`, }, ], details: { action: "start", phases: { ...phases }, error: `phase '${params.phase}' is already ${current}`, } as PhaseTrackerDetails, }; } const blocked = PHASES.find((p) => p !== params.phase && phases[p].status === "in_progress"); if (blocked) { return { content: [ { type: "text", text: `Error: phase "${blocked}" is already in_progress. Complete or skip it first.`, }, ], details: { action: "start", phases: { ...phases }, error: `${blocked} already in_progress`, } as PhaseTrackerDetails, }; } if ( params.phase === "implement" && gauntletEntered && resolveFlowGuards(loadGauntletSettings(ctx.cwd).gauntlet).enforce ) { const reject = (text: string) => ({ content: [{ type: "text" as const, text: `Error: ${text}` }], details: { action: "start", phases: { ...phases }, error: text } as PhaseTrackerDetails, }); if (!planCheckStamp) { return reject( 'plan not verified - run plan_check({ planPath: "" }) and fix findings before starting implementation', ); } for (const [file, expected] of [ [planCheckStamp.planPath, planCheckStamp.planSha256], [planCheckStamp.specPath, planCheckStamp.specSha256], ] as const) { let bytes: Buffer; try { bytes = readFileBytes(file); } catch { return reject(`plan-check stamp file missing at ${file} - re-run plan_check on the current plan path`); } if (sha256(bytes) !== expected) { return reject(`plan-check stamp is stale: ${file} changed since the last pass - re-run plan_check and retry`); } } } phases = { ...phases, [params.phase]: transitionPhaseState("in_progress") as PhaseState }; // A rewind must not inherit the prior verify's conformance latch. if (params.phase === "implement") { conformanceDispatched = false; fixRounds = 0; fixRoundCredits = 0; } gauntletEntered = nextGauntletEntered(gauntletEntered, "start", phases.brainstorm.status); firedGuards.clear(); updateWidget(ctx); return { content: [{ type: "text", text: `Phase "${params.phase}" → in_progress\n${formatStatus(phases)}` }], details: { action: "start", phases: { ...phases } } as PhaseTrackerDetails, }; } case "complete": { if (!params.phase) { return { content: [{ type: "text", text: "Error: phase required for complete" }], details: { action: "complete", phases: { ...phases }, error: "phase required" } as PhaseTrackerDetails, }; } const current = phases[params.phase].status; if (current !== "in_progress" && current !== "pending") { return { content: [{ type: "text", text: `Error: phase "${params.phase}" is ${current}, cannot complete.` }], details: { action: "complete", phases: { ...phases }, error: `${params.phase} is ${current}`, } as PhaseTrackerDetails, }; } // gating contract: closureGateBlocks (see helpers). gauntletEntered is the leading // conjunct so a cold-session `complete verify` (the #2 incident) neither blocks nor // loads settings; the remaining conjuncts match closureGateBlocks exactly. if ( params.phase === "verify" && gauntletEntered && resolveClosureReview(loadGauntletSettings(ctx.cwd).gauntlet).enforce && !conformanceDispatched ) { return { content: [{ type: "text", text: CLOSURE_GATE_ERROR }], details: { action: "complete", phases: { ...phases }, error: "no conformance-reviewer dispatch observed", } as PhaseTrackerDetails, }; } if ( gauntletEntered && (params.phase === "implement" || params.phase === "verify") && resolveFlowGuards(loadGauntletSettings(ctx.cwd).gauntlet).enforce ) { let tasks: { name: string; status: string }[] = []; for (const entry of [...ctx.sessionManager.getBranch()].reverse()) { if ( entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.toolName !== "plan_tracker" || entry.message.isError ) { continue; } const details = entry.message.details as { tasks?: { name: string; status: string }[]; error?: string } | undefined; if (!details || details.error || !details.tasks) continue; tasks = details.tasks; break; } const unfinished = tasks.flatMap((task, index) => task.status === "pending" || task.status === "in_progress" ? [`${index}: ${task.name} (${task.status})`] : [], ); if (unfinished.length) { return { content: [ { type: "text", text: `Cannot complete ${params.phase}: unfinished tasks:\n${unfinished.join("\n")}\nReconcile these same indices against acceptance evidence, update them and retry.`, }, ], details: { action: "complete", phases: { ...phases }, error: "unfinished tasks" } as PhaseTrackerDetails, }; } } phases = { ...phases, [params.phase]: transitionPhaseState("complete") as PhaseState }; firedGuards.clear(); updateWidget(ctx); const advisory = params.phase === "verify" && phases.ship.status === "pending" ? `\n\n${SHIP_ADVISORY}` : ""; return { content: [ { type: "text", text: `Phase "${params.phase}" → complete\n${formatStatus(phases)}${advisory}` }, ], details: { action: "complete", phases: { ...phases } } as PhaseTrackerDetails, }; } case "skip": { if (!params.phase) { return { content: [{ type: "text", text: "Error: phase required for skip" }], details: { action: "skip", phases: { ...phases }, error: "phase required" } as PhaseTrackerDetails, }; } if (!params.reason || params.reason.trim() === "") { return { content: [ { type: "text", text: "Error: phase_tracker skip requires a 'reason' string explaining why the phase is being skipped", }, ], details: { action: "skip", phases: { ...phases }, error: "reason required for skip" } as PhaseTrackerDetails, }; } const reason = params.reason; phases = { ...phases, [params.phase]: transitionPhaseState("skipped", reason) as PhaseState }; firedGuards.clear(); updateWidget(ctx); return { content: [ { type: "text", text: `Phase "${params.phase}" → skipped (${reason})\n${formatStatus(phases)}` }, ], details: { action: "skip", phases: { ...phases } } as PhaseTrackerDetails, }; } case "substep": { if (!params.phase) { return { content: [{ type: "text", text: "Error: phase required for substep" }], details: { action: "substep", phases: { ...phases }, error: "phase required" } as PhaseTrackerDetails, }; } const check = checkSubstep(phases[params.phase].status); if (!check.ok) { return { content: [{ type: "text", text: `Error: phase "${params.phase}": ${check.error}` }], details: { action: "substep", phases: { ...phases }, error: check.error } as PhaseTrackerDetails, }; } const entry: PhaseState = { status: "in_progress" }; if (typeof params.substep === "string" && params.substep.trim()) entry.substep = params.substep.trim(); phases = { ...phases, [params.phase]: entry }; updateWidget(ctx); const label = entry.substep ? `Phase "${params.phase}" substep → ${entry.substep}` : `Phase "${params.phase}" substep cleared`; return { content: [{ type: "text", text: `${label}\n${formatStatus(phases)}` }], details: { action: "substep", phases: { ...phases } } as PhaseTrackerDetails, }; } case "status": { return { content: [{ type: "text", text: formatStatus(phases) }], details: { action: "status", phases: { ...phases } } as PhaseTrackerDetails, }; } case "reset": { phases = Object.fromEntries( PHASES.map((p) => [p, transitionPhaseState("pending")]), ) as PhaseMap; conformanceDispatched = false; fixRounds = 0; fixRoundCredits = 0; gauntletEntered = nextGauntletEntered(gauntletEntered, "reset", phases.brainstorm.status); firedGuards.clear(); updateWidget(ctx); return { content: [{ type: "text", text: "Phase tracker reset. All phases pending." }], details: { action: "reset", phases: { ...phases } } as PhaseTrackerDetails, }; } case "grant_fix_rounds": { const reject = (error: string) => ({ content: [{ type: "text" as const, text: `Error: ${error}` }], details: { action: "grant_fix_rounds", phases: { ...phases }, error } as PhaseTrackerDetails, }); if (params.rounds === undefined) return reject("grant_fix_rounds requires rounds: a positive integer"); const reason = params.reason?.trim() ?? ""; if (reason.length === 0) return reject("grant_fix_rounds requires reason: the human's approval, quoted"); const closure = resolveClosureReview(loadGauntletSettings(ctx.cwd).gauntlet); const capLive = !isSubagentChild && gauntletEntered && phases.verify.status === "in_progress" && conformanceDispatched && closure.enforce && fixRounds >= closure.maxFixRounds; if (!capLive) { return reject( `grant_fix_rounds: no fix-round cap block is active (${fixRounds} used, cap ${closure.maxFixRounds}); nothing to overrule`, ); } if (fixRoundCredits > 0) { return reject(`grant_fix_rounds: ${fixRoundCredits} granted round(s) still unused; spend them before granting more`); } fixRoundCredits = params.rounds; return { content: [ { type: "text", text: `Granted ${params.rounds} extra fix round(s) - reason: "${reason}"\n${formatStatus(phases)}` }, ], details: { action: "grant_fix_rounds", rounds: params.rounds, reason, phases: { ...phases } } as PhaseTrackerDetails, }; } default: return { content: [{ type: "text", text: `Unknown action: ${params.action}` }], details: { action: "status", phases: { ...phases }, error: "unknown action" } as PhaseTrackerDetails, }; } }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("phase_tracker ")); text += theme.fg("muted", args.action); if (args.phase) { text += ` ${theme.fg("accent", args.phase)}`; } if (args.action === "skip" && args.reason) { text += ` ${theme.fg("dim", `(${args.reason})`)}`; } return new Text(text, 0, 0); }, renderResult(result, _options, theme) { const details = result.details as PhaseTrackerDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } if (details.error) { return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); } const p = details.phases; const completeCount = PHASES.filter((ph) => p[ph].status === "complete").length; switch (details.action) { case "start": { const active = PHASES.find((ph) => p[ph].status === "in_progress") ?? ""; return new Text(theme.fg("warning", "→ ") + theme.fg("muted", `${active} in progress`), 0, 0); } case "complete": return new Text( theme.fg("success", "✓ ") + theme.fg("muted", `${completeCount}/${PHASES.length} phases complete`), 0, 0, ); case "skip": return new Text(theme.fg("dim", "⊘ ") + theme.fg("muted", "phase skipped"), 0, 0); case "substep": { const active = PHASES.find((ph) => p[ph].status === "in_progress"); const label = active ? phaseLabel(active, p[active].substep) : ""; return new Text(theme.fg("warning", "→ ") + theme.fg("muted", label), 0, 0); } case "status": { let text = theme.fg("muted", "Phases:"); for (const ph of PHASES) { const icon = p[ph].status === "complete" ? theme.fg("success", "✓") : p[ph].status === "in_progress" ? theme.fg("warning", "→") : p[ph].status === "skipped" ? theme.fg("dim", "⊘") : theme.fg("dim", "○"); const suffix = p[ph].reason ? theme.fg("dim", ` (${p[ph].reason})`) : ""; const nameColor = p[ph].status === "skipped" ? "dim" : "muted"; text += `\n${icon} ${theme.fg(nameColor, ph)}${suffix}`; } return new Text(text, 0, 0); } case "reset": return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Phase tracker reset"), 0, 0); case "grant_fix_rounds": return new Text( theme.fg("success", "+ ") + theme.fg("muted", `${details.rounds} extra fix round(s) granted`) + theme.fg("dim", ` (${details.reason})`), 0, 0, ); default: return new Text(theme.fg("dim", "Done"), 0, 0); } }, }); }