pi-gauntlet 4.4.2 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## v4.5.0 - 2026-07-30
4
+
5
+ Recover stalled automatic handoffs without weakening gauntlet gates (#3). The existing
6
+ phase-tracker now sends one branch-local, fire-and-forget continuation nudge when an
7
+ `agent_settled` event leaves either `plan -> implement` or `verify -> ship` pending. The
8
+ handler requires brainstorming entry, no active phase, a non-aborted stop, an exact
9
+ recognized edge, an unspent ancestry-local attempt, and a final idle check. Recovery
10
+ messages persist their edge in custom-message details, so repeated settlements and session
11
+ reconstruction do not re-trigger the same unchanged handoff.
12
+
13
+ The implementation remains deliberately narrow: no transition engine, retry loop, timer,
14
+ settings key, phase mutation, or older-host fallback. Prompt wording now reinforces both
15
+ automatic handoffs, while specification approval and the final branch disposition remain
16
+ human gates. Pure classifier tests and real extension event tests cover edge selection,
17
+ persistence, abort/idleness guards, competing-handler ordering, and one-shot behavior.
18
+
19
+ ## v4.4.3 - 2026-07-19
20
+
21
+ Brainstorming is the sole gauntlet entry point (#2): gate all three phase-tracker
22
+ enforcement surfaces (closure completion gate, closure-model guard, flow guards) on a
23
+ durable `gauntletEntered` marker so the gauntlet is dormant unless brainstorming started
24
+ the flow. A reflexive `phase_tracker start verify` on an ad-hoc one-liner no longer trips
25
+ the closure gate or forces a spurious conformance dispatch.
26
+
27
+ The marker is derived in `reconstructState` from the session branch (mirroring
28
+ `conformanceDispatched`: reset on reconstruct, threaded through replay, updated in the live
29
+ start/reset handlers via one `nextGauntletEntered` transition), so it survives pi-condense
30
+ pruning and `--session` resume. All three surfaces gate marker-first, so a dormant
31
+ out-of-flow session short-circuits before any settings load; in-flow behavior is unchanged
32
+ (`brainstormActive` implies `gauntletEntered`). New pure helpers `nextGauntletEntered` /
33
+ `closureGateBlocks` / `closureModelGuardApplies` / `flowGuardApplies` are unit-tested; the
34
+ two settings-dependent surfaces inline-match their predicate (the `markerGuardApplies`
35
+ convention) to preserve marker-first laziness. Docs (README, configuration.md, AGENTS.md,
36
+ conformance-check.md, the `phase_tracker` tool description) reworded: enforcement is opt-in
37
+ by brainstorming entry, not ambient.
38
+
3
39
  ## v4.4.2 - 2026-07-15
4
40
 
5
41
  Condense the finish-time conformance disposition gate to a dense, human-readable
package/README.md CHANGED
@@ -38,9 +38,11 @@ Concretely, one change through the gauntlet:
38
38
  1. You describe the change. **`brainstorming`** sets up an isolated worktree, explores the codebase, and turns your description into a written spec. A multi-model critique runs on it automatically. **You read and approve the spec - human gate 1.** No implementation code exists yet.
39
39
  2. **`writing-plans`** decomposes the approved spec into atomic, independently-verifiable tasks, grouped into parallel waves where they don't touch the same files.
40
40
  3. **`subagent-driven-development`** executes the plan one task at a time, each in a fresh subagent, behind spec-compliance review then code-quality review. TDD-locked: red, green, refactor.
41
- 4. **verify**: a whole-diff code review, then the **conformance gate** - a subagent reads the finished code and docs against your *original words* from step 1, not the plan, and reports per-requirement: delivered, partial, missing, drifted, or unauthorized. This gate is machine-blocked from being skipped. Compatible executable recommendations auto-run through an isolated fix-and-re-audit loop with no prompt; anything still open surfaces as a dense list - one line per decision, plain-language, with its recommended choice inline. Reply `1` to take every recommendation, or `2:` with per-item overrides; a current `CONFORMS` / no-concerns result goes straight to the branch options with no extra conformance sign-off.
41
+ 4. **verify**: a whole-diff code review, then the **conformance gate** - a subagent reads the finished code and docs against your *original words* from step 1, not the plan, and reports per-requirement: delivered, partial, missing, drifted, or unauthorized. Inside a brainstorming-entered flow this gate is machine-blocked from being skipped. Compatible executable recommendations auto-run through an isolated fix-and-re-audit loop with no prompt; anything still open surfaces as a dense list - one line per decision, plain-language, with its recommended choice inline. Reply `1` to take every recommendation, or `2:` with per-item overrides; a current `CONFORMS` / no-concerns result goes straight to the branch options with no extra conformance sign-off.
42
42
  5. **`finishing-a-development-branch`**: squash, PR, keep, or discard. **Human gate 2** - the only other decision you make.
43
43
 
44
+ Only the machine-owned `plan -> implement` and `verify -> ship` handoffs receive a branch-local one-shot nudge after an unexpected settled stop; it is fire-and-forget, does not bypass either human gate, and older Pi hosts without `agent_settled` retain existing behavior.
45
+
44
46
  ```mermaid
45
47
  flowchart LR
46
48
  R([request]) --> B[brainstorm<br/>+ spec]
@@ -66,13 +68,13 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
66
68
  - **7 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `spec-council-member`, `spec-council-synthesizer`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
67
69
  - **3 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you commit/push without a passing test run since your last edit. See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
68
70
 
69
- pi-gauntlet is **opinionated**: every non-trivial change rides this one pipeline. There's no separate "just edit a file and commit" path - the skills gate each other, so the phase-tracker extension mechanically blocks a phase from closing before its gate runs. Reach for a shortcut and a gate stops you; that's the design, not friction.
71
+ pi-gauntlet is **opinionated**: every non-trivial change is *meant* to ride this one pipeline, entered through `brainstorming`. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs. A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
70
72
 
71
73
  ## Key concepts
72
74
 
73
75
  | Term | Meaning |
74
76
  | --- | --- |
75
- | Gate | A machine-enforced checkpoint between phases (e.g. `complete verify` is blocked until conformance review has run). Not a suggestion. |
77
+ | Gate | A machine-enforced checkpoint between phases (e.g. within a brainstorming-entered flow, `complete verify` is blocked until conformance review has run). Not a suggestion. |
76
78
  | Spec council | Multi-model critique of the spec before you see it (`roasting-the-spec`); falls back to a single-model critique if no council is configured. |
77
79
  | Conformance gate | The closing check: does the delivered code + docs match your *original prompt*, not the derived plan? Compatible executable recommendations auto-fix first; anything still open renders as a dense one-line-per-decision list with each recommended choice inline. Reply `1` to accept all recommendations or `2:` with per-item overrides; a current `CONFORMS` / no-concerns handoff goes straight to branch options with no extra sign-off. |
78
80
  | Wave | A batch of plan tasks that don't touch the same files, dispatched to implementers in parallel. |
@@ -3,6 +3,10 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  CONTEXT_DRAFT_MARKER,
5
5
  checkSubstep,
6
+ closureGateBlocks,
7
+ closureModelGuardApplies,
8
+ flowGuardApplies,
9
+ nextGauntletEntered,
6
10
  phaseLabel,
7
11
  parseGitCommit,
8
12
  resolveRepoDir,
@@ -10,8 +14,51 @@ import {
10
14
  markerBlockReason,
11
15
  transitionPhaseState,
12
16
  markerGuardApplies,
17
+ recoverableEdge,
18
+ type RecoveryPhaseMap,
19
+ type RecoveryPhaseStatus,
13
20
  } from "./phase-tracker-helpers.ts";
14
21
 
22
+ const recoveryPhases = (
23
+ overrides: Partial<Record<keyof RecoveryPhaseMap, RecoveryPhaseStatus>> = {},
24
+ ): RecoveryPhaseMap =>
25
+ Object.fromEntries(
26
+ (["brainstorm", "plan", "implement", "verify", "ship"] as const).map((phase) => [
27
+ phase,
28
+ { status: overrides[phase] ?? "pending" },
29
+ ]),
30
+ ) as RecoveryPhaseMap;
31
+
32
+ test("recoverableEdge: recognizes only the two authorized edges", () => {
33
+ assert.equal(recoverableEdge(recoveryPhases({ plan: "complete" })), "plan-implement");
34
+ assert.equal(recoverableEdge(recoveryPhases({ verify: "complete" })), "verify-ship");
35
+ });
36
+
37
+ test("recoverableEdge: rejects any active phase", () => {
38
+ for (const phase of ["brainstorm", "plan", "implement", "verify", "ship"] as const) {
39
+ const recoverable: Partial<Record<keyof RecoveryPhaseMap, RecoveryPhaseStatus>> = phase === "verify" || phase === "ship" ? { plan: "complete" } : { verify: "complete" };
40
+ assert.equal(recoverableEdge(recoveryPhases(recoverable)), phase === "verify" || phase === "ship" ? "plan-implement" : "verify-ship");
41
+ assert.equal(recoverableEdge(recoveryPhases({ ...recoverable, [phase]: "in_progress" })), undefined);
42
+ }
43
+ });
44
+
45
+ test("recoverableEdge: rejects invalid source and destination states", () => {
46
+ for (const status of ["pending", "in_progress", "skipped"] as const) {
47
+ assert.equal(recoverableEdge(recoveryPhases({ plan: status })), undefined);
48
+ assert.equal(recoverableEdge(recoveryPhases({ verify: status })), undefined);
49
+ }
50
+ for (const status of ["in_progress", "complete", "skipped"] as const) {
51
+ assert.equal(recoverableEdge(recoveryPhases({ plan: "complete", implement: status })), undefined);
52
+ assert.equal(recoverableEdge(recoveryPhases({ verify: "complete", ship: status })), undefined);
53
+ }
54
+ });
55
+
56
+ test("recoverableEdge: rejects unrelated and ambiguous phase maps", () => {
57
+ assert.equal(recoverableEdge(recoveryPhases()), undefined);
58
+ assert.equal(recoverableEdge(recoveryPhases({ brainstorm: "complete", implement: "complete" })), undefined);
59
+ assert.equal(recoverableEdge(recoveryPhases({ plan: "complete", verify: "complete" })), undefined);
60
+ });
61
+
15
62
  test("checkSubstep: in_progress -> ok", () => {
16
63
  assert.deepEqual(checkSubstep("in_progress"), { ok: true });
17
64
  });
@@ -118,3 +165,48 @@ test("markerGuardApplies: gated by flowGuards.enforce and brainstorm in_progress
118
165
  assert.equal(markerGuardApplies(true, "in_progress"), true);
119
166
  assert.equal(markerGuardApplies(true, "pending"), false);
120
167
  });
168
+
169
+ test("nextGauntletEntered: arms only when a start makes brainstorm in_progress", () => {
170
+ assert.equal(nextGauntletEntered(false, "start", "in_progress"), true);
171
+ assert.equal(nextGauntletEntered(true, "start", "in_progress"), true); // re-arm idempotent
172
+ });
173
+
174
+ test("nextGauntletEntered: reset always disarms", () => {
175
+ assert.equal(nextGauntletEntered(true, "reset", "pending"), false);
176
+ assert.equal(nextGauntletEntered(true, "reset", "in_progress"), false);
177
+ assert.equal(nextGauntletEntered(false, "reset", "pending"), false);
178
+ });
179
+
180
+ test("nextGauntletEntered: marker survives downstream actions", () => {
181
+ assert.equal(nextGauntletEntered(true, "start", "complete"), true); // start plan/implement (brainstorm already complete)
182
+ assert.equal(nextGauntletEntered(true, "complete", "complete"), true);
183
+ assert.equal(nextGauntletEntered(true, "substep", "in_progress"), true);
184
+ });
185
+
186
+ test("nextGauntletEntered: a non-brainstorm start never arms a dormant flow", () => {
187
+ assert.equal(nextGauntletEntered(false, "start", "complete"), false); // cold start verify/implement
188
+ assert.equal(nextGauntletEntered(false, "start", "skipped"), false);
189
+ assert.equal(nextGauntletEntered(false, "complete", "pending"), false);
190
+ });
191
+
192
+ test("closureGateBlocks: blocks only for verify + entered + enforce + not-yet-dispatched", () => {
193
+ assert.equal(closureGateBlocks("verify", true, true, false), true);
194
+ assert.equal(closureGateBlocks("verify", false, true, false), false); // incident: not entered -> no block
195
+ assert.equal(closureGateBlocks("verify", true, true, true), false); // already dispatched
196
+ assert.equal(closureGateBlocks("verify", true, false, false), false); // enforce off
197
+ assert.equal(closureGateBlocks("plan", true, true, false), false); // wrong phase
198
+ });
199
+
200
+ test("flowGuardApplies: requires both an active guard phase and an entered flow", () => {
201
+ assert.equal(flowGuardApplies(true, true), true);
202
+ assert.equal(flowGuardApplies(true, false), false); // not entered
203
+ assert.equal(flowGuardApplies(false, true), false); // phase not active
204
+ assert.equal(flowGuardApplies(false, false), false);
205
+ });
206
+
207
+ test("closureModelGuardApplies: requires both an entered flow and closure enforcement", () => {
208
+ assert.equal(closureModelGuardApplies(true, true), true);
209
+ assert.equal(closureModelGuardApplies(false, true), false); // not entered -> dormant
210
+ assert.equal(closureModelGuardApplies(true, false), false); // enforce off
211
+ assert.equal(closureModelGuardApplies(false, false), false);
212
+ });
@@ -10,6 +10,21 @@ export const CONTEXT_DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replac
10
10
 
11
11
  export type SubstepCheck = { ok: true } | { ok: false; error: string };
12
12
 
13
+ export type RecoveryEdge = "plan-implement" | "verify-ship";
14
+ export type RecoveryPhaseStatus = "pending" | "in_progress" | "complete" | "skipped";
15
+ export type RecoveryPhaseMap = Record<
16
+ "brainstorm" | "plan" | "implement" | "verify" | "ship",
17
+ { status: RecoveryPhaseStatus }
18
+ >;
19
+
20
+ export function recoverableEdge(phases: RecoveryPhaseMap): RecoveryEdge | undefined {
21
+ if (Object.values(phases).some((phase) => phase.status === "in_progress")) return undefined;
22
+ const planImplement = phases.plan.status === "complete" && phases.implement.status === "pending";
23
+ const verifyShip = phases.verify.status === "complete" && phases.ship.status === "pending";
24
+ if (planImplement === verifyShip) return undefined;
25
+ return planImplement ? "plan-implement" : "verify-ship";
26
+ }
27
+
13
28
  export function checkSubstep(phaseStatus: string): SubstepCheck {
14
29
  if (phaseStatus === "in_progress") return { ok: true };
15
30
  return { ok: false, error: `substep requires an in_progress phase (status is ${phaseStatus})` };
@@ -90,6 +105,46 @@ export function markerGuardApplies(flowGuardsEnforced: boolean, brainstormStatus
90
105
  return flowGuardsEnforced && brainstormStatus === "in_progress";
91
106
  }
92
107
 
108
+ // Flow-entry marker (spec 2026-07-19-sole-gauntlet-entry-point). The gauntlet is
109
+ // opt-in: enforcement is dormant unless brainstorming started this flow. A `start`
110
+ // that leaves brainstorm in_progress is the unique arming signal (exactly one phase
111
+ // is in_progress at a time; the start target is not stored but is inferable). `reset`
112
+ // disarms; every other action preserves the running marker so it survives brainstorm
113
+ // -> plan -> implement -> verify. brainstormStatus is the phase status AFTER the action.
114
+ export function nextGauntletEntered(prev: boolean, action: string, brainstormStatus: string): boolean {
115
+ if (action === "reset") return false;
116
+ if (action === "start" && brainstormStatus === "in_progress") return true;
117
+ return prev;
118
+ }
119
+
120
+ // Closure completion-gate decision. Marker-first: a non-entered flow never blocks
121
+ // `complete verify`. Executable contract for the extension's inlined check (same
122
+ // define-and-test-but-inline pattern as markerGuardApplies).
123
+ export function closureGateBlocks(
124
+ phase: string,
125
+ gauntletEntered: boolean,
126
+ closureEnforced: boolean,
127
+ conformanceDispatched: boolean,
128
+ ): boolean {
129
+ return phase === "verify" && gauntletEntered && closureEnforced && !conformanceDispatched;
130
+ }
131
+
132
+ // Closure-model-guard applicability. Marker-first: the extension inlines this with the
133
+ // settings read (closureEnforced()) as a lazy call AFTER gauntletEntered, so a dormant
134
+ // (out-of-flow) session performs no settings I/O. This predicate is the tested contract
135
+ // for that inlined check (same define-test-and-inline pattern as markerGuardApplies /
136
+ // closureGateBlocks); it takes the already-resolved booleans, never triggering the read.
137
+ export function closureModelGuardApplies(gauntletEntered: boolean, closureEnforced: boolean): boolean {
138
+ return gauntletEntered && closureEnforced;
139
+ }
140
+
141
+ // Flow-guard applicability: a guard fires only when its phase is active AND the flow
142
+ // was entered via brainstorming. Both inputs are in-memory, so the extension calls
143
+ // this directly with no settings-load cost.
144
+ export function flowGuardApplies(phaseActive: boolean, gauntletEntered: boolean): boolean {
145
+ return phaseActive && gauntletEntered;
146
+ }
147
+
93
148
  export const markerBlockReason = (file: string): string =>
94
149
  `Blocked: ${file} still begins with the context-draft marker - the spec-writing ` +
95
150
  `overwrite has not happened. Overwrite the draft with the real spec (write tool, ` +
@@ -0,0 +1,159 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import registerPhaseTracker from "./phase-tracker.ts";
4
+
5
+ const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
6
+ type Phase = (typeof PHASES)[number];
7
+ type Status = "pending" | "in_progress" | "complete" | "skipped";
8
+
9
+ const phases = (overrides: Partial<Record<Phase, Status>> = {}) =>
10
+ Object.fromEntries(PHASES.map((phase) => [phase, { status: overrides[phase] ?? "pending" }])) as Record<
11
+ Phase,
12
+ { status: Status }
13
+ >;
14
+
15
+ const phaseResult = (action: string, state: Record<Phase, { status: Status }>) => ({
16
+ type: "message",
17
+ message: { role: "toolResult", toolName: "phase_tracker", details: { action, phases: state } },
18
+ });
19
+
20
+ const assistant = (stopReason = "stop") => ({ type: "message", message: { role: "assistant", stopReason } });
21
+
22
+ const enteredBranch = (state: Record<Phase, { status: Status }>, extra: unknown[] = []) => [
23
+ phaseResult("start", phases({ brainstorm: "in_progress" })),
24
+ phaseResult("complete", state),
25
+ ...extra,
26
+ ];
27
+
28
+ function harness(options: { branch?: unknown[]; idle?: boolean; beforeSettled?: (setIdle: (idle: boolean) => void) => void; sendThrows?: boolean } = {}) {
29
+ const handlers = new Map<string, ((event: unknown, ctx: unknown) => unknown)[]>();
30
+ const tools: { name: string; execute: (...args: any[]) => unknown }[] = [];
31
+ const sent: { message: any; options: any }[] = [];
32
+ let idle = options.idle ?? true;
33
+ const ctx = {
34
+ cwd: process.cwd(),
35
+ hasUI: false,
36
+ isIdle: () => idle,
37
+ sessionManager: { getBranch: () => options.branch ?? [] },
38
+ };
39
+ const pi = {
40
+ on(event: string, handler: (event: unknown, context: unknown) => unknown) {
41
+ const registered = handlers.get(event) ?? [];
42
+ registered.push(handler);
43
+ handlers.set(event, registered);
44
+ },
45
+ registerTool(tool: { name: string; execute: (...args: any[]) => unknown }) {
46
+ tools.push(tool);
47
+ },
48
+ sendMessage(message: unknown, sendOptions: unknown) {
49
+ sent.push({ message, options: sendOptions });
50
+ if (options.sendThrows) throw new Error("send failed");
51
+ },
52
+ };
53
+ if (options.beforeSettled) pi.on("agent_settled", () => options.beforeSettled!(next => (idle = next)));
54
+ registerPhaseTracker(pi as any);
55
+ const emit = async (event: string) => {
56
+ for (const handler of handlers.get(event) ?? []) await handler({ type: event }, ctx);
57
+ };
58
+ return { emit, sent, tools, setIdle: (next: boolean) => (idle = next) };
59
+ }
60
+
61
+ const settle = async (h: ReturnType<typeof harness>) => {
62
+ await h.emit("session_start");
63
+ await h.emit("agent_settled");
64
+ };
65
+
66
+ test("agent_settled nudges each exact edge with persisted details", async () => {
67
+ for (const [state, edge, skill] of [
68
+ [phases({ brainstorm: "complete", plan: "complete" }), "plan-implement", "/skill:subagent-driven-development"],
69
+ [
70
+ phases({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete" }),
71
+ "verify-ship",
72
+ "/skill:finishing-a-development-branch",
73
+ ],
74
+ ] as const) {
75
+ const h = harness({ branch: enteredBranch(state, [assistant()]) });
76
+ await settle(h);
77
+ assert.equal(h.sent.length, 1);
78
+ assert.deepEqual(h.sent[0].options, { triggerTurn: true });
79
+ assert.equal(h.sent[0].message.customType, "pi-gauntlet-transition-recovery");
80
+ assert.equal(h.sent[0].message.display, true);
81
+ assert.deepEqual(h.sent[0].message.details, { piGauntletRecoveryEdge: edge });
82
+ assert.match(h.sent[0].message.content, new RegExp(skill.replace(/[/-]/g, "\\$&")));
83
+ }
84
+ });
85
+
86
+ test("repeated settlement and persisted matching recovery details suppress a second nudge", async () => {
87
+ const state = phases({ brainstorm: "complete", plan: "complete" });
88
+ const h = harness({ branch: enteredBranch(state, [assistant()]) });
89
+ await settle(h);
90
+ await h.emit("agent_settled");
91
+ assert.equal(h.sent.length, 1);
92
+
93
+ const restored = harness({
94
+ branch: enteredBranch(state, [
95
+ { type: "custom_message", customType: "pi-gauntlet-transition-recovery", details: { piGauntletRecoveryEdge: "plan-implement" } },
96
+ assistant(),
97
+ ]),
98
+ });
99
+ await settle(restored);
100
+ assert.equal(restored.sent.length, 0);
101
+ });
102
+
103
+ test("foreign or malformed custom-message details do not suppress recovery", async () => {
104
+ const state = phases({ brainstorm: "complete", plan: "complete" });
105
+ for (const entry of [
106
+ { type: "custom_message", customType: "other", details: { piGauntletRecoveryEdge: "plan-implement" } },
107
+ { type: "custom_message", customType: "pi-gauntlet-transition-recovery", details: null },
108
+ { type: "custom_message", customType: "pi-gauntlet-transition-recovery", details: { piGauntletRecoveryEdge: "nope" } },
109
+ ]) {
110
+ const h = harness({ branch: enteredBranch(state, [entry, assistant()]) });
111
+ await settle(h);
112
+ assert.equal(h.sent.length, 1);
113
+ }
114
+ });
115
+
116
+ test("only brainstorming-entered, non-aborted settled flows recover", async () => {
117
+ const state = phases({ brainstorm: "complete", plan: "complete" });
118
+ const cold = harness({ branch: [phaseResult("complete", state), assistant()] });
119
+ await settle(cold);
120
+ assert.equal(cold.sent.length, 0);
121
+
122
+ const branch = enteredBranch(state, [assistant("aborted")]);
123
+ const aborted = harness({ branch });
124
+ await settle(aborted);
125
+ assert.equal(aborted.sent.length, 0);
126
+ branch.push(assistant());
127
+ await aborted.emit("agent_settled");
128
+ assert.equal(aborted.sent.length, 1);
129
+ });
130
+
131
+ test("active phases, non-idleness, and earlier competing handlers do not spend recovery", async () => {
132
+ for (const phase of PHASES) {
133
+ const h = harness({ branch: enteredBranch(phases({ brainstorm: phase === "brainstorm" ? "in_progress" : "complete", plan: phase === "plan" ? "in_progress" : "complete", implement: phase === "implement" ? "in_progress" : "pending", verify: phase === "verify" ? "in_progress" : "pending", ship: phase === "ship" ? "in_progress" : "pending" }), [assistant()]) });
134
+ await settle(h);
135
+ assert.equal(h.sent.length, 0, phase);
136
+ }
137
+
138
+ const state = phases({ brainstorm: "complete", plan: "complete" });
139
+ const notIdle = harness({ branch: enteredBranch(state, [assistant()]), idle: false });
140
+ await settle(notIdle);
141
+ notIdle.setIdle(true);
142
+ await notIdle.emit("agent_settled");
143
+ assert.equal(notIdle.sent.length, 1);
144
+
145
+ const ordered = harness({
146
+ branch: enteredBranch(state, [assistant()]),
147
+ beforeSettled: setIdle => setIdle(false),
148
+ });
149
+ await settle(ordered);
150
+ assert.equal(ordered.sent.length, 0);
151
+ });
152
+
153
+ test("a throwing send spends the in-memory edge", async () => {
154
+ const h = harness({ branch: enteredBranch(phases({ brainstorm: "complete", plan: "complete" }), [assistant()]), sendThrows: true });
155
+ await h.emit("session_start");
156
+ await assert.rejects(h.emit("agent_settled"), /send failed/);
157
+ await h.emit("agent_settled");
158
+ assert.equal(h.sent.length, 1);
159
+ });
@@ -26,12 +26,16 @@ import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
26
26
  import {
27
27
  checkSubstep,
28
28
  findMarkerFile,
29
+ flowGuardApplies,
29
30
  markerBlockReason,
31
+ nextGauntletEntered,
30
32
  parseGitCommit,
31
33
  phaseLabel,
34
+ recoverableEdge,
32
35
  resolveRepoDir,
33
36
  STMT_START,
34
37
  transitionPhaseState,
38
+ type RecoveryEdge,
35
39
  } from "./lib/phase-tracker-helpers.ts";
36
40
 
37
41
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
@@ -78,6 +82,18 @@ const SHIP_ADVISORY =
78
82
  "menu is the human gate that resolves any carried-open decision. Only reopen verify if\n" +
79
83
  "a `fix` gap was left unresolved (neither fixed nor deferred).";
80
84
 
85
+ const RECOVERY_CUSTOM_TYPE = "pi-gauntlet-transition-recovery";
86
+ const RECOVERY_MESSAGES: Record<RecoveryEdge, string> = {
87
+ "plan-implement": "Continue the approved workflow now. Invoke /skill:subagent-driven-development.",
88
+ "verify-ship": "Continue the approved workflow now. Invoke /skill:finishing-a-development-branch.",
89
+ };
90
+
91
+ const recoveryEdgeFromDetails = (details: unknown): RecoveryEdge | undefined => {
92
+ if (!details || typeof details !== "object") return undefined;
93
+ const edge = (details as { piGauntletRecoveryEdge?: unknown }).piGauntletRecoveryEdge;
94
+ return edge === "plan-implement" || edge === "verify-ship" ? edge : undefined;
95
+ };
96
+
81
97
  // --- Flow guards (spec 2026-06-17-gauntlet-flow-guards) ---
82
98
 
83
99
  const GUARD_PHASES: Phase[] = ["brainstorm", "plan", "implement"];
@@ -239,6 +255,8 @@ function formatStatus(phases: PhaseMap): string {
239
255
  export default function (pi: ExtensionAPI) {
240
256
  let phases: PhaseMap = emptyPhases();
241
257
  let conformanceDispatched = false;
258
+ let gauntletEntered = false;
259
+ const attemptedRecoveryEdges = new Set<RecoveryEdge>();
242
260
 
243
261
  // Warn-once-per-phase ledger; cleared on every phase transition and on reconstruct.
244
262
  const firedGuards = new Map<string, boolean>();
@@ -283,12 +301,29 @@ export default function (pi: ExtensionAPI) {
283
301
  }
284
302
  };
285
303
 
304
+ const branchLatestAssistantStopReason = (ctx: ExtensionContext): string | undefined => {
305
+ let stopReason: string | undefined;
306
+ for (const entry of ctx.sessionManager.getBranch()) {
307
+ if (entry.type === "message" && entry.message.role === "assistant") stopReason = entry.message.stopReason;
308
+ }
309
+ return stopReason;
310
+ };
311
+
286
312
  const reconstructState = (ctx: ExtensionContext) => {
287
313
  phases = emptyPhases();
288
314
  conformanceDispatched = false;
315
+ gauntletEntered = false;
316
+ attemptedRecoveryEdges.clear();
289
317
  firedGuards.clear();
290
318
  pendingGuardWarnings.clear();
291
319
  for (const entry of ctx.sessionManager.getBranch()) {
320
+ if (entry.type === "custom_message") {
321
+ if (entry.customType === RECOVERY_CUSTOM_TYPE) {
322
+ const edge = recoveryEdgeFromDetails(entry.details);
323
+ if (edge) attemptedRecoveryEdges.add(edge);
324
+ }
325
+ continue;
326
+ }
292
327
  if (entry.type !== "message") continue;
293
328
  const msg = entry.message;
294
329
  if (msg.role !== "toolResult") continue;
@@ -296,6 +331,7 @@ export default function (pi: ExtensionAPI) {
296
331
  const details = msg.details as PhaseTrackerDetails | undefined;
297
332
  if (details && !details.error) {
298
333
  phases = details.phases;
334
+ gauntletEntered = nextGauntletEntered(gauntletEntered, details.action, details.phases.brainstorm.status);
299
335
  if (details.action === "reset") conformanceDispatched = false;
300
336
  }
301
337
  } else if (msg.toolName === "subagent") {
@@ -325,6 +361,23 @@ export default function (pi: ExtensionAPI) {
325
361
  });
326
362
  }
327
363
 
364
+ pi.on("agent_settled", (_event, ctx) => {
365
+ const latestAssistantStopReason = branchLatestAssistantStopReason(ctx);
366
+ if (!gauntletEntered || latestAssistantStopReason === "aborted") return;
367
+ const edge = recoverableEdge(phases);
368
+ if (!edge || attemptedRecoveryEdges.has(edge) || !ctx.isIdle()) return;
369
+ attemptedRecoveryEdges.add(edge);
370
+ pi.sendMessage(
371
+ {
372
+ customType: RECOVERY_CUSTOM_TYPE,
373
+ content: RECOVERY_MESSAGES[edge],
374
+ display: true,
375
+ details: { piGauntletRecoveryEdge: edge },
376
+ },
377
+ { triggerTurn: true },
378
+ );
379
+ });
380
+
328
381
  pi.on("tool_call", async (event, ctx) => {
329
382
  // D3.b: one fresh settings read per event, lazily, only if a guard needs it.
330
383
  const addGuardWarning = (id: string, text: string) => {
@@ -345,7 +398,11 @@ export default function (pi: ExtensionAPI) {
345
398
  const specDirs = () => resolveFlowGuards(g()).specDirs;
346
399
 
347
400
  // Closure-review model guard - independent of flowGuards, gated by closureReview.enforce.
348
- if (event.toolName === "subagent" && closureEnforced()) {
401
+ // gating contract: closureModelGuardApplies (see helpers). Marker-first: gauntletEntered
402
+ // short-circuits before closureEnforced()'s settings load, so an ad-hoc (out-of-flow)
403
+ // subagent dispatch never loads settings or leaks a settingsErrorWarning onto its result.
404
+ // Inline-matched (not called) so closureEnforced() stays a lazy second conjunct.
405
+ if (event.toolName === "subagent" && gauntletEntered && closureEnforced()) {
349
406
  const model = closureReviewModel();
350
407
  // Only execution-mode dispatches carry a model; management/control modes
351
408
  // (action: list/get/create/update/delete/status/...) execute nothing, so skip them.
@@ -371,8 +428,10 @@ export default function (pi: ExtensionAPI) {
371
428
  // event no guard inspects (any read-only tool, or any tool in a dormant session)
372
429
  // returns here without touching disk. Only genuinely guardable events pay for g().
373
430
  const brainstormActive = phases.brainstorm.status === "in_progress";
374
- const guardableWrite = (event.toolName === "write" || event.toolName === "edit") && brainstormActive;
375
- const guardableBash = event.toolName === "bash" && activeGuardPhase() !== undefined;
431
+ const guardableWrite =
432
+ (event.toolName === "write" || event.toolName === "edit") && flowGuardApplies(brainstormActive, gauntletEntered);
433
+ const guardableBash =
434
+ event.toolName === "bash" && flowGuardApplies(activeGuardPhase() !== undefined, gauntletEntered);
376
435
  if (!guardableWrite && !guardableBash) return undefined;
377
436
 
378
437
  if (!flowGuardsEnforced()) return undefined;
@@ -527,7 +586,8 @@ export default function (pi: ExtensionAPI) {
527
586
  description:
528
587
  "Track workflow phase progress (brainstorm → plan → implement → verify → ship). " +
529
588
  "Actions: start (mark phase in_progress), complete (mark phase complete), " +
530
- "skip (mark phase skipped with reason), status (show all phases), reset (clear all phases), substep (set/clear a substep label on an in_progress phase).",
589
+ "skip (mark phase skipped with reason), status (show all phases), reset (clear all phases), substep (set/clear a substep label on an in_progress phase). " +
590
+ "Drives gauntlet-flow enforcement entered via brainstorming; the closure gate, closure-model guard, and flow guards arm only when brainstorming started the flow. Ad-hoc start verify/start implement calls do not arm the gates. Not for ad-hoc use.",
531
591
  parameters: PhaseTrackerParams,
532
592
 
533
593
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -572,6 +632,7 @@ export default function (pi: ExtensionAPI) {
572
632
  };
573
633
  }
574
634
  phases = { ...phases, [params.phase]: transitionPhaseState("in_progress") as PhaseState };
635
+ gauntletEntered = nextGauntletEntered(gauntletEntered, "start", phases.brainstorm.status);
575
636
  firedGuards.clear();
576
637
  updateWidget(ctx);
577
638
  return {
@@ -598,8 +659,12 @@ export default function (pi: ExtensionAPI) {
598
659
  } as PhaseTrackerDetails,
599
660
  };
600
661
  }
662
+ // gating contract: closureGateBlocks (see helpers). gauntletEntered is the leading
663
+ // conjunct so a cold-session `complete verify` (the #2 incident) neither blocks nor
664
+ // loads settings; the remaining conjuncts match closureGateBlocks exactly.
601
665
  if (
602
666
  params.phase === "verify" &&
667
+ gauntletEntered &&
603
668
  resolveClosureReview(loadGauntletSettings(ctx.cwd).gauntlet).enforce &&
604
669
  !conformanceDispatched
605
670
  ) {
@@ -694,6 +759,7 @@ export default function (pi: ExtensionAPI) {
694
759
  PHASES.map((p) => [p, transitionPhaseState("pending")]),
695
760
  ) as PhaseMap;
696
761
  conformanceDispatched = false;
762
+ gauntletEntered = nextGauntletEntered(gauntletEntered, "reset", phases.brainstorm.status);
697
763
  firedGuards.clear();
698
764
  updateWidget(ctx);
699
765
  return {
@@ -0,0 +1,41 @@
1
+ const sources = {
2
+ "@earendil-works/pi-ai": `export const StringEnum = (values, options = {}) => ({ values, ...options });`,
3
+ "@earendil-works/pi-coding-agent": `
4
+ export class SettingsManager {
5
+ static create() { return new SettingsManager(); }
6
+ getGlobalSettings() { return {}; }
7
+ getProjectSettings() { return {}; }
8
+ drainErrors() { return []; }
9
+ }
10
+ export const getAgentDir = () => "/tmp/pi-gauntlet-test-agent";
11
+ `,
12
+ "@earendil-works/pi-tui": `
13
+ export class Text {
14
+ constructor(text) { this.text = text; }
15
+ }
16
+ `,
17
+ "@sinclair/typebox": `
18
+ const schema = (...args) => ({ args });
19
+ export const Type = {
20
+ Object: schema,
21
+ Optional: schema,
22
+ String: schema,
23
+ Boolean: schema,
24
+ Union: schema,
25
+ Null: schema,
26
+ };
27
+ `,
28
+ };
29
+
30
+ export async function resolve(specifier, context, nextResolve) {
31
+ if (specifier in sources) return { url: `pi-gauntlet-test:${encodeURIComponent(specifier)}`, shortCircuit: true };
32
+ return nextResolve(specifier, context);
33
+ }
34
+
35
+ export async function load(url, context, nextLoad) {
36
+ if (url.startsWith("pi-gauntlet-test:")) {
37
+ const specifier = decodeURIComponent(url.slice("pi-gauntlet-test:".length));
38
+ return { format: "module", source: sources[specifier], shortCircuit: true };
39
+ }
40
+ return nextLoad(url, context);
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "4.4.2",
3
+ "version": "4.5.0",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "author": "Jacek Juraszek",
6
6
  "type": "module",
@@ -30,7 +30,8 @@ You are the **orchestrator**. You read the plan, dispatch, review the review, de
30
30
  - A subagent returns `NEEDS_CONTEXT` or `BLOCKED` (see [Implementer Status](#implementer-status))
31
31
  - A reviewer finds issues the implementer cannot resolve in two attempts
32
32
  - A ⚠️ workflow warning fires
33
- - You hit the end of the plan (then stop and report — see [After All Tasks](#after-all-tasks-complete))
33
+
34
+ Reaching the end of the plan is not a pause: continue through verification and invoke `/skill:finishing-a-development-branch` as defined in [After All Tasks](#after-all-tasks-complete).
34
35
 
35
36
  Periodic "should I continue?" prompts add latency without adding safety. The plan is the contract; execute it.
36
37
 
@@ -168,10 +168,10 @@ Per round:
168
168
  including DELIVERED rows and their `evidence` `file:line`) and the round's
169
169
  fix diff. Inject `model:` call-site per `gauntlet_setting({ key:
170
170
  "closureReview" }).model` — same mechanism as the initial audit; omit
171
- `model:` when it is `undefined` to inherit the parent's model. The
172
- phase-tracker closure guard blocks a dispatch that omits `model:` when
173
- `closureReview.model` is set, and warns (non-blocking) on one whose model
174
- differs.
171
+ `model:` when it is `undefined` to inherit the parent's model. Inside a
172
+ brainstorming-entered flow, the phase-tracker closure guard blocks a dispatch
173
+ that omits `model:` when `closureReview.model` is set, and warns (non-blocking)
174
+ on one whose model differs.
175
175
  7. **Converge or continue**: verdict `CONFORMS` → record it, done. Open gaps
176
176
  within the cap → re-partition (per the rule above) and start the next
177
177
  round. Cap (`gauntlet_setting({ key: "closureReview" }).maxFixRounds`,
@@ -231,7 +231,7 @@ Then auto-select the execution mode and proceed — no pause, no picker. The mod
231
231
  - **If any wave contains ≥2 tasks → Parallel-Wave Mode.** The strengthened Wave Grouping contract (files + runtime-resource disjoint) guarantees every multi-task wave is parallel-safe.
232
232
  - **Otherwise (pure dependency chain, one task per wave) → Sequential Mode.**
233
233
 
234
- Announce the selected mode in one line (transparency), then auto-invoke `/skill:subagent-driven-development` in this session. Do not wait for confirmation — the spec gate already happened, and the plan is a mechanical derivative. The only pauses from here are in-flight STOPs (`BLOCKED` / `NEEDS_CONTEXT`) and the end gate, both owned by the executor.
234
+ Auto-invoke `/skill:subagent-driven-development` in this session. Do not wait for confirmation — the spec gate already happened, and the plan is a mechanical derivative. The only pauses from here are in-flight STOPs (`BLOCKED` / `NEEDS_CONTEXT`) and the end gate, both owned by the executor.
235
235
 
236
236
  ## Red Flags — STOP
237
237