pi-gauntlet 7.26.0 → 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,11 @@
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
+
3
9
  ## v7.26.0 - 2026-10-22
4
10
 
5
11
  ### Changed
@@ -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.26.0",
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",