pi-gauntlet 4.4.3 → 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,21 @@
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
+
3
19
  ## v4.4.3 - 2026-07-19
4
20
 
5
21
  Brainstorming is the sole gauntlet entry point (#2): gate all three phase-tracker
package/README.md CHANGED
@@ -41,6 +41,8 @@ Concretely, one change through the gauntlet:
41
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]
@@ -14,8 +14,51 @@ import {
14
14
  markerBlockReason,
15
15
  transitionPhaseState,
16
16
  markerGuardApplies,
17
+ recoverableEdge,
18
+ type RecoveryPhaseMap,
19
+ type RecoveryPhaseStatus,
17
20
  } from "./phase-tracker-helpers.ts";
18
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
+
19
62
  test("checkSubstep: in_progress -> ok", () => {
20
63
  assert.deepEqual(checkSubstep("in_progress"), { ok: true });
21
64
  });
@@ -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})` };
@@ -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
+ });
@@ -31,9 +31,11 @@ import {
31
31
  nextGauntletEntered,
32
32
  parseGitCommit,
33
33
  phaseLabel,
34
+ recoverableEdge,
34
35
  resolveRepoDir,
35
36
  STMT_START,
36
37
  transitionPhaseState,
38
+ type RecoveryEdge,
37
39
  } from "./lib/phase-tracker-helpers.ts";
38
40
 
39
41
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
@@ -80,6 +82,18 @@ const SHIP_ADVISORY =
80
82
  "menu is the human gate that resolves any carried-open decision. Only reopen verify if\n" +
81
83
  "a `fix` gap was left unresolved (neither fixed nor deferred).";
82
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
+
83
97
  // --- Flow guards (spec 2026-06-17-gauntlet-flow-guards) ---
84
98
 
85
99
  const GUARD_PHASES: Phase[] = ["brainstorm", "plan", "implement"];
@@ -242,6 +256,7 @@ export default function (pi: ExtensionAPI) {
242
256
  let phases: PhaseMap = emptyPhases();
243
257
  let conformanceDispatched = false;
244
258
  let gauntletEntered = false;
259
+ const attemptedRecoveryEdges = new Set<RecoveryEdge>();
245
260
 
246
261
  // Warn-once-per-phase ledger; cleared on every phase transition and on reconstruct.
247
262
  const firedGuards = new Map<string, boolean>();
@@ -286,13 +301,29 @@ export default function (pi: ExtensionAPI) {
286
301
  }
287
302
  };
288
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
+
289
312
  const reconstructState = (ctx: ExtensionContext) => {
290
313
  phases = emptyPhases();
291
314
  conformanceDispatched = false;
292
315
  gauntletEntered = false;
316
+ attemptedRecoveryEdges.clear();
293
317
  firedGuards.clear();
294
318
  pendingGuardWarnings.clear();
295
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
+ }
296
327
  if (entry.type !== "message") continue;
297
328
  const msg = entry.message;
298
329
  if (msg.role !== "toolResult") continue;
@@ -330,6 +361,23 @@ export default function (pi: ExtensionAPI) {
330
361
  });
331
362
  }
332
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
+
333
381
  pi.on("tool_call", async (event, ctx) => {
334
382
  // D3.b: one fresh settings read per event, lazily, only if a guard needs it.
335
383
  const addGuardWarning = (id: string, text: string) => {
@@ -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.3",
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
 
@@ -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