pi-gauntlet 7.26.0 → 7.28.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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## v7.28.0 - 2026-10-23
4
+
5
+ ### Added
6
+
7
+ - **shape-ticket repairs a list of tickets as one batch and grounds every ticket in a repo audit.** A comma or whitespace list of refs runs as one batch repair: duplicates collapse, unfetched refs are reported and skipped, a wishful or tautological AC stops only its own ticket, and each ticket gets the single-ticket confirmation gate in ref order, written on approval before the next gate prints; splits keep their all-or-nothing gate, and batches above eight tickets dispatch in successive calls. A read-only `scout` audit runs per ticket in create and repair mode (`skills/shape-ticket/reference/repo-audit.md`): claims checked against code, repo-fit of the Idea against AGENTS.md/CLAUDE.md/REVIEW.md with a cited simpler or existing path, a sibling-repo leg from the `## shape-ticket` `- sibling:` overrides lines, in-motion work, and a best-effort replication scenario recorded as a note, never a block. Findings rewrite the Problem/Idea with citations or render as an `Audit:` block inside the existing gate; the roast stays content-only and receives the audit as untrusted data; the drafter keeps `audit.md` beside the draft only when gather produced it. The AC integrity gate gains a uniqueness rule (an implied child row folds into its parent, a bundled row splits). `## Examples and rationalizations` and the tracker ladder move to `reference/examples.md` and `reference/tracker-ladder.md`. (#80)
8
+ - Eval samples `batch-repair`, `audit-repo-fit`, `scenario-best-effort`, `audit-quiet-noop`, `audit-byte-lock`, `audit-dispatch`, and `audit-grounding` under `eval/shape-ticket/`. (#80)
9
+
10
+ ### Changed
11
+
12
+ - The bundle-reference lint in `scripts/ci.mjs` exempts `audit.md` and `snapshot.md` only as `<tmp>/...` path segments or bare names, so a broken `reference/audit.md` link is flagged again. (#80)
13
+
14
+ ## v7.27.0 - 2026-10-22
15
+
16
+ ### Added
17
+
18
+ - **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`.
19
+
3
20
  ## v7.26.0 - 2026-10-22
4
21
 
5
22
  ### Changed
package/README.md CHANGED
@@ -35,7 +35,7 @@ pi-gauntlet's only hard dependency is pi-cohort - every gate that dispatches a r
35
35
 
36
36
  Concretely, one change through the gauntlet:
37
37
 
38
- 0. *(Optional)* Before there's even a spec, `/skill:shape-ticket` can create or repair a single tracker issue - shaping a raw ask into a Context/Problem/Idea/Acceptance Criteria ticket, gated by an AC integrity check, a cheap council roast, and one human-confirmed write that may include one optional gated comment for a Reporter note or overlap evidence. Implementer detail moves into the gated Reporter note; brainstorming reads that note with the ticket so the spec retains the detail. A failed roast is retried once, then surfaced inline at the gate if it fails again. It's a tool, not a phase: no worktree, no plan/phase tracker, runs from any repo state. The agent may start it on its own when the ask is "file a ticket" or another skill routes there; the write still waits for your yes on the presented diff. Similarly, `/skill:chase-bug` triages a raw bug report into an evidenced verdict - and can hand off to shape-ticket, brainstorming, or a bounded hotfix - before any spec exists.
38
+ 0. *(Optional)* Before there's even a spec, `/skill:shape-ticket` can create one tracker issue or repair a comma/whitespace list of issue refs - shaping each into a Context/Problem/Idea/Acceptance Criteria ticket, grounded by a repository audit, an AC integrity check, and a cheap council roast. Repair finishes preparation for all tickets before presenting exact-diff confirmations sequentially in ref order; each approval writes that ticket before the next gate, and may include one optional gated comment for a Reporter note or overlap evidence. The audit checks factual claims, Idea fit, AC mechanisms against cited repo guidance, and already-stated observable legs in configured sibling checkouts. It reports in-motion work and attempts a best-effort scenario through authorized current-system reads, without promising reproduction or a fix; missing access or nothing to observe is reported, not a new approval gate. Implementer detail moves into the gated Reporter note; brainstorming reads that note with the ticket so the spec retains the detail. A failed roast is retried once, then surfaced inline at the gate if it fails again. It's a tool, not a phase: no worktree, no plan/phase tracker, runs from any repo state. The agent may start it on its own when the ask is "file a ticket" or another skill routes there; the write still waits for your yes on the presented diff. Similarly, `/skill:chase-bug` triages a raw bug report into an evidenced verdict - and can hand off to shape-ticket, brainstorming, or a bounded hotfix - before any spec exists.
39
39
  1. Opt in with `/skill:brainstorming` and describe the change. The command is the reliable discovery-and-entry path; explicit prose requesting brainstorming/the gauntlet also supplies consent when the skill is already loaded or an available instruction or route identifies it. An ordinary implementation request or an agent recommendation is not consent. **`brainstorming`** sets up an isolated worktree, explores the codebase, and turns your description into a written spec - when its recon finds a cited case that the ask is the wrong change (misdiagnosed, wrong-sized, already solved, a band-aid, or cutting across a sound pattern - including a repo convention that is itself debt) and can name the alternative, question one is a framing question (`as framed`, `pivot: <alternative>`, or `smaller or none`); otherwise it opens with one sentence naming what it checked, and every approach it proposes ends with a `Pattern:` line naming what it follows or cuts across; when the run starts from a ticket, the spec carries the ticket's acceptance criteria verbatim, each with a disposition (`in-scope`, `deviates:`, `deferred:`, `venue:`, `elsewhere:` for a row another repo's part delivers), scope cuts come only from your answer to a cut question and are listed at the gate, the conformance gate checks the in-scope ones, and `/skill:check-delivery` verifies `venue:` rows before the prod deploy. A multi-model critique runs on it automatically. If the spec replaces a known prior spec, brainstorming marks the predecessor with a `> **Superseded by:**` banner under its title (default format, syntax overridable via `.pi/gauntlet-overrides.md`; candidates come from brainstorming's scout recon, never a mechanical sweep). **You read and approve the spec - human gate 1.** No implementation code exists yet.
40
40
  2. **`writing-plans`** decomposes the approved spec into atomic, independently-verifiable tasks, grouped into parallel waves where they don't touch the same files.
41
41
  3. **`subagent-driven-development`** executes the plan one task at a time, each in a fresh subagent, behind spec-compliance review then code-quality review. TDD-locked: red, green, refactor. Independent implementation, review, and council batches remain parallel, but their dispatches are foreground: the orchestrator waits for terminal results before accepting work or advancing a phase.
@@ -77,7 +77,7 @@ The run's grants, target/scope baseline, repair usage, and poll deadline live in
77
77
 
78
78
  pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
79
79
 
80
- - **20 skills** - the workflow logic. Fifteen activate automatically when pi sees the matching kind of task, and the workflow skills chain through their gates: `shape-ticket` (creates or repairs one tracker issue per run against a Context/Problem/Idea/Acceptance-Criteria template, gated by an AC integrity check, a cheap council roast, and a single human-confirmed write; model-invocable so other skills and commands can route "file a ticket" to it, the gate still needs your yes), `gatekeep-pr` (model-invocable on an explicit ask that names the PR and the verb; the gates still need your selection - is consent-gated pre-merge verification of a PR against the scope contract at its head - the approved spec's acceptance-criteria dispositions and Design when the PR carries one, else the PR's stated intent; the ticket is cross-checked, never judged here (`check-delivery` owns it) - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; a pending commit status that GitHub does not report as blocking the gating actor (`mergeStateStatus` not `BLOCKED`) is not waited for - external-CI commit statuses included - while a running Actions check, an unknown merge state, and every other merge precondition still withhold; the project's verification command runs only as fallback), a rubric-based review, a compact bottom-up report (what the PR delivers, including rows deferred or deviating per spec, PR comments, nits, blockers, then a `mergeable` / `fixable` verdict) and a verb menu whose rows derive from the actor's permissions on the PR; nothing mutates (fixes, pushes, reviews, merges, tracker comments) until you pick a row, except the opt-in `--rebase [base]` sync that rebases, reviews, and lease-pushes the head before verification, or holds the rebased head locally when a conflict stop's scoped tests fail, so the normal `fix` row resolves it before the lease push; after your own merge two opt-in rows post the `AC coverage` block to the ticket or the PR; every terminal menu offers a recommended `cleanup` row that tears down the gate's worktree and branch and runs the overrides file's `## cleanup` steps - run it with `/skill:gatekeep-pr <pr> [--rebase [base]]`), `check-delivery` (model-invocable on an explicit ask that names the ticket and the verb; the gates still need your selection - is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`), `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `forge-skill`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Five more require explicit entry (`disable-model-invocation: true`): `brainstorming` starts the gated workflow on explicit consent - use `/skill:brainstorming` for reliable discovery, or opt in through reachable explicit prose or a human-selected handoff; after spec approval, downstream planning and implementation still chain automatically. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, a council assessment when available, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`), a bare worktree that already holds a spec, or a spec tracked on main under a configured spec dir (a spec seed: it creates or reuses the worktree through `using-git-worktrees` and pins the spec), restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief-file> | <spec>.md | <worktree-name-or-path>]`.
80
+ - **20 skills** - the workflow logic. Fifteen activate automatically when pi sees the matching kind of task, and the workflow skills chain through their gates: `shape-ticket` (creates one tracker issue or repairs a comma/whitespace list of refs against a Context/Problem/Idea/Acceptance-Criteria template, with a grounded repo audit, an AC integrity check, a cheap council roast, and sequential per-ticket exact-diff write confirmations; model-invocable so other skills and commands can route "file a ticket" to it, the gate still needs your yes), `gatekeep-pr` (model-invocable on an explicit ask that names the PR and the verb; the gates still need your selection - is consent-gated pre-merge verification of a PR against the scope contract at its head - the approved spec's acceptance-criteria dispositions and Design when the PR carries one, else the PR's stated intent; the ticket is cross-checked, never judged here (`check-delivery` owns it) - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; a pending commit status that GitHub does not report as blocking the gating actor (`mergeStateStatus` not `BLOCKED`) is not waited for - external-CI commit statuses included - while a running Actions check, an unknown merge state, and every other merge precondition still withhold; the project's verification command runs only as fallback), a rubric-based review, a compact bottom-up report (what the PR delivers, including rows deferred or deviating per spec, PR comments, nits, blockers, then a `mergeable` / `fixable` verdict) and a verb menu whose rows derive from the actor's permissions on the PR; nothing mutates (fixes, pushes, reviews, merges, tracker comments) until you pick a row, except the opt-in `--rebase [base]` sync that rebases, reviews, and lease-pushes the head before verification, or holds the rebased head locally when a conflict stop's scoped tests fail, so the normal `fix` row resolves it before the lease push; after your own merge two opt-in rows post the `AC coverage` block to the ticket or the PR; every terminal menu offers a recommended `cleanup` row that tears down the gate's worktree and branch and runs the overrides file's `## cleanup` steps - run it with `/skill:gatekeep-pr <pr> [--rebase [base]]`), `check-delivery` (model-invocable on an explicit ask that names the ticket and the verb; the gates still need your selection - is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`), `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `forge-skill`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Five more require explicit entry (`disable-model-invocation: true`): `brainstorming` starts the gated workflow on explicit consent - use `/skill:brainstorming` for reliable discovery, or opt in through reachable explicit prose or a human-selected handoff; after spec approval, downstream planning and implementation still chain automatically. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, a council assessment when available, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`), a bare worktree that already holds a spec, or a spec tracked on main under a configured spec dir (a spec seed: it creates or reuses the worktree through `using-git-worktrees` and pins the spec), restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief-file> | <spec>.md | <worktree-name-or-path>]`.
81
81
  - **9 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `plan-writer`, `spec-council-member`, `spec-council-synthesizer`, `spec-council-applier`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
82
82
  - **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite, and that every test command carries a passing base-commit result; a pass stamps the plan for implementation. `telemetry` records one YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - it is written only inside a brainstorming-entered flow, kept out of history until `finishing-a-development-branch` seals it once with `gauntlet-telemetry-seal` (a `telemetry:` commit under git; the working-copy snapshot in a plain jj workspace) - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
83
83
 
@@ -307,6 +307,7 @@ The gauntlet overrides file is the one place a repo tunes gauntlet's Markdown su
307
307
  | `## verification-before-completion` | verification-before-completion; gatekeep-pr (verification command only); chase-bug (hotfix verification) | repo test entrypoint discovery | overrides | the canonical verification target |
308
308
  | `## Spec index` `- docs: <glob>` | `gauntlet-spec-index` bin | `**/doc/**/*.md`, `**/docs/**/*.md`, `README.md`, `AGENTS.md` | overrides | the docs corpus include list; quotes/backticks stripped, fixed exclusions still apply, no other keys read |
309
309
  | `## Issue tracker` keys | shape-ticket, linear, chase-bug, check-delivery, brainstorming (`repo:` and tracker selection) | capability ladder / `git remote` | overrides | which tracker, repo, and commands ticket work uses |
310
+ | `## shape-ticket` `- sibling: <path relative to the repo root>` | shape-ticket | single-repo audit | extends | sibling checkouts the repo audit reads for a second observable leg already stated in the ticket |
310
311
  | `## Capabilities` | shape-ticket | tool discovery | extends | tools, commands, env hosts available to ticket authoring |
311
312
  | `## Ticket wording` | shape-ticket | the shipped wording contract | overrides | ticket self-containment rules |
312
313
  | `## Response channels` | chase-bug | ask the user | overrides | where bug responses are posted |
@@ -326,7 +327,7 @@ The gauntlet overrides file is the one place a repo tunes gauntlet's Markdown su
326
327
  - chase-bug requires evidenced root cause, no triage fix, and tracked-clean baseline checks at invocation, before the menu, at hotfix handoff, and at skill end; never revert pre-existing work - fixed
327
328
  - check-delivery requires exactly one explicit ticket ref, a matching repo (or matching local-repo AC group), and treats fetched content as untrusted data - fixed
328
329
  - roasting-the-spec edits only the spec, never code or main - fixed
329
- - shape-ticket tracker writes require one explicit exact-diff confirmation for the entire batch; unattended runs stop at the gate - fixed
330
+ - shape-ticket tracker writes require one explicit exact-diff confirmation per ticket write; a source-ticket split's N bodies share one gate with subset selection, not approval of a whole repair batch; unattended runs stop at the gate - fixed
330
331
  - test-driven-development requires a failing test before production code; delete code written first and restart from tests - fixed
331
332
  - verification-before-completion requires fresh verification evidence before completion claims - fixed
332
333
  - spec directories - settings: `flowGuards.specDirs`
@@ -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
+ }