pi-gauntlet 7.25.1 → 7.27.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v7.27.0 - 2026-10-22
4
+
5
+ ### Added
6
+
7
+ - **The phase widget labels the in-progress phase from the dispatches it observes.** A `subagent` call of a known persona (`scout`, `context-builder`, `spec-council-*`, `spec-reviewer`, `plan-writer`, `implementer`, `code-reviewer`, `conformance-reviewer`) or a `plan_check` call sets the phase's substep while the dispatch runs - `verify(conformance)`, `implement(implement+sr)` - and the result clears it; a repeated label in the same phase renders a widget-only ordinal, `verify(conformance #2)`. Skills are unchanged, `status` and the resume brief show the bare label, and session replay restores only labels set by an explicit `substep` call. Vocabulary in `doc/configuration.md`.
8
+
9
+ ## v7.26.0 - 2026-10-22
10
+
11
+ ### Changed
12
+
13
+ - **The implement orchestrator honors standing amend grants and per-task fix-round funding.** With amend permission on the spec's `**Amend-grant:**` line, it applies each amend-class item's recommended edit in the same turn instead of presenting an option menu; a redraw still stops. A granting sentence may include `fund N fix rounds` or `N more fix rounds`, giving each task N funded fix rounds past the escalated round. Each funded dispatch prints `funded round <k> of <N> for task <n>`, and every stop note while funding exists carries `budget: <spent> of <N> spent`. A funded `STAGNANT`, error, or non-`DONE` status still stops; unrecoverable spend on resume counts as exhausted. (#74)
14
+
3
15
  ## v7.25.1 - 2026-10-22
4
16
 
5
17
  ### Changed
package/README.md CHANGED
@@ -122,7 +122,7 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
122
122
  ## Requirements
123
123
 
124
124
  - [pi-coding-agent](https://github.com/earendil-works/pi) ≥ 0.85.1 - tested minimum for sequential `phase_tracker` tool execution, which persists a tracker update before a later tool call in the same model message.
125
- - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.4.3 - required peer package, including binary-safe worktree patches. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
125
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.4.4 - required peer package, including binary-safe worktree patches. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
126
126
 
127
127
  Both packages must be listed in your `.pi/settings.json#packages` array (pi adds them automatically when you `pi install`). pi-gauntlet and pi-cohort are versioned independently but release together whenever dispatch semantics change - pin compatible versions of both. At each pi-gauntlet release, set the pi-cohort peer minimum and README floor together to the newest released pi-cohort version; `/skill:release` checks the registry before proceeding.
128
128
 
@@ -6,6 +6,8 @@ import { dirname, join } from "node:path";
6
6
  import {
7
7
  CONTEXT_DRAFT_MARKER,
8
8
  checkSubstep,
9
+ dispatchAgents,
10
+ dispatchLabel,
9
11
  closureGateBlocks,
10
12
  closureModelGuardApplies,
11
13
  flowGuardApplies,
@@ -80,6 +82,57 @@ test("checkSubstep: non-in_progress statuses -> error naming actual status", ()
80
82
  test("phaseLabel: with and without substep", () => {
81
83
  assert.equal(phaseLabel("brainstorm", "gather"), "brainstorm(gather)");
82
84
  assert.equal(phaseLabel("brainstorm", undefined), "brainstorm");
85
+ assert.equal(phaseLabel("brainstorm", undefined, 2), "brainstorm");
86
+ for (const count of [undefined, 0, 1, 2]) {
87
+ assert.equal(phaseLabel("verify", "conformance", count), count === 2 ? "verify(conformance #2)" : "verify(conformance)");
88
+ }
89
+ });
90
+
91
+ test("dispatchAgents: walks dispatch shapes in order and ignores non-string agents", () => {
92
+ const cases: [string, unknown, string[]][] = [
93
+ ["single", { agent: "scout" }, ["scout"]],
94
+ ["tasks", { tasks: [{ agent: "scout" }, { agent: "context-builder" }] }, ["scout", "context-builder"]],
95
+ ["recursive chain", { chain: [{ chain: [{ agent: "plan-writer" }] }] }, ["plan-writer"]],
96
+ ["root and nested", { agent: "root", tasks: [{ agent: "task" }], chain: [{ agent: "step", parallel: [{ agent: "implementer" }, { agent: "spec-reviewer" }] }] }, ["root", "task", "step", "implementer", "spec-reviewer"]],
97
+ ["bare top-level array", [{ agent: "scout" }], []],
98
+ ["nested array ignored", { tasks: [[{ agent: "scout" }]] }, []],
99
+ ["object nodes", { tasks: { agent: "context-builder" } }, ["context-builder"]],
100
+ ["malformed", { agent: 42, tasks: [null, "scout", { agent: false }, { agent: "unknown" }] }, ["unknown"]],
101
+ ["null", null, []],
102
+ ];
103
+ for (const [name, input, expected] of cases) assert.deepEqual(dispatchAgents(input), expected, name);
104
+ });
105
+
106
+ test("dispatchLabel: maps the vocabulary and combined implementation dispatch", () => {
107
+ const cases: [string, unknown, string][] = [
108
+ ["subagent", { agent: "scout" }, "gather"],
109
+ ["subagent", { agent: "context-builder" }, "gather"],
110
+ ["subagent", { agent: "spec-council-design" }, "council"],
111
+ ["subagent", { agent: "spec-council-security" }, "council"],
112
+ ["subagent", { agent: "spec-reviewer" }, "spec-review"],
113
+ ["subagent", { agent: "plan-writer" }, "plan-write"],
114
+ ["plan_check", {}, "plan-check"],
115
+ ["subagent", { agent: "implementer" }, "implement"],
116
+ ["subagent", { agent: "code-reviewer" }, "code-review"],
117
+ ["subagent", { agent: "conformance-reviewer" }, "conformance"],
118
+ ["subagent", { chain: [{ parallel: [{ agent: "implementer" }] }, { parallel: [{ agent: "spec-reviewer" }] }] }, "implement+sr"],
119
+ ["subagent", { tasks: [{ agent: "worker" }, { agent: "scout" }] }, "gather"],
120
+ ["subagent", { tasks: [{ agent: "scout" }, { agent: "code-reviewer" }] }, "gather"],
121
+ ["subagent", { tasks: [{ agent: "code-reviewer" }, { agent: "scout" }] }, "code-review"],
122
+ ["subagent", { tasks: [{ agent: "scout" }, { agent: "spec-reviewer" }, { agent: "implementer" }] }, "implement+sr"],
123
+ ];
124
+ for (const [tool, input, expected] of cases) assert.equal(dispatchLabel(tool, input), expected);
125
+ });
126
+
127
+ test("dispatchLabel: ignores management, unmapped agents, other tools and malformed input", () => {
128
+ const inputs: unknown[] = [
129
+ { agent: "worker" }, { agent: "spec-summarizer" }, { agent: "ad-hoc" },
130
+ { agent: "spec-council" }, { agent: "implementer-extra" },
131
+ { action: "list", agent: "scout" }, { action: undefined, agent: "scout" },
132
+ undefined, null, 1, "scout", {}, { agent: 1 }, { tasks: [null, false] },
133
+ ];
134
+ for (const input of inputs) assert.equal(dispatchLabel("subagent", input), undefined);
135
+ assert.equal(dispatchLabel("tool_result", { agent: "scout" }), undefined);
83
136
  });
84
137
 
85
138
  test("parseGitCommand: branch subcommands with and without global flags", () => {
@@ -31,8 +31,54 @@ export function checkSubstep(phaseStatus: string): SubstepCheck {
31
31
  return { ok: false, error: `substep requires an in_progress phase (status is ${phaseStatus})` };
32
32
  }
33
33
 
34
- export function phaseLabel(name: string, substep?: string): string {
35
- return substep ? `${name}(${substep})` : name;
34
+ export const DISPATCH_LABELS = {
35
+ scout: "gather",
36
+ "context-builder": "gather",
37
+ "spec-council-": "council",
38
+ "spec-reviewer": "spec-review",
39
+ "plan-writer": "plan-write",
40
+ plan_check: "plan-check",
41
+ implementer: "implement",
42
+ "code-reviewer": "code-review",
43
+ "conformance-reviewer": "conformance",
44
+ "implement+sr": "implement+sr",
45
+ } as const;
46
+
47
+ export function dispatchAgents(input: unknown): string[] {
48
+ const agents: string[] = [];
49
+ const collect = (node: unknown): void => {
50
+ if (!node || typeof node !== "object") return;
51
+ const object = node as Record<string, unknown>;
52
+ if (typeof object.agent === "string") agents.push(object.agent);
53
+ for (const key of ["tasks", "chain", "parallel"] as const) {
54
+ const value = object[key];
55
+ if (Array.isArray(value)) for (const item of value) collect(item);
56
+ else if (value && typeof value === "object") collect(value);
57
+ }
58
+ };
59
+ collect(input);
60
+ return agents;
61
+ }
62
+
63
+ export function dispatchLabel(toolName: string, input: unknown): string | undefined {
64
+ if (toolName === "plan_check") return DISPATCH_LABELS.plan_check;
65
+ if (toolName !== "subagent") return undefined;
66
+ if (input && typeof input === "object" && "action" in input) return undefined;
67
+ const agents = dispatchAgents(input);
68
+ if (agents.includes("implementer") && agents.includes("spec-reviewer")) return DISPATCH_LABELS["implement+sr"];
69
+ for (const agent of agents) {
70
+ for (const name of ["scout", "context-builder", "spec-reviewer", "plan-writer", "implementer", "code-reviewer", "conformance-reviewer"] as const) {
71
+ if (agent === name) return DISPATCH_LABELS[name];
72
+ }
73
+ if (agent.startsWith("spec-council-")) return DISPATCH_LABELS["spec-council-"];
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ export function phaseLabel(name: string, substep?: string, count?: number): string {
79
+ if (!substep) return name;
80
+ const ordinal = count !== undefined && count >= 2 ? ` #${count}` : "";
81
+ return `${name}(${substep}${ordinal})`;
36
82
  }
37
83
 
38
84
  // Same statement-start anchor as the branch guards in phase-tracker.ts. Exported
@@ -46,17 +46,17 @@ type Status = "pending" | "in_progress" | "complete" | "skipped";
46
46
  const phases = (overrides: Partial<Record<Phase, Status>> = {}) =>
47
47
  Object.fromEntries(PHASES.map((phase) => [phase, { status: overrides[phase] ?? "pending" }])) as Record<
48
48
  Phase,
49
- { status: Status }
49
+ { status: Status; substep?: string }
50
50
  >;
51
51
 
52
- const phaseResult = (action: string, state: Record<Phase, { status: Status }>) => ({
52
+ const phaseResult = (action: string, state: Record<Phase, { status: Status; substep?: string }>) => ({
53
53
  type: "message",
54
54
  message: { role: "toolResult", toolName: "phase_tracker", details: { action, phases: state } },
55
55
  });
56
56
 
57
57
  const assistant = (stopReason = "stop") => ({ type: "message", message: { role: "assistant", stopReason } });
58
58
 
59
- const enteredBranch = (state: Record<Phase, { status: Status }>, extra: unknown[] = []) => [
59
+ const enteredBranch = (state: Record<Phase, { status: Status; substep?: string }>, extra: unknown[] = []) => [
60
60
  phaseResult("start", phases({ brainstorm: "in_progress" })),
61
61
  phaseResult("complete", state),
62
62
  ...extra,
@@ -71,15 +71,19 @@ const resumedBranch = (rest: Partial<Record<Phase, Status>>) => [
71
71
 
72
72
  type TestModel = { provider: string; id: string };
73
73
 
74
- function harness(options: { cwd?: string; branch?: unknown[]; idle?: boolean; beforeSettled?: (setIdle: (idle: boolean) => void) => void; sendThrows?: boolean; model?: TestModel; thinkingLevel?: string; registry?: Record<string, TestModel>; setModelResult?: boolean | Error } = {}) {
74
+ function harness(options: { hasUI?: boolean; cwd?: string; branch?: unknown[]; idle?: boolean; beforeSettled?: (setIdle: (idle: boolean) => void) => void; sendThrows?: boolean; model?: TestModel; thinkingLevel?: string; registry?: Record<string, TestModel>; setModelResult?: boolean | Error } = {}) {
75
75
  const handlers = new Map<string, ((event: unknown, ctx: unknown) => unknown)[]>();
76
76
  const tools: { name: string; executionMode?: string; parameters?: any; execute: (...args: any[]) => unknown }[] = [];
77
77
  const sent: { message: any; options: any }[] = [];
78
+ const widgets: (string | undefined)[] = [];
78
79
  let idle = options.idle ?? true;
79
80
  let branch = options.branch ?? [];
80
81
  const ctx = {
81
82
  cwd: options.cwd ?? tempCwd(),
82
- hasUI: false,
83
+ hasUI: options.hasUI ?? false,
84
+ ui: { setWidget: (name: string, factory: any) => {
85
+ if (name === "phase_tracker") widgets.push(factory?.({}, { fg: (_color: string, text: string) => text }).text);
86
+ } },
83
87
  isIdle: () => idle,
84
88
  sessionManager: { getBranch: () => branch },
85
89
  model: options.model,
@@ -122,7 +126,7 @@ function harness(options: { cwd?: string; branch?: unknown[]; idle?: boolean; be
122
126
  for (const handler of handlers.get(name) ?? []) results.push(await handler(event, ctx));
123
127
  return results;
124
128
  };
125
- return { pi, emit, emitEvent, sent, tools, ctx, setIdle: (next: boolean) => (idle = next), setBranch: (next: unknown[]) => (branch = next) };
129
+ return { pi, emit, emitEvent, sent, tools, ctx, widgets, setIdle: (next: boolean) => (idle = next), setBranch: (next: unknown[]) => (branch = next) };
126
130
  }
127
131
 
128
132
  const settle = async (h: ReturnType<typeof harness>) => {
@@ -2064,3 +2068,335 @@ test("open-task nudge: silent on an isError result, a degraded settings read, an
2064
2068
  process.env.PI_SUBAGENT_DEPTH = "0";
2065
2069
  }
2066
2070
  });
2071
+
2072
+ for (const action of ["complete", "skip"] as const) {
2073
+ for (const isError of [false, true]) {
2074
+ test(`live dispatch clears after ${action} of a different phase, error=${isError}`, async () => {
2075
+ const h = harness({ hasUI: true });
2076
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
2077
+ await startPhase(h, "verify");
2078
+ await h.emitEvent("tool_call", subagentCall("review", { agent: "code-reviewer" }));
2079
+ const result = await tool.execute("transition", { action, phase: "ship", reason: "not needed" }, undefined, undefined, h.ctx) as any;
2080
+ assert.equal(result.details.error, undefined);
2081
+ assert.match(h.widgets.at(-1)!, /verify\(code-review\)/);
2082
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "review", isError, details: {}, content: [] });
2083
+ assert.doesNotMatch(h.widgets.at(-1)!, /verify\(/);
2084
+ await h.emitEvent("tool_call", subagentCall("next", { agent: "code-reviewer" }));
2085
+ assert.ok(h.widgets.at(-1)!.includes(`verify(code-review${isError ? "" : " #2"})`));
2086
+ });
2087
+ }
2088
+ }
2089
+
2090
+ for (const isError of [false, true]) {
2091
+ test(`live dispatch survives a deferred start of a different phase, error=${isError}`, async () => {
2092
+ const h = harness({
2093
+ hasUI: true,
2094
+ cwd: tempCwd({ piGauntlet: { phaseModels: { ship: "p/m" } } }),
2095
+ model: M("old"),
2096
+ registry: { "p/m": M("m") },
2097
+ });
2098
+ let entered!: () => void;
2099
+ let release!: () => void;
2100
+ const modelEntered = new Promise<void>(resolve => { entered = resolve; });
2101
+ const modelReleased = new Promise<void>(resolve => { release = resolve; });
2102
+ h.pi.setModel = async () => {
2103
+ entered();
2104
+ await modelReleased;
2105
+ return true;
2106
+ };
2107
+ const shipStart = startPhase(h, "ship");
2108
+ await modelEntered;
2109
+ await startPhase(h, "verify");
2110
+ await h.emitEvent("tool_call", subagentCall("review", { agent: "code-reviewer" }));
2111
+ release();
2112
+ assert.equal((await shipStart).status, "in_progress");
2113
+ assert.match(h.widgets.at(-1)!, /verify\(code-review\)/);
2114
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "review", isError, details: {}, content: [] });
2115
+ assert.doesNotMatch(h.widgets.at(-1)!, /verify\(/);
2116
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
2117
+ const status = await tool.execute("status", { action: "status" }, undefined, undefined, h.ctx) as any;
2118
+ assert.equal(status.details.phases.ship.status, "in_progress");
2119
+ await h.emitEvent("tool_call", subagentCall("next", { agent: "code-reviewer" }));
2120
+ assert.ok(h.widgets.at(-1)!.includes(`verify(code-review${isError ? "" : " #2"})`));
2121
+ });
2122
+ }
2123
+
2124
+ test("live dispatch labels repaint and keep ordinals out of status snapshots", async () => {
2125
+ const h = harness({ hasUI: true });
2126
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
2127
+ const start = await tool.execute("start", { action: "start", phase: "verify" }, undefined, undefined, h.ctx) as any;
2128
+ await h.emitEvent("tool_call", subagentCall("a", { agent: "conformance-reviewer" }));
2129
+ assert.match(h.widgets.at(-1)!, /verify\(conformance\)/);
2130
+ assert.equal(start.details.phases.verify.substep, undefined);
2131
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "a", details: {}, content: [] });
2132
+ assert.doesNotMatch(h.widgets.at(-1)!, /verify\(/);
2133
+ await h.emitEvent("tool_call", subagentCall("b", { agent: "conformance-reviewer" }));
2134
+ assert.match(h.widgets.at(-1)!, /verify\(conformance #2\)/);
2135
+ const status = await tool.execute("status", { action: "status" }, undefined, undefined, h.ctx) as any;
2136
+ assert.match(status.content[0].text, /verify\(conformance\)/);
2137
+ assert.doesNotMatch(status.content[0].text, /#2/);
2138
+ const theme = { fg: (_color: string, text: string) => text };
2139
+ const renderer = (tool as any).renderResult;
2140
+ assert.doesNotMatch(renderer(status, {}, theme).text, /#2/);
2141
+ const explicit = await tool.execute("explicit", { action: "substep", phase: "verify", substep: "conformance" }, undefined, undefined, h.ctx) as any;
2142
+ assert.equal(renderer(explicit, {}, theme).text, "→ verify(conformance)");
2143
+ });
2144
+
2145
+ for (const row of [
2146
+ { phase: "implement", input: { chain: [{ parallel: [{ agent: "implementer" }] }, { parallel: [{ agent: "spec-reviewer" }] }] }, label: "implement+sr" },
2147
+ { phase: "implement", input: { agent: "code-reviewer" }, label: "code-review" },
2148
+ { phase: "implement", input: { agent: "implementer" }, label: "implement" },
2149
+ { phase: "implement", input: { agent: "spec-reviewer" }, label: "spec-review" },
2150
+ { phase: "plan", toolName: "plan_check", input: {}, label: "plan-check" },
2151
+ { phase: "verify", toolName: "plan_check", input: {}, label: "plan-check" },
2152
+ ]) {
2153
+ test(`live vocabulary: ${row.phase}(${row.label})`, async () => {
2154
+ const h = harness({ hasUI: true });
2155
+ await startPhase(h, row.phase);
2156
+ await h.emitEvent("tool_call", { toolName: row.toolName ?? "subagent", toolCallId: "a", input: row.input });
2157
+ assert.ok(h.widgets.at(-1)!.includes(`${row.phase}(${row.label})`));
2158
+ const paints = h.widgets.length;
2159
+ for (const input of [{ agent: "worker" }, { action: "status", agent: "implementer" }]) {
2160
+ await h.emitEvent("tool_call", subagentCall("ignored", input));
2161
+ }
2162
+ await h.emitEvent("tool_execution_end", { toolName: "plan_tracker", toolCallId: "reopen", result: { details: { tasks: [{ status: "in_progress" }] } } });
2163
+ assert.ok(h.widgets.at(-1)!.includes(`${row.phase}(${row.label})`));
2164
+ assert.equal(h.widgets.length, paints + 1);
2165
+ await h.emitEvent("tool_execution_end", { toolName: row.toolName ?? "subagent", toolCallId: "a", result: { details: {} } });
2166
+ assert.ok(!h.widgets.at(-1)!.includes(`${row.phase}(`));
2167
+ });
2168
+ }
2169
+
2170
+ for (const agents of [
2171
+ ["code-reviewer", "implementer", "code-reviewer", "conformance-reviewer"],
2172
+ ["conformance-reviewer", "implementer", "conformance-reviewer", "code-reviewer", "code-reviewer", "code-reviewer"],
2173
+ ]) {
2174
+ test(`live repair sequence: ${agents.join(" -> ")}`, async () => {
2175
+ const h = harness({ hasUI: true });
2176
+ await startPhase(h, "verify");
2177
+ const counts: Record<string, number> = {};
2178
+ for (const [i, agent] of agents.entries()) {
2179
+ const label = ({ "code-reviewer": "code-review", implementer: "implement", "conformance-reviewer": "conformance" })[agent]!;
2180
+ const n = counts[label] = (counts[label] ?? 0) + 1;
2181
+ await h.emitEvent("tool_call", subagentCall(String(i), { tasks: [{ agent }] }));
2182
+ assert.ok(h.widgets.at(-1)!.includes(`verify(${label}${n > 1 ? ` #${n}` : ""})`));
2183
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: String(i), details: {}, content: [] });
2184
+ }
2185
+ });
2186
+ }
2187
+
2188
+ for (const row of [
2189
+ { name: "normal", isError: false, details: {}, refund: false },
2190
+ { name: "executed error", isError: true, details: { results: [] }, refund: false },
2191
+ { name: "empty error", isError: true, details: {}, refund: true },
2192
+ ]) {
2193
+ for (const endOnly of [false, true]) {
2194
+ test(`live terminal: ${row.name}, execution-end-only=${endOnly}`, async () => {
2195
+ const h = harness({ hasUI: true });
2196
+ await startPhase(h, "verify");
2197
+ await h.emitEvent("tool_call", subagentCall("a", { agent: "code-reviewer" }));
2198
+ const paints = h.widgets.length;
2199
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "other", details: {}, content: [] });
2200
+ assert.equal(h.widgets.length, paints);
2201
+ const terminal = { toolName: "subagent", toolCallId: "a", isError: row.isError, details: row.details, content: [] };
2202
+ if (!endOnly) await h.emitEvent("tool_result", terminal);
2203
+ await h.emitEvent("tool_execution_end", { ...terminal, result: { details: row.details } });
2204
+ assert.equal(h.widgets.length, paints + 1);
2205
+ assert.doesNotMatch(h.widgets.at(-1)!, /verify\(/);
2206
+ await h.emitEvent("tool_execution_end", { ...terminal, result: { details: row.details } });
2207
+ assert.equal(h.widgets.length, paints + 1);
2208
+ await h.emitEvent("tool_call", subagentCall("b", { agent: "code-reviewer" }));
2209
+ assert.ok(h.widgets.at(-1)!.includes(`verify(code-review${row.refund ? "" : " #2"})`));
2210
+ });
2211
+ }
2212
+ }
2213
+
2214
+ test("live overlap forgets older calls and explicit overwrite survives cleanup", async () => {
2215
+ const h = harness({ hasUI: true });
2216
+ await startPhase(h, "verify");
2217
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
2218
+ const explicit = (substep: string) => tool.execute("explicit", { action: "substep", phase: "verify", substep }, undefined, undefined, h.ctx);
2219
+ await explicit("before");
2220
+ for (const id of ["a", "b"]) await h.emitEvent("tool_call", subagentCall(id, { agent: "code-reviewer", async: true }));
2221
+ assert.match(h.widgets.at(-1)!, /verify\(code-review #2\)/);
2222
+ const paints = h.widgets.length;
2223
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "a", details: {}, content: [] });
2224
+ assert.equal(h.widgets.length, paints);
2225
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "b", details: {}, content: [] });
2226
+ assert.doesNotMatch(h.widgets.at(-1)!, /verify\(/);
2227
+ await h.emitEvent("tool_call", subagentCall("c", { agent: "code-reviewer" }));
2228
+ const snapshot = await explicit("custom") as any;
2229
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "c", isError: true, details: {}, content: [] });
2230
+ assert.match(h.widgets.at(-1)!, /verify\(custom\)/);
2231
+ assert.equal(snapshot.details.phases.verify.substep, "custom");
2232
+ await h.emitEvent("tool_call", subagentCall("d", { agent: "code-reviewer" }));
2233
+ assert.match(h.widgets.at(-1)!, /verify\(code-review #3\)/);
2234
+ });
2235
+
2236
+ for (const action of ["start", "complete", "skip", "reset"]) {
2237
+ test(`live transition ${action} drops terminal ownership and restart resets counts`, async () => {
2238
+ const h = harness({ hasUI: true });
2239
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
2240
+ await startPhase(h, "verify");
2241
+ await h.emitEvent("tool_call", subagentCall("old", { agent: "code-reviewer" }));
2242
+ const rejected = await tool.execute("bad", { action: "start", phase: "plan" }, undefined, undefined, h.ctx) as any;
2243
+ assert.ok(rejected.details.error);
2244
+ assert.match(h.widgets.at(-1)!, /verify\(code-review\)/);
2245
+ await tool.execute("transition", { action, phase: "verify", reason: "test" }, undefined, undefined, h.ctx);
2246
+ if (action === "reset") assert.equal(h.widgets.at(-1), undefined);
2247
+ if (action !== "start") await tool.execute("restart", { action: "start", phase: "verify", force: true }, undefined, undefined, h.ctx);
2248
+ await tool.execute("label", { action: "substep", phase: "verify", substep: "custom" }, undefined, undefined, h.ctx);
2249
+ const paints = h.widgets.length;
2250
+ await h.emitEvent("tool_execution_end", { toolName: "subagent", toolCallId: "old", result: { details: {} } });
2251
+ assert.equal(h.widgets.length, paints);
2252
+ assert.match(h.widgets.at(-1)!, /verify\(custom\)/);
2253
+ await h.emitEvent("tool_call", subagentCall("new", { agent: "code-reviewer" }));
2254
+ assert.match(h.widgets.at(-1)!, /verify\(code-review\)/);
2255
+ });
2256
+ }
2257
+
2258
+ test("live plan backstop clears ownership only on actual implement completion", async () => {
2259
+ const h = harness({ hasUI: true });
2260
+ await startPhase(h, "implement");
2261
+ await h.emitEvent("tool_call", subagentCall("old", { agent: "code-reviewer" }));
2262
+ for (const tasks of [undefined, [{ status: "in_progress" }]]) {
2263
+ await h.emitEvent("tool_execution_end", { toolName: "plan_tracker", result: { details: { tasks } } });
2264
+ assert.match(h.widgets.at(-1)!, /implement\(code-review\)/);
2265
+ }
2266
+ await h.emitEvent("tool_execution_end", { toolName: "plan_tracker", result: { details: { tasks: [{ status: "complete" }] } } });
2267
+ await startPhase(h, "verify");
2268
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
2269
+ await tool.execute("label", { action: "substep", phase: "verify", substep: "custom" }, undefined, undefined, h.ctx);
2270
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "old", details: {}, content: [] });
2271
+ assert.match(h.widgets.at(-1)!, /verify\(custom\)/);
2272
+ await h.emitEvent("tool_call", subagentCall("new", { agent: "code-reviewer" }));
2273
+ assert.match(h.widgets.at(-1)!, /verify\(code-review\)/);
2274
+ });
2275
+
2276
+ test("live observation is dormant without a phase and in a subagent child", async () => {
2277
+ const dormant = harness({ hasUI: true });
2278
+ await dormant.emitEvent("tool_call", { toolName: "plan_check", toolCallId: "a", input: {} });
2279
+ assert.equal(dormant.widgets.length, 0);
2280
+ const prior = process.env.PI_SUBAGENT_DEPTH;
2281
+ let child: ReturnType<typeof harness>;
2282
+ try {
2283
+ process.env.PI_SUBAGENT_DEPTH = "1";
2284
+ child = harness({ hasUI: true });
2285
+ } finally {
2286
+ if (prior === undefined) delete process.env.PI_SUBAGENT_DEPTH;
2287
+ else process.env.PI_SUBAGENT_DEPTH = prior;
2288
+ }
2289
+ await startPhase(child, "verify");
2290
+ const paints = child.widgets.length;
2291
+ await child.emitEvent("tool_call", subagentCall("a", { agent: "conformance-reviewer" }));
2292
+ await child.emitEvent("tool_call", { toolName: "plan_check", toolCallId: "b", input: {} });
2293
+ assert.equal(child.widgets.length, paints);
2294
+ });
2295
+
2296
+ for (const cap of [false, true]) {
2297
+ test(`live blocked ${cap ? "cap" : "lone"} call creates no label or ordinal`, async () => {
2298
+ const cwd = tempCwd({ piGauntlet: { closureReview: { enforce: true, maxFixRounds: cap ? 0 : 3 } } });
2299
+ const h = harness({ hasUI: true, cwd, branch: verifyBranch([subagentResult(["conformance-reviewer"])]) });
2300
+ await h.emit("session_start");
2301
+ const paints = h.widgets.length;
2302
+ assert.equal((await firstCallResult(h, "blocked", cap ? implementerWave() : loneImplementer()))?.block, true);
2303
+ await h.emitEvent("tool_execution_end", { toolName: "subagent", toolCallId: "blocked", isError: true, result: { details: {} } });
2304
+ assert.equal(h.widgets.length, paints);
2305
+ writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ piGauntlet: { closureReview: { enforce: false } } }));
2306
+ await firstCallResult(h, "allowed", implementerWave());
2307
+ assert.match(h.widgets.at(-1)!, /verify\(implement\)/);
2308
+ });
2309
+ }
2310
+
2311
+ const replaySnapshot = (phase: Phase, substep?: string, status: Status = "in_progress") => {
2312
+ const state = phases({ [phase]: status });
2313
+ if (substep !== undefined) state[phase].substep = substep;
2314
+ return state;
2315
+ };
2316
+
2317
+ const explicitReplay = (phase: Phase, label: string) => [
2318
+ phaseResult("start", replaySnapshot(phase)),
2319
+ phaseResult("substep", replaySnapshot(phase, label)),
2320
+ ];
2321
+
2322
+ for (const row of [
2323
+ { name: "derived status only", branch: [phaseResult("start", replaySnapshot("verify")), phaseResult("status", replaySnapshot("verify", "conformance"))], phase: "verify", expected: undefined },
2324
+ { name: "explicit before derived status", branch: [...explicitReplay("verify", "gather"), phaseResult("status", replaySnapshot("verify", "council"))], phase: "verify", expected: "gather" },
2325
+ ...([{ phase: "brainstorm", label: "gather", agent: "scout" }, { phase: "plan", label: "plan-write", agent: "plan-writer" }] as const).map(r => ({
2326
+ name: `unfinished ${r.agent}`, phase: r.phase, expected: r.label,
2327
+ branch: [...explicitReplay(r.phase, r.label), assistant(), { type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "unfinished", name: "subagent", arguments: { agent: r.agent } }] } }],
2328
+ })),
2329
+ { name: "null or omitted clear", phase: "verify", expected: undefined, branch: [...explicitReplay("verify", "gather"), phaseResult("substep", replaySnapshot("verify")), phaseResult("status", replaySnapshot("verify", "council"))] },
2330
+ ...["start", "complete", "skip"].map(action => ({ name: `same phase ${action}`, phase: "verify", expected: undefined, branch: [...explicitReplay("verify", "gather"), phaseResult(action, replaySnapshot("verify", undefined, action === "complete" ? "complete" : action === "skip" ? "skipped" : "in_progress"))] })),
2331
+ ...["complete", "skip"].map(action => {
2332
+ const state = replaySnapshot("verify", "gather");
2333
+ state.ship.status = action === "complete" ? "complete" : "skipped";
2334
+ return { name: `other phase ${action}`, phase: "verify", expected: "gather", branch: [...explicitReplay("verify", "gather"), phaseResult(action, state)] };
2335
+ }),
2336
+ { name: "reset then start", phase: "verify", expected: undefined, branch: [...explicitReplay("verify", "gather"), phaseResult("reset", phases()), phaseResult("start", replaySnapshot("verify"))] },
2337
+ ...["substep", "start", "complete", "skip", "reset"].map(action => {
2338
+ const rejected = phaseResult(action, phases());
2339
+ Object.assign(rejected.message.details, { error: "rejected" });
2340
+ return { name: `rejected ${action}`, phase: "verify", expected: "gather", branch: [...explicitReplay("verify", "gather"), rejected] };
2341
+ }),
2342
+ { name: "task-driven completion", phase: "implement", expected: undefined, expectedStatus: "complete", branch: [...explicitReplay("implement", "custom"), taskSnapshot([{ name: "task", status: "complete" }])] },
2343
+ { name: "custom after clear", phase: "verify", expected: "custom", branch: [...explicitReplay("verify", "gather"), phaseResult("substep", replaySnapshot("verify")), phaseResult("substep", replaySnapshot("verify", "custom"))] },
2344
+ ]) {
2345
+ test(`replay explicit labels: ${row.name}`, async () => {
2346
+ const original = structuredClone(row.branch);
2347
+ const h = harness({ hasUI: true, branch: row.branch });
2348
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
2349
+ await h.emit("session_start");
2350
+ const status = await tool.execute("status", { action: "status" }, undefined, undefined, h.ctx) as any;
2351
+ assert.equal(status.details.phases[row.phase].substep, row.expected);
2352
+ if ("expectedStatus" in row) assert.equal(status.details.phases[row.phase].status, row.expectedStatus);
2353
+ if (row.expected) {
2354
+ assert.ok(h.widgets.at(-1)!.includes(`${row.phase}(${row.expected})`));
2355
+ assert.ok(status.content[0].text.includes(`${row.phase}(${row.expected})`));
2356
+ } else {
2357
+ assert.ok(!h.widgets.at(-1)!.includes(`${row.phase}(`));
2358
+ }
2359
+ assert.deepEqual(row.branch, original);
2360
+ if (status.details.phases[row.phase].status !== "in_progress") await startPhase(h, row.phase);
2361
+ const restarted = await tool.execute("status", { action: "status" }, undefined, undefined, h.ctx) as any;
2362
+ assert.equal(restarted.details.phases[row.phase].substep, row.expected);
2363
+ await h.emitEvent("tool_call", subagentCall("new", { agent: "code-reviewer" }));
2364
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "new", details: {}, content: [] });
2365
+ assert.deepEqual(row.branch, original);
2366
+ });
2367
+ }
2368
+
2369
+ for (const event of ["session_start", "session_switch", "session_fork", "session_tree"]) {
2370
+ for (const kind of ["explicit", "derived", "empty"]) {
2371
+ test(`replay ${event} resets live ownership and ordinals: ${kind}`, async () => {
2372
+ const h = harness({ hasUI: true });
2373
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
2374
+ await startPhase(h, "verify");
2375
+ await h.emitEvent("tool_call", subagentCall("first", { agent: "code-reviewer" }));
2376
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "first", details: {}, content: [] });
2377
+ await h.emitEvent("tool_call", subagentCall("old", { agent: "code-reviewer" }));
2378
+ assert.match(h.widgets.at(-1)!, /verify\(code-review #2\)/);
2379
+ const branch = kind === "empty" ? [assistant()] : kind === "explicit" ? explicitReplay("verify", "custom") : [phaseResult("start", replaySnapshot("verify")), phaseResult("status", replaySnapshot("verify", "code-review"))];
2380
+ const original = structuredClone(branch);
2381
+ h.setBranch(branch);
2382
+ await h.emit(event);
2383
+ if (kind === "empty") assert.equal(h.widgets.at(-1), undefined);
2384
+ else if (kind === "explicit") assert.match(h.widgets.at(-1)!, /verify\(custom\)/);
2385
+ else assert.doesNotMatch(h.widgets.at(-1)!, /verify\(/);
2386
+ const status = await tool.execute("status", { action: "status" }, undefined, undefined, h.ctx) as any;
2387
+ assert.equal(status.details.phases.verify.substep, kind === "explicit" ? "custom" : undefined);
2388
+ assert.deepEqual(branch, original);
2389
+ const paints = h.widgets.length;
2390
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "old", details: {}, content: [] });
2391
+ await h.emitEvent("tool_execution_end", { toolName: "subagent", toolCallId: "old", result: { details: {} } });
2392
+ assert.equal(h.widgets.length, paints);
2393
+ if (kind === "empty") await startPhase(h, "verify");
2394
+ await h.emitEvent("tool_call", subagentCall("next", { agent: "code-reviewer" }));
2395
+ assert.match(h.widgets.at(-1)!, /verify\(code-review\)/);
2396
+ await h.emitEvent("tool_result", { toolName: "subagent", toolCallId: "next", details: {}, content: [] });
2397
+ await h.emitEvent("tool_call", subagentCall("again", { agent: "code-reviewer" }));
2398
+ assert.match(h.widgets.at(-1)!, /verify\(code-review #2\)/);
2399
+ assert.deepEqual(branch, original);
2400
+ });
2401
+ }
2402
+ }
@@ -31,6 +31,7 @@ import { checkoutOf, gitSync } from "./lib/checkout.ts";
31
31
  import { checkPlan, sha256, type FsPort, type PlanCheckFinding } from "./lib/plan-check.ts";
32
32
  import {
33
33
  checkSubstep,
34
+ dispatchLabel,
34
35
  findMarkerFile,
35
36
  flowGuardApplies,
36
37
  implementExemptDirs,
@@ -354,10 +355,11 @@ function hasActivity(phases: PhaseMap): boolean {
354
355
  return PHASES.some((p) => phases[p].status !== "pending");
355
356
  }
356
357
 
357
- function formatWidget(phases: PhaseMap, theme: Theme): string {
358
+ function formatWidget(phases: PhaseMap, dispatchCounts: Record<Phase, Record<string, number>>, theme: Theme): string {
358
359
  const parts = PHASES.map((p) => {
359
360
  const icon = phaseIcon(phases[p].status, theme);
360
- const labeled = phaseLabel(p, phases[p].status === "in_progress" ? phases[p].substep : undefined);
361
+ const substep = phases[p].status === "in_progress" ? phases[p].substep : undefined;
362
+ const labeled = phaseLabel(p, substep, dispatchCounts[p][substep ?? ""] ?? 0);
361
363
  const name = phases[p].status === "skipped" ? theme.fg("dim", labeled) : labeled;
362
364
  return `${icon} ${name}`;
363
365
  });
@@ -422,6 +424,31 @@ async function applyPhaseModel(
422
424
  export default function (pi: ExtensionAPI) {
423
425
  let phases: PhaseMap = emptyPhases();
424
426
  let conformanceDispatched = false;
427
+ let dispatchCounts = Object.fromEntries(PHASES.map((p) => [p, {}])) as Record<Phase, Record<string, number>>;
428
+ let liveDispatch: { toolCallId: string; phase: Phase; label: string } | undefined;
429
+ const observeDispatch = (toolName: string, input: unknown, toolCallId: string): boolean => {
430
+ if (isSubagentChild) return false;
431
+ const label = dispatchLabel(toolName, input);
432
+ const phase = PHASES.find((p) => phases[p].status === "in_progress");
433
+ if (label === undefined || phase === undefined) return false;
434
+ dispatchCounts[phase][label] = (dispatchCounts[phase][label] ?? 0) + 1;
435
+ phases = { ...phases, [phase]: { ...phases[phase], substep: label } };
436
+ liveDispatch = { toolCallId, phase, label };
437
+ return true;
438
+ };
439
+ const isNonDispatchResult = (isError: boolean | undefined, details: unknown): boolean =>
440
+ isError === true && details !== null && typeof details === "object" && Object.keys(details).length === 0;
441
+ const finishDispatch = (toolName: string, toolCallId: string, isError: boolean | undefined, details: unknown): boolean => {
442
+ if ((toolName !== "subagent" && toolName !== "plan_check") || liveDispatch?.toolCallId !== toolCallId) return false;
443
+ const { phase, label } = liveDispatch;
444
+ if (isNonDispatchResult(isError, details)) dispatchCounts[phase][label] -= 1;
445
+ if (phases[phase].substep === label) {
446
+ const { substep: _substep, ...entry } = phases[phase];
447
+ phases = { ...phases, [phase]: entry };
448
+ }
449
+ liveDispatch = undefined;
450
+ return true;
451
+ };
425
452
  let fixRounds = 0;
426
453
  let fixRoundCredits = 0;
427
454
  let gauntletEntered = false;
@@ -493,6 +520,7 @@ export default function (pi: ExtensionAPI) {
493
520
  tasks.every((t) => t.status === "complete" || t.status === "skipped")
494
521
  ) {
495
522
  phases = { ...phases, implement: { status: "complete" } };
523
+ liveDispatch = undefined;
496
524
  firedGuards.clear();
497
525
  }
498
526
  };
@@ -507,6 +535,9 @@ export default function (pi: ExtensionAPI) {
507
535
 
508
536
  const reconstructState = (ctx: ExtensionContext) => {
509
537
  phases = emptyPhases();
538
+ dispatchCounts = Object.fromEntries(PHASES.map((p) => [p, {}])) as Record<Phase, Record<string, number>>;
539
+ liveDispatch = undefined;
540
+ let explicitSubstep: Partial<Record<Phase, string>> = {};
510
541
  conformanceDispatched = false;
511
542
  fixRounds = 0;
512
543
  fixRoundCredits = 0;
@@ -533,6 +564,23 @@ export default function (pi: ExtensionAPI) {
533
564
  if (msg.toolName === "phase_tracker") {
534
565
  const details = msg.details as PhaseTrackerDetails | undefined;
535
566
  if (details && !details.error) {
567
+ if (details.action === "substep") {
568
+ const phase = PHASES.find((p) => details.phases[p].status === "in_progress");
569
+ if (phase !== undefined) {
570
+ const label = details.phases[phase].substep;
571
+ if (label !== undefined) explicitSubstep[phase] = label;
572
+ else delete explicitSubstep[phase];
573
+ }
574
+ } else if (details.action === "start" || details.action === "complete" || details.action === "skip") {
575
+ const changed = PHASES.filter((p) => phases[p].status !== details.phases[p].status);
576
+ if (changed.length === 0 && details.action === "start") {
577
+ const phase = PHASES.find((p) => details.phases[p].status === "in_progress");
578
+ if (phase !== undefined) delete explicitSubstep[phase];
579
+ }
580
+ for (const phase of changed) delete explicitSubstep[phase];
581
+ } else if (details.action === "reset") {
582
+ explicitSubstep = {};
583
+ }
536
584
  phases = details.phases;
537
585
  if (details.action === "grant_fix_rounds" && typeof details.rounds === "number") fixRoundCredits = details.rounds;
538
586
  gauntletEntered = nextGauntletEntered(gauntletEntered, details.action, details.phases.brainstorm.status);
@@ -561,9 +609,18 @@ export default function (pi: ExtensionAPI) {
561
609
  observeCadence(msg.details);
562
610
  } else if (msg.toolName === "plan_tracker") {
563
611
  const details = msg.details as { tasks?: { status: string }[]; error?: string } | undefined;
564
- if (details && !details.error) applyPlanActivity(details.tasks);
612
+ if (details && !details.error) {
613
+ const wasImplementing = phases.implement.status === "in_progress";
614
+ applyPlanActivity(details.tasks);
615
+ if (wasImplementing && phases.implement.status === "complete") delete explicitSubstep.implement;
616
+ }
565
617
  }
566
618
  }
619
+ phases = Object.fromEntries(PHASES.map((phase) => {
620
+ const { substep: _substep, ...entry } = phases[phase];
621
+ const label = explicitSubstep[phase];
622
+ return [phase, label === undefined ? entry : { ...entry, substep: label }];
623
+ })) as PhaseMap;
567
624
  };
568
625
 
569
626
  const updateWidget = (ctx: ExtensionContext) => {
@@ -572,7 +629,7 @@ export default function (pi: ExtensionAPI) {
572
629
  ctx.ui.setWidget("phase_tracker", undefined);
573
630
  } else {
574
631
  ctx.ui.setWidget("phase_tracker", (_tui, theme) => {
575
- return new Text(formatWidget(phases, theme), 0, 0);
632
+ return new Text(formatWidget(phases, dispatchCounts, theme), 0, 0);
576
633
  });
577
634
  }
578
635
  };
@@ -657,6 +714,8 @@ export default function (pi: ExtensionAPI) {
657
714
  }
658
715
  }
659
716
 
717
+ if (observeDispatch(event.toolName, event.input, event.toolCallId)) updateWidget(ctx);
718
+
660
719
  // Flow guards apply only to write/edit/bash while a guard phase is active.
661
720
  // Cheap in-memory gate BEFORE any settings load: phase state is in-memory, so an
662
721
  // event no guard inspects (any read-only tool, or any tool in a dormant session)
@@ -782,6 +841,7 @@ export default function (pi: ExtensionAPI) {
782
841
  });
783
842
 
784
843
  pi.on("tool_result", async (event, ctx) => {
844
+ if (finishDispatch(event.toolName, event.toolCallId, event.isError, event.details)) updateWidget(ctx);
785
845
  if (event.toolName === "subagent") {
786
846
  observeFixWave(event.details, event.isError, ctx);
787
847
  if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true;
@@ -818,6 +878,8 @@ export default function (pi: ExtensionAPI) {
818
878
  });
819
879
 
820
880
  pi.on("tool_execution_end", async (event, ctx) => {
881
+ const resultDetails = (event.result as { details?: unknown } | undefined)?.details;
882
+ if (finishDispatch(event.toolName, event.toolCallId, event.isError, resultDetails)) updateWidget(ctx);
821
883
  if (event.toolName !== "plan_tracker" || event.isError) return;
822
884
  const details = (event.result as { details?: { tasks?: { status: string }[]; error?: string } } | undefined)
823
885
  ?.details;
@@ -1032,6 +1094,8 @@ export default function (pi: ExtensionAPI) {
1032
1094
  const target = resolved.phaseModels[phase];
1033
1095
  if (target) startLines.push(...(await applyPhaseModel(pi, ctx, phase, target)));
1034
1096
  }
1097
+ if (liveDispatch?.phase === params.phase) liveDispatch = undefined;
1098
+ dispatchCounts[params.phase] = {};
1035
1099
  phases = { ...phases, [params.phase]: transitionPhaseState("in_progress") as PhaseState };
1036
1100
  // A rewind must not inherit the prior verify's conformance latch.
1037
1101
  if (params.phase === "implement") {
@@ -1102,6 +1166,7 @@ export default function (pi: ExtensionAPI) {
1102
1166
  };
1103
1167
  }
1104
1168
  }
1169
+ if (liveDispatch?.phase === params.phase) liveDispatch = undefined;
1105
1170
  phases = { ...phases, [params.phase]: transitionPhaseState("complete") as PhaseState };
1106
1171
  firedGuards.clear();
1107
1172
  updateWidget(ctx);
@@ -1134,6 +1199,7 @@ export default function (pi: ExtensionAPI) {
1134
1199
  };
1135
1200
  }
1136
1201
  const reason = params.reason;
1202
+ if (liveDispatch?.phase === params.phase) liveDispatch = undefined;
1137
1203
  phases = { ...phases, [params.phase]: transitionPhaseState("skipped", reason) as PhaseState };
1138
1204
  firedGuards.clear();
1139
1205
  updateWidget(ctx);
@@ -1180,6 +1246,8 @@ export default function (pi: ExtensionAPI) {
1180
1246
  }
1181
1247
 
1182
1248
  case "reset": {
1249
+ liveDispatch = undefined;
1250
+ dispatchCounts = Object.fromEntries(PHASES.map((p) => [p, {}])) as Record<Phase, Record<string, number>>;
1183
1251
  phases = Object.fromEntries(
1184
1252
  PHASES.map((p) => [p, transitionPhaseState("pending")]),
1185
1253
  ) as PhaseMap;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "7.25.1",
3
+ "version": "7.27.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",
@@ -62,7 +62,7 @@
62
62
  "test": "node scripts/ci.mjs"
63
63
  },
64
64
  "peerDependencies": {
65
- "pi-cohort": ">=7.4.3"
65
+ "pi-cohort": ">=7.4.4"
66
66
  },
67
67
  "dependencies": {
68
68
  "yaml": "^2.9.0"
@@ -19,6 +19,8 @@ A grant is the spec's `**Amend-grant:**` line read through the shared predicate;
19
19
  | exactly one line whose trimmed value is any other non-empty single-line text | grant active; the sentence is that value |
20
20
  | two or more lines, one line with an empty value, or a value continued onto the next physical line (any non-blank line directly below the field that is neither a `**Key:**` header line nor a `> **Superseded by:**` banner line) | malformed -> stop, print the offending line(s) |
21
21
 
22
+ **Value decoding**. Run the structural predicate first; missing or `none` gives neither capability, and malformed lines stop with the offending lines quoted. Decode every active value: section 1 at each batch boundary, SDD Fix-Loop Rounds at each funded dispatch, and `spec-finalization.md` before writing the value. Match exactly one `fund N fix rounds` or `N more fix rounds` anywhere on the line, with N matching `[1-9][0-9]*`; no match funds nothing. Stop and quote the line before any item applies or any round is funded on two or more matches or a token-shaped invalid phrase (`fund 0 fix rounds`, `fund three fix rounds`, `-2 more fix rounds`). Remove the matched token and its `; ` / `, ` separators for amend decoding: a non-empty remainder keeps wording-agnostic amend permission (`auto-apply amends`, `apply spec fixes without asking`, or equivalent); an empty remainder is budget-only, with no amend permission. Keep grants without a budget unchanged. Decode `auto-apply amends; fund 3 fix rounds` as both capabilities and `2 more fix rounds` as budget-only. The budget is non-retroactive: rounds spent before the line was written do not count against or toward it. Write `none` on revocation such as `revoke auto-apply amends`, clearing both capabilities; retain funding alone by writing the token alone.
23
+
22
24
  **Writers.** Spec-writing and the spec gate are owned by `spec-finalization.md`; this file owns the mid-flow changes:
23
25
 
24
26
  | Event | Edit |
@@ -54,7 +56,7 @@ Collect every amendment pending at this decision point (same spec-review round,
54
56
 
55
57
  The working tree stays at pre-batch HEAD until apply (section 5) - nothing is edited before the reviewer and, where needed, the human have answered. A redraw item stops alone first (`../SKILL.md` redraw path); amend items are held and re-batched after it resolves.
56
58
 
57
- **Standing grant active** ([boundaries](#standing-grants)): at every batch boundary re-read the spec's `**Amend-grant:**` line through the predicate; malformed stops the batch with the offending line(s). Active -> skip steps 2-4, apply every item, print one line each `amended the spec: <title> - <what>`, record `granted`, and quote the line's value in the commit body - the record kind `finishing-a-development-branch` Step 4's digest reads is unchanged.
59
+ **Standing grant active** ([boundaries](#standing-grants)): at every batch boundary re-read the spec's `**Amend-grant:**` line through the predicate and Value decoding; malformed stops the batch with the offending line(s). Active with amend permission -> skip steps 2-4, apply every item's `recommended` choice (`accept` or its named `alt-n`, never `accept` by default) through section 5, print one line each `amended the spec: <title> - <what>`, record `granted`, and quote the line's value in the commit body - the record kind `finishing-a-development-branch` Step 4's digest reads is unchanged. Budget-only takes ordinary steps 2-4; absent or `none` follows the ordinary path.
58
60
 
59
61
  ## 2. Prefilter - no model call
60
62
 
@@ -126,6 +126,7 @@ New predecessor candidates at spec-writing: <path> (<title>), ... - index rows a
126
126
  1 - approve: proceed to planning under the existing amendment review; a fresh reviewer applies evidence-backed factual corrections on its own, and every other spec amendment (scope, acceptance-criteria, or contract edits, and redraws) stops for your review.
127
127
  2 - approve, auto-apply amends: every later spec amendment in this flow (corrected facts, paths, verification lines, and scope, acceptance-criteria, or public-contract edits alike) applies without asking; only a redraw (changed problem statement, component added, removed, or re-bounded) still stops for you, and the grant never stands in for a spec approval.
128
128
  3 - show council dispositions: prints the council audit verbatim, then re-presents this gate. Not an approval.
129
+ Add "; fund N fix rounds" (or "N more fix rounds") to 1 or 2 to fund N implement fix rounds past the escalated round.
129
130
 
130
131
  Or tell me what to change in the spec, or say "revert applied council edit <X>" to undo a specific applied edit.
131
132
  ```
@@ -134,7 +135,20 @@ If you believe the summary needs correcting, do **not** silently rewrite it - re
134
135
 
135
136
  **Revert valve.** "Revert applied council edit X" is a normal change request: revise the spec to undo edit X, re-dispatch the summarizer with a **fresh** temp path (per the re-dispatch rule below), and re-present the gate. This is cheap here - the spec is not yet plan- or code-bearing.
136
137
 
137
- Wait for the user. On a change request (including a revert), revise the spec and re-present - mint a **fresh** temp path for the re-dispatched summarizer (never reuse a prior round's path, so stale content can never be mistaken for the new summary). On `3` or its label alone: print the audit verbatim from `git -C <abs worktree path> show -s --format=%b HEAD` (never from a held tool result; the body survives the grant's `--amend --no-edit`), then re-present the same gate - the briefing text of the gate message just rendered, reused verbatim (no summarizer re-dispatch, no read of the removed `$SUMMARY_PATH`; if that text is no longer in context, the one-line degrade note takes its place), the `Council:` line, the adjacent lines, the three rows; nothing in the spec or the commit changes. `3` plus a change request is a change request. `3` plus `1`/`2` prints and re-presents, and the approval is read only from a clean `1`/`2` on the re-presented gate - a reply carrying `3` never grants. A gate-round recommit (change request or revert) re-runs the same heredoc commit with the audit as its body, so HEAD always carries it; on a revert of edit X, move X's `Applied:` line to `Rejected:` with `-> reverted at gate` and recompute the `Council:` counts from the revised audit. On approval - `1`, `approve`, or equivalent prose approves without a grant; `2`, `approve, auto-apply amends`, or equivalent prose approves and grants - proceed immediately to `/skill:writing-plans` with no further prompt; first read [Standing grants](amendment-surface.md#standing-grants) (stop if unreadable). On a grant (`2`, `approve, auto-apply amends`, or equivalent prose), `edit` the spec's `**Amend-grant:**` value to the granting sentence (the digit `2` becomes the grant description after `2 - approve, auto-apply amends:` above), then `git -C <abs worktree path> add -- <spec path>` and `git -C <abs worktree path> commit --amend --no-edit -q`, so the spec commit carries the line and keeps its council-audit body; then confirm `git -C <abs worktree path> show HEAD:<spec path>` reads "grant active" through the predicate. On a plain approval (`1`, `approve`) the value stays `none` and the commit is untouched. A reply that mixes approval with a change request ("2 but rename the section") is a change request: revise, re-present, and read the grant only from the reply to the re-presented gate. If the amend or the HEAD check fails, stop and report; never proceed as granted. The plan and execution mode are mechanical derivatives, so the only human gate here is spec approval itself. Don't land the spec on `main`; it stays in the worktree and ships in the same squash commit as the implementation.
138
+ Wait for the user. Interpret funded replies with this table before routing change requests:
139
+
140
+ | Reply | Action / written value |
141
+ |---|---|
142
+ | bare `2` | approve; persist the option-2 description above |
143
+ | `2; fund 3 fix rounds` or `2, fund 3 fix rounds` | approve; persist that description plus `; fund 3 fix rounds`, not a change request |
144
+ | `1` with a budget token | approve; persist `fund 3 fix rounds` alone for that token (budget-only) |
145
+ | approval prose with a token | approve; preserve the prose collapsed to one physical line |
146
+ | budget token alone (`fund 3 fix rounds`) | re-present the gate asking for `1` or `2`; write nothing |
147
+ | token with malformed N | re-present the gate quoting the token; write nothing, never imply a change request |
148
+
149
+ For every value to persist, read [Standing grants](amendment-surface.md#standing-grants) and run Value decoding. Decode before `edit` or `--amend`; never persist a malformed token. Use the decoded table value in the approval persistence below, including budget-only approvals.
150
+
151
+ On a change request (including a revert), revise the spec and re-present - mint a **fresh** temp path for the re-dispatched summarizer (never reuse a prior round's path, so stale content can never be mistaken for the new summary). On `3` or its label alone: print the audit verbatim from `git -C <abs worktree path> show -s --format=%b HEAD` (never from a held tool result; the body survives the grant's `--amend --no-edit`), then re-present the same gate - the briefing text of the gate message just rendered, reused verbatim (no summarizer re-dispatch, no read of the removed `$SUMMARY_PATH`; if that text is no longer in context, the one-line degrade note takes its place), the `Council:` line, the adjacent lines, the three rows; nothing in the spec or the commit changes. `3` plus a change request is a change request. `3` plus `1`/`2` prints and re-presents, and the approval is read only from a clean `1`/`2` on the re-presented gate - a reply carrying `3` never grants. A gate-round recommit (change request or revert) re-runs the same heredoc commit with the audit as its body, so HEAD always carries it; on a revert of edit X, move X's `Applied:` line to `Rejected:` with `-> reverted at gate` and recompute the `Council:` counts from the revised audit. On approval - `1`, `approve`, or equivalent prose approves without a grant; `2`, `approve, auto-apply amends`, or equivalent prose approves and grants - proceed immediately to `/skill:writing-plans` with no further prompt; first read [Standing grants](amendment-surface.md#standing-grants) (stop if unreadable). On a grant or funded approval, `edit` the spec's `**Amend-grant:**` value to the table value for funded replies, otherwise the granting sentence (the digit `2` becomes the grant description after `2 - approve, auto-apply amends:` above), then `git -C <abs worktree path> add -- <spec path>` and `git -C <abs worktree path> commit --amend --no-edit -q`, so the spec commit carries the line and keeps its council-audit body; then confirm `git -C <abs worktree path> show HEAD:<spec path>` reads "grant active" through the predicate. On a plain approval (`1`, `approve`) the value stays `none` and the commit is untouched. A reply that mixes approval with a change request ("2 but rename the section") is a change request: revise, re-present, and read the grant only from the reply to the re-presented gate. If the amend or the HEAD check fails, stop and report; never proceed as granted. The plan and execution mode are mechanical derivatives, so the only human gate here is spec approval itself. Don't land the spec on `main`; it stays in the worktree and ships in the same squash commit as the implementation.
138
152
 
139
153
  Post-approval changes follow [Amending an approved spec](../SKILL.md#amending-an-approved-spec).
140
154
 
@@ -30,7 +30,7 @@ You are the **orchestrator**. You read the plan, dispatch, review the review, de
30
30
  **Do not pause to check in with the user between tasks.** The plan is already approved. Pause only when:
31
31
 
32
32
  - A subagent returns `NEEDS_CONTEXT` or `BLOCKED` (see [Implementer Status](#implementer-status))
33
- - An escalated round fails (stop note per [Fix-Loop Rounds](#fix-loop-rounds))
33
+ - An escalated round fails with no funded round left (stop note per [Fix-Loop Rounds](#fix-loop-rounds))
34
34
  - A ⚠️ workflow warning fires
35
35
 
36
36
  Reaching the end of the plan is not a pause: continue through verification and invoke `/skill:finishing-a-development-branch <abs worktree path>` as defined in [After All Tasks](#after-all-tasks-complete).
@@ -45,7 +45,7 @@ Periodic "should I continue?" prompts add latency without adding safety. The pla
45
45
 
46
46
  Extract **`SCOPED_TEST_COMMANDS`** from the task's `**Tests:**` command bullets, backticks stripped, one per line; `- none:` -> `none`; a wave = the union of its tasks' commands. Treat `via:` and `baseline:` bullets and `- Test:` paths as contract, not commands: carry them with the task text, never in `SCOPED_TEST_COMMANDS`.
47
47
 
48
- Keep the orchestrator as the spec's only writer during execution. On implementer `BLOCKED` citing a spec defect, or a review finding showing the spec (not the code) is wrong, pause the fix loop, read and execute brainstorming's [amendment path](../brainstorming/SKILL.md#amending-an-approved-spec) in place (it reviews first, stops only on escalation or redraw), then resume. Keep code-vs-spec mismatch in the SR loop. Require an SR unable to read the spec at a cited anchor (missing file, unresolvable heading/range) to return a blocking finding - the contract is spec+task or stop, never a silent fallback to task-only review.
48
+ Keep the orchestrator as the spec's only writer during execution. On implementer `BLOCKED` citing a spec defect, or a review finding showing the spec (not the code) is wrong, pause the fix loop, read and execute brainstorming's [amendment path](../brainstorming/SKILL.md#amending-an-approved-spec) in place (it reviews first; with amend permission on the `**Amend-grant:**` line it applies amends without a stop and still stops on a redraw; without permission it stops on escalation or redraw), then resume. Keep code-vs-spec mismatch in the SR loop. Require an SR unable to read the spec at a cited anchor (missing file, unresolvable heading/range) to return a blocking finding - the contract is spec+task or stop, never a silent fallback to task-only review.
49
49
 
50
50
  ## Fix-Loop Rounds
51
51
 
@@ -68,7 +68,7 @@ Every fix re-dispatch (implementer) and code-review re-review carries the consum
68
68
 
69
69
  Every dispatched fix is verified by a re-review before escalation or task progression - the loop only ever exits on a clean review or an escalation.
70
70
 
71
- **Escalate** = one escalated round; stop only on failure. Call `gauntlet_setting({ key: "escalationLoop" })` (unavailable -> stop and report). `implModel` undefined -> stop note. Otherwise re-dispatch that fix - same payload and isolation knobs (`cwd`, `worktree`, `SCOPED_TEST_COMMANDS`, status protocol, prior patch, spec anchors) plus the prior report verbatim - overriding only `model: <implModel>`, `context: "fresh"`, `async: false`; one implementer, no fan-out. Run the normal fix-round review gate (SR then CR on `Behaviour-change: yes`, else the triggering reviewer with the re-review marker). All clean -> proceed; the escalated patch supersedes the prior one at integrate. Any review with issues, a non-`DONE` status, or a dispatch error -> stop note per `stop-note.md`; no second dispatch. Once per loop; independent of the convergence exception. No `plan_tracker` write during escalation - the task stays `in_progress` until the human decides.
71
+ Read [reference/fix-loop.md](./reference/fix-loop.md) now for escalation and funded-round dispatch rules.
72
72
 
73
73
  **Worked examples:**
74
74
 
@@ -103,10 +103,10 @@ Every implementer dispatch returns one of:
103
103
  |---|---|---|
104
104
  | `DONE` | Implemented + tests pass + self-reviewed; work present in the worker's tree; the wave commit is step 7 | Proceed to spec review |
105
105
  | `DONE_WITH_CONCERNS` | Implemented + tests pass, but flagged a deviation or risk | Read concerns. If acceptable, proceed. If not, dispatch fix subagent. |
106
- | `NEEDS_CONTEXT` | Implementer needs information you have | Resolve fact asks per [lookup handling](./stop-note.md). For decisions, answer and re-dispatch with answers in the task text, or escalate to the user. |
107
- | `BLOCKED` | Cannot proceed (missing dependency, spec contradicts code, environment broken) | Resolve fact asks per [lookup handling](./stop-note.md). For decisions, stop and diagnose: fix the orchestration-level issue and re-dispatch, or escalate to the user. |
106
+ | `NEEDS_CONTEXT` | Implementer needs information you have | For a spec-defect citation, take the amendment route below first. Resolve fact asks per [lookup handling](./stop-note.md). For decisions, answer and re-dispatch with answers in the task text, or escalate to the user. |
107
+ | `BLOCKED` | Cannot proceed (missing dependency, spec contradicts code, environment broken) | For a spec-defect citation, take the amendment route below first. Resolve fact asks per [lookup handling](./stop-note.md). For decisions, stop and diagnose: fix the orchestration-level issue and re-dispatch, or escalate to the user. |
108
108
 
109
- Classify each question: artifact location, value, or existence is a fact ask; scope choices, option choices, and another-round approval are decisions.
109
+ Classify each question: treat a question whose candidate answers are each edits to the spec, plan, or an approved rule as an amendment batch per [amendment-surface.md](../brainstorming/reference/amendment-surface.md), never a decision. Stop for a redraw: changed problem statement; component added, removed, or re-bounded. Decode the `**Amend-grant:**` value through that surface's Standing grants. With amend permission, apply each item's `recommended` (`accept` or named `alt-n`), print `amended the spec: <title> - <what>`, and complete section 5's plan-writer amend dispatch, `plan_check`, tracker update, and one `amend:` commit in the same turn; skip reviewer, menu, and confirmation turns. Without permission (including absent or `none`), retain the surface's menu and escalation/redraw routing. For remaining questions, classify artifact location, value, or existence as a fact ask; scope and option choices as decisions; another-round approval as a decision only with no funded round left.
110
110
 
111
111
  Implementer prompt templates must instruct subagents to return one of these statuses explicitly.
112
112
 
@@ -305,7 +305,7 @@ git -C "$SR_TREE" apply --index "$SR_PATCH" || exit 1
305
305
  printf 'snapshot: %s\n' "$SR_TREE"
306
306
  ```
307
307
 
308
- Record its review tree per [Fix delta](#fix-delta) before every parent-dispatched SR, keyed by task. Use the printed snapshot path as that spec-review task's `cwd` and whole-file read root. Keep the authoritative spec's absolute path in the implementation worktree. Pass the patch diff as before; the snapshot contains this task's changed and added files, with deleted files absent. Read deleted files from `WAVE_BASE_SHA` when the review needs their prior contents. Dispatch read-only, with no `worktree` flag. On snapshot setup or tree-capture failure, or a missing snapshot path, stop dispatch and retain the failed snapshot directory and shell output for diagnosis; dispatch no review and perform no snapshot cleanup. Save each snapshot-backed review's terminal report to its `output:` path, read it, then remove that review's snapshot with `git -C <implementation-worktree> worktree remove --force <snapshot-path>`. Keep every snapshot used as a reviewer `cwd` until that terminal report is saved; recording its tree never authorizes early cleanup. Remove a reconstruction used only to record tree-before after capturing its tree, since no reviewer runs there. Recreate from the replacement patch for each pre-integration re-review. Post-integration SR reviews the implementation worktree directly.
308
+ Record its review tree per [Fix delta](#fix-delta) before every parent-dispatched SR, keyed by task. Use the printed snapshot path as that spec-review task's `cwd` and whole-file read root. Keep the authoritative spec's absolute path in the implementation worktree. Pass the patch diff as before; the snapshot contains this task's changed and added files, with deleted files absent. Read deleted files from `WAVE_BASE_SHA` when the review needs their prior contents. Dispatch read-only, with no `worktree` flag. On snapshot setup or tree-capture failure, or a missing snapshot path, stop dispatch and retain the failed snapshot directory and shell output for diagnosis; dispatch no review and perform no snapshot cleanup. Save each snapshot-backed review's terminal report to its `output:` path, read it, then remove that review's snapshot with `git -C <implementation-worktree> worktree remove --force <snapshot-path>`. Keep every snapshot used as a reviewer `cwd` until that terminal report is saved; recording its tree never authorizes early cleanup. Remove a reconstruction used only to record tree-before after capturing its tree, since no reviewer runs there. Recreate from the replacement patch for each pre-integration re-review; on a failed recreate, stop dispatch and retain that snapshot directory and shell output, like any setup failure. Post-integration SR reviews the implementation worktree directly.
309
309
 
310
310
  ## When a Subagent Fails
311
311
 
@@ -338,13 +338,13 @@ Record its review tree per [Fix delta](#fix-delta) before every parent-dispatche
338
338
  ## Red Flags — STOP
339
339
 
340
340
  - Writing code yourself instead of dispatching.
341
- - Pausing between tasks for anything other than `NEEDS_CONTEXT`, `BLOCKED`, a fix-loop escalation, a workflow warning, or a spec amendment.
341
+ - Pausing between tasks for anything other than `NEEDS_CONTEXT`, `BLOCKED`, a fix-loop escalation, a workflow warning, a redraw, or an amend with no standing grant.
342
342
  - Dispatching parallel implementers on overlapping files, on a shared mutable runtime resource, or without `worktree: true`.
343
343
  - Making a subagent read the plan, inlining spec excerpts to the spec reviewer, or dispatching with a `SCOPED_TEST_COMMANDS` value missing or not copied from the plan's `Tests:` bullets.
344
344
  - Dispatching `code-reviewer` before every in-scope spec-review verdict is ✅, or per task inside a wave.
345
345
  - Moving to the next task with either review still showing issues, or skipping the `Implementer Status` parse.
346
346
  - Second chain call in the same wave after a stop or a chain error - the retained results are the fan-out result; continue on the snapshot path.
347
- - Dispatching fix 3 without a reviewer-emitted `CONVERGING` verdict, or continuing past `STAGNANT` instead of escalating.
347
+ - Dispatching fix 3 without a reviewer-emitted `CONVERGING` verdict, or continuing past `STAGNANT` instead of escalating (or, on a funded re-review, stopping).
348
348
  - Running the full verification entrypoint, or any test command outside `SCOPED_TEST_COMMANDS`, during the implement phase.
349
349
  - Dispatching a repair before reopening the plan-task indices that own its files, whole-diff CR before parent verification passes, or conformance before the CR result is accepted.
350
350
  - Polling, joining, or relaunching an unexpectedly asynchronous dispatch, or starting on main without explicit user consent.
@@ -0,0 +1,23 @@
1
+ # Escalated fix rounds
2
+
3
+ **Escalate** = one escalated round; stop only on failure. Call `gauntlet_setting({ key: "escalationLoop" })` (unavailable -> stop and report). `implModel` undefined -> stop note. Otherwise re-dispatch that fix - same payload and isolation knobs (`cwd`, `worktree`, `SCOPED_TEST_COMMANDS`, status protocol, prior patch, spec anchors) plus the prior report verbatim - overriding only `model: <implModel>`, `context: "fresh"`, `async: false`; one implementer, no fan-out. Run the normal fix-round review gate (SR then CR on `Behaviour-change: yes`, else the triggering reviewer with the re-review marker). All clean -> proceed; the escalated patch supersedes the prior one at integrate. A non-`DONE` status or a dispatch error -> stop note per `stop-note.md`. A review with issues -> a funded round while one remains for the task (below), otherwise stop note; no other second dispatch. Once per loop; independent of the convergence exception. No `plan_tracker` write during escalation - keep the task `in_progress` until the wave commit or, on a stop, the human decides.
4
+
5
+ ## Funded rounds
6
+
7
+ Decode the current `**Amend-grant:**` line using **Value decoding** in [Standing grants](../../brainstorming/reference/amendment-surface.md#standing-grants) before each funded dispatch. Funding is independent of ordinary fixes and the escalated round; it never supplies a missing `implModel`, another escalation grant, verify credits, or `grant_fix_rounds`. Keep the review-3 convergence exception once per loop; never re-arm it through funding.
8
+
9
+ Start one per-task ledger at zero when each task first enters Fix-Loop Rounds. Carry it across SR, behavior-change reroutes, the SR-to-CR transition, and wave repairs re-entering SR; use no wave-level allowance. Determine touched owners using the SR targeting rule (tasks whose files the fix touches). For a multi-task CR repair, spend once from every touched task; a touched exhausted owner stops the wave with that task's stop note before dispatch.
10
+
11
+ Before every funded dispatch, print `funded round <k> of <N> for task <n>` for each touched task. Apply mid-loop grants prospectively, with spent starting at zero: rounds before the token was written neither count against nor toward it. Repeat the escalated payload and isolation exactly: configured `model: <implModel>` from `escalationLoop`, `context: "fresh"`, `async: false`, one implementer, no fan-out, same `cwd`/`worktree`, task text, prior patch, spec anchors, status protocol, `SCOPED_TEST_COMMANDS`, and complete prior report verbatim. Run the same tests and same review gate: SR then CR on `Behaviour-change: yes`, otherwise the triggering reviewer with the re-review marker, trajectory block and Fix delta. Every dispatched round spends one, including failures.
12
+
13
+ | Funded outcome | Action |
14
+ |---|---|
15
+ | Errors and non-`DONE` | Count as spent and stop with the task's stop note. |
16
+ | Any funded re-review reports `STAGNANT` | Stop immediately regardless of funding remaining. |
17
+ | Issue-bearing re-review with rounds left | Dispatch the next funded round. |
18
+ | Issue-bearing re-review after N rounds | Stop with the exhausted task's stop note. |
19
+ | All reviews clean | Continue through the wave gate and commit before task completion. |
20
+
21
+ Emit the task's stop note for the funded-stop outcomes above (including `STAGNANT`) and an escalated-round failure with no funded round left. While a budget token exists, include `budget: <spent> of <N> spent` in every stop note after `Problem:` and any `Searched:` lines and before the file finding, including pre-escalation stops, missing `implModel`, and exhausted fact lookups at spent zero; use the `(not recovered on resume)` suffix below when recovery fails, and omit the line without a token. Treat this line as the only round-history exception to `stop-note.md`'s no-round-history rule.
22
+
23
+ On resume, keep header restoration unchanged; recover each task's spent count only from its `funded round <k> of <N>` transcript lines. Without recoverable lines, treat funding as exhausted; stop the next issue-bearing re-review with `budget: <N> of <N> spent (not recovered on resume)`. Treat a newly written budget token as the human refund, never a resume reset. Use no new spent carrier or handoff/resume grammar.