@fyeeme/pi-review 1.0.2 → 1.1.1

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/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  Review & cleanup extension for [pi](https://github.com/earendil-works/pi).
4
4
 
5
- Registers two commands (`/code-review` and `/code-simplify`) and a general-purpose
6
- `subagent` tool that spawns parallel pi subprocesses — providing the **real
5
+ Registers two commands (`/code-review` and `/code-simplify`), a general-purpose
6
+ `subagent` tool that spawns parallel pi subprocesses, and the
7
+ `simplify_fanout` dispatch-gate tool — providing the **real
7
8
  fan-out capability** that the `code-review` and `simplify` skills
8
9
  (bundled in this package under `skills/`) need for their multi-agent flows.
9
10
 
@@ -54,11 +55,10 @@ and follow it — using the `subagent` tool for any fan-out / verify / gap-hunt.
54
55
 
55
56
  Cleanup (reuse / simplification / efficiency / altitude) via the `simplify`
56
57
  skill. **The handler decides parallel vs single-pass mode deterministically**
57
- from `ctx.getContextUsage()` (real token count) + whether the `subagent` tool is
58
- registered — mirroring CC's `Jvo` guard:
58
+ from `ctx.getContextUsage()` (real token count), diff size, and whether
59
+ fan-out tools are registered for this process:
59
60
 
60
- - context < 80% full AND `subagent` tool available → **parallel** (4 cleanup
61
- agents via `subagent` mode: parallel)
61
+ - context < 80% full AND diff < 400K chars AND fan-out allowed → **parallel**
62
62
  - otherwise → **single-pass** (inline 4 angles)
63
63
 
64
64
  This is the deterministic mode selection a pure-prompt skill cannot reproduce
@@ -66,6 +66,20 @@ This is the deterministic mode selection a pure-prompt skill cannot reproduce
66
66
  `ctx.getContextUsage()`). The decision is announced in the trigger message so
67
67
  it is observable.
68
68
 
69
+ **CC-parity opening (visible Phase 0, tool-gated fan-out).** Both modes open
70
+ the way Claude Code's `/simplify` does — the session never "rushes" into
71
+ agents. The trigger message carries the handler-resolved scope, a
72
+ changed-file index, and the exact `git -C … diff …` command; the model runs
73
+ that command visibly, reads the diff, and writes a 2–4 line change-intent
74
+ summary BEFORE anything launches. In PARALLEL mode the fan-out is dispatched
75
+ by the model calling the **`simplify_fanout`** tool (the counterpart of CC's
76
+ Agent-tool call): the tool re-resolves the diff itself (never trusting
77
+ model-passed diff text), re-checks the fan-out guards with fresh context
78
+ usage (the model just read the whole diff), embeds the diff in each of the 4
79
+ angle tasks, and spawns real pi subprocesses (`maxTurns` 15, read-only tool
80
+ whitelist). The findings come back as that tool's result — same turn, ready
81
+ for Phase 2.
82
+
69
83
  **Apply → verify → revert safety net** (harden-code-simplify): after Phase 2
70
84
  applies the cleanups, the handler also injects a verification command detected
71
85
  from `package.json` scripts (`check` → `test` → `lint` → `typecheck`). The
package/index.ts CHANGED
@@ -4,13 +4,20 @@
4
4
  * Registers:
5
5
  * - the `subagent` tool — general-purpose parallel/sequential sub-agent fan-out
6
6
  * via real pi subprocesses. Shared capability used by both skills below;
7
+ * - the `simplify_fanout` tool — /code-simplify PARALLEL mode's dispatch
8
+ * gate: the model calls it after its visible Phase 0 (read the diff, write
9
+ * the change-intent summary); the tool re-resolves the diff and spawns the
10
+ * 4 cleanup agents (same recursion guard as `subagent`);
7
11
  * - the `review_report` tool — structured findings sink for the code-review
8
12
  * skill (Pi's counterpart to CC's ReportFindings): renders the Markdown
9
13
  * report + writes JSON to <cwd>/.pi/review/ for CI;
10
14
  * - the `/code-review` command — effort-level review via the code-review skill;
11
15
  * - the `/code-simplify` command — cleanup via the simplify skill; the handler
12
- * decides parallel vs single-pass from ctx.getContextUsage(), mirroring CC's
13
- * Jvo guard (a deterministic decision a pure-prompt skill cannot reproduce).
16
+ * resolves the widened diff scope first (upstream merge-base → HEAD →
17
+ * staged/unstaged), then decides parallel vs single-pass from
18
+ * ctx.getContextUsage(), diff size, and fan-out availability. Both modes
19
+ * open with a visible, model-run Phase 0 (CC parity) — only PARALLEL
20
+ * dispatches agents, via the tool above.
14
21
  *
15
22
  * Both skills ship bundled in this package under `skills/` — this extension
16
23
  * provides the entry commands + the fan-out capability they need.
@@ -18,22 +25,25 @@
18
25
  * Layout (layered so the tool layer can be split into its own extension later):
19
26
  * src/tools/subagent.ts — generic capability (subagent tool; dispatch from pi-subagent-core)
20
27
  * src/tools/review_report.ts — structured findings sink (review_report tool; CC ReportFindings counterpart)
21
- * src/commands/*.ts — per-skill entry commands
28
+ * src/commands/*.ts — per-skill entry commands (code-simplify.ts also owns the simplify_fanout tool)
22
29
  */
23
30
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
31
  import { isFanoutToolAllowed } from "@fyeeme/pi-subagent-core";
25
32
  import { registerCodeReview } from "./src/commands/code-review.ts";
26
- import { registerSimplify } from "./src/commands/code-simplify.ts";
33
+ import { registerSimplify, simplifyFanoutTool } from "./src/commands/code-simplify.ts";
27
34
  import { subagentTool } from "./src/tools/subagent.ts";
28
35
  import { reviewReportTool } from "./src/tools/review_report.ts";
29
36
 
30
37
  export default function (pi: ExtensionAPI): void {
31
- // The fan-out tool registers only when recursion is allowed for THIS
38
+ // The fan-out tools register only when recursion is allowed for THIS
32
39
  // process (top-level, or a child the spawner explicitly opted in AND that is
33
40
  // below the max-depth cap). A default child — spawned without the fan-out
34
- // tool in its whitelist — loads without it, so it physically cannot recurse.
41
+ // tool in its whitelist — loads without them, so it physically cannot recurse.
35
42
  // This is the whitelist-by-default recursion guard (harden-code-simplify).
36
- if (isFanoutToolAllowed()) pi.registerTool(subagentTool);
43
+ if (isFanoutToolAllowed()) {
44
+ pi.registerTool(subagentTool);
45
+ pi.registerTool(simplifyFanoutTool);
46
+ }
37
47
  pi.registerTool(reviewReportTool);
38
48
  registerCodeReview(pi);
39
49
  registerSimplify(pi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-review",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Review & cleanup extension for pi. Registers /code-review and /code-simplify commands plus a general-purpose `subagent` tool that spawns parallel pi subprocesses — providing the real fan-out capability the code-review and simplify skills (bundled under `skills/`) need for their multi-agent flows. The /code-simplify handler uses ctx.getContextUsage() to decide parallel vs single-pass mode deterministically.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,8 @@
27
27
  ],
28
28
  "pi": {
29
29
  "extensions": [
30
- "./index.ts"
30
+ "./index.ts",
31
+ "./node_modules/@fyeeme/pi-subagent-core/sub-agent.ts"
31
32
  ]
32
33
  },
33
34
  "scripts": {
@@ -35,7 +36,7 @@
35
36
  "typecheck": "tsc"
36
37
  },
37
38
  "dependencies": {
38
- "@fyeeme/pi-subagent-core": "^0.3.3"
39
+ "@fyeeme/pi-subagent-core": "^0.5.0"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "@earendil-works/pi-ai": ">=0.84.1",
@@ -188,6 +188,27 @@ finder agents in a single batch (mode: parallel) so they run concurrently;
188
188
  otherwise do not fake the fan-out — work the angles yourself in sequence in
189
189
  this same context, or report that the subagent capability is unavailable.
190
190
 
191
+ **Finder turn budget(Pi adaptation — the same runaway-exploration guard the
192
+ Phase 3 gap-hunt already carries)** — a finder that exhausts its turn cap
193
+ mid-read returns NOTHING and silently loses its whole angle (observed on a
194
+ 168-file diff: 7/10 finders burned their full turn budget with zero output,
195
+ and the coverage hole cascaded into two extra compensation waves). Constrain
196
+ every finder batch:
197
+
198
+ 1. **Set `maxTurns: 20` on the `subagent` call** — the slowest finder pins
199
+ the wave's wall time; 20 turns covers the highest-risk hunks of any
200
+ single angle, and a capped finder still owes partial output (next item).
201
+ 2. **Declare the budget inside each finder prompt** — e.g. "You have ~15
202
+ tool calls. Spend them on the highest-risk hunks first; when half are
203
+ spent, stop opening new files."
204
+ 3. **Final-message contract** — the finder's LAST assistant message must be
205
+ its JSON candidate array (an empty `[]` is a valid answer). Partial
206
+ output beats none: candidates that never reach text never reach verify.
207
+ 4. **A finder that hits max-turns with no JSON is a FAILED finder**, not an
208
+ empty angle: re-dispatch that single angle on a narrower file slice
209
+ before Phase 2 (or fold it into the xhigh/max gap-hunt), and note the
210
+ re-dispatch in the report.
211
+
191
212
  **Finder allocation** (CC inline, verified 2.1.227): the number of correctness
192
213
  angles comes from the effort quad tuple, taken **in order A→E** (`slice(0, N)`
193
214
  — do not hand-pick angles; that makes runs unreproducible):
@@ -50,18 +50,36 @@ description: "Review the changed code for reuse, simplification, efficiency, and
50
50
  agent depth >= CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH (default 3); (b) the
51
51
  Agent tool must be in the allowlist. On Pi: (a) is N/A — the `subagent`
52
52
  tool spawns a fresh subprocess (always depth 0), so depth never accumulates
53
- — so decideSimplifyMode substitutes a context-fraction heuristic
54
- (tokens/contextWindow >= 0.8 → single-pass), a Pi addition NOT a mirror of
55
- Dii; (b) is mirrored as "the `subagent` tool must be registered". The
56
- decision is made DETERMINISTICALLY by the /code-simplify handler — it can
53
+ — so decideSimplifyMode substitutes three Pi-added guards: a context-fraction
54
+ heuristic (tokens/contextWindow >= 0.8 → single-pass), a diff-size
55
+ guard (diff >= 400K chars → single-pass; the 4-copy fan-out would burn
56
+ ~400K input tokens on prompt text alone), and fan-out availability
57
+ (the fan-out tools must be registered for this process — the Pi
58
+ counterpart of Dii's allowlist clause) — Pi additions NOT mirrors of
59
+ Dii. The cleanup agents' tool whitelist (read/grep/find/ls/bash) never
60
+ includes a fan-out tool, so recursion stays physically bounded
61
+ regardless of tool registration. The decision is made
62
+ DETERMINISTICALLY by the /code-simplify handler — it can
57
63
  read ctx.getContextUsage(), which a pure-prompt skill cannot — and announced
58
64
  in the trigger message; this skill just provides the two mode bodies.
59
65
  3. Command — CC: /simplify; Pi: /code-simplify.
60
-
61
- Prerequisite: the `subagent` tool (provided by the pi-review extension) for
62
- PARALLEL MODE, and the `review_report` tool (same extension) for
63
- the Phase 2 structured outcome report. SINGLE-PASS MODE runs
64
- standalone apart from `review_report`.
66
+ 4. Dispatch — CC's lead model writes the 4 Agent prompts itself after its
67
+ visible Phase 0. Pi keeps the same TIMELINE but moves the packaging into
68
+ code: the trigger message carries the handler-resolved scope, the
69
+ changed-file index, and the exact `git -C … diff …` command; the model
70
+ runs it, reads the diff, writes a change-intent summary, and THEN calls
71
+ the `simplify_fanout` tool (the counterpart of CC's Agent call). The
72
+ tool re-resolves the diff itself (never trusting model-passed diff
73
+ text — CC's own transcripts show its model skipping the diff inlining),
74
+ embeds it in each task, and spawns the agents; its result carries the
75
+ findings back into the same turn for Phase 2.
76
+
77
+ Prerequisite: the `review_report` tool (provided by the pi-review extension)
78
+ for the Phase 2 structured outcome report. PARALLEL MODE
79
+ additionally needs the `simplify_fanout` tool (same extension;
80
+ registered whenever fan-out is allowed for this process — the
81
+ recursion guard; the command only picks PARALLEL when it is).
82
+ SINGLE-PASS MODE runs standalone apart from `review_report`.
65
83
  -->
66
84
 
67
85
  You are improving the quality of the changed code, not hunting for bugs. Review
@@ -71,29 +89,53 @@ find. Do not look for correctness bugs — that is what `/code-review` is for.
71
89
  The `/code-simplify` handler has already chosen the mode (PARALLEL or
72
90
  SINGLE-PASS) from real context usage and announced it in the trigger message.
73
91
  Follow the body that matches; do not fake the mode you weren't asked to run.
92
+ Both modes open the same way: the trigger message carries the handler-resolved
93
+ scope, a changed-file index, and the exact git command — Phase 0 below is a
94
+ VISIBLE, model-run step before anything launches.
74
95
 
75
96
  ## Phase 0 — Gather the diff
76
97
 
77
- Run `git diff @{upstream}...HEAD` (or `git diff main...HEAD` / `git diff HEAD~1`
98
+ When the trigger message carries a handler-resolved scope (it always does for
99
+ /code-simplify), use THAT: run the exact `git -C … diff …` command the trigger
100
+ provides — the handler already ran the cascade (merge-base → HEAD → staged →
101
+ unstaged) to pick it — read the full diff, and write a 2–4 line change-intent
102
+ summary before anything else. Do not re-derive a different range. That summary
103
+ and your first-hand reading are what you will use to merge, dedup, and judge
104
+ findings in Phase 2.
105
+
106
+ (No trigger scope — e.g. the skill invoked standalone? Then: run
107
+ `git diff @{upstream}...HEAD` (or `git diff main...HEAD` / `git diff HEAD~1`
78
108
  if there's no upstream) to get the unified diff under review. If there are
79
109
  uncommitted changes, or the range diff is empty, also run `git diff HEAD` and
80
110
  include the working-tree changes in scope — the review often runs before the
81
111
  commit. If a PR number, branch name, or file path was passed as an argument,
82
- review that target instead. Treat this diff as the review scope.
112
+ review that target instead. Treat this diff as the review scope.)
83
113
 
84
114
  ---
85
115
 
86
- # PARALLEL MODE (subagent tool available AND context not near-full)
116
+ # PARALLEL MODE (context not near-full AND diff under the fan-out threshold AND fan-out available)
87
117
 
88
- `/code-simplify → 4 cleanup agents in parallel → apply the fixes`
118
+ `/code-simplify → visible Phase 0 (read the diff, summarize) → simplify_fanout tool → 4 cleanup agents → apply the fixes`
89
119
 
90
120
  ## Phase 1 — Review (4 cleanup agents in parallel)
91
121
 
92
- Launch **4 independent review agents** via the subagent tool, all in a
93
- single message so they run concurrently. Pass each agent the diff and one of
94
- the four angles below. Each returns its findings with `file`, `line`, a
95
- one-line `summary`, and the concrete cost (what is duplicated, wasted, or
96
- harder to maintain).
122
+ After your Phase 0 summary, call the `simplify_fanout` tool exactly as the
123
+ trigger message instructs (passing the target through verbatim when one was
124
+ given). The tool re-resolves the same diff deterministically, embeds it in
125
+ each agent's task together with the zero-token context package, and dispatches
126
+ 4 independent pi subprocesses — one per angle below — with `maxTurns: 15` and
127
+ a read-only tool whitelist (read/grep/find/ls/bash). Each returns its findings
128
+ with `file`, `line`, a one-line `summary`, and the concrete cost (what is
129
+ duplicated, wasted, or harder to maintain). The agent rows appear live in the
130
+ agent widget / FleetView and respect the `maxConcurrency` setting.
131
+
132
+ Do NOT write the four agent prompts yourself, inline the diff into any
133
+ prompt, or call the generic `subagent` tool for this — `simplify_fanout` owns
134
+ the packaging. If the tool reports that fan-out conditions no longer hold
135
+ (context grew while you read the diff), fall back to the SINGLE-PASS body
136
+ below and report `fanned_out: false`. When the tool result arrives, merge and
137
+ deduplicate the findings against your first-hand Phase 0 reading. The four
138
+ angles below are what the agents were asked to find.
97
139
 
98
140
  ### Reuse
99
141
 
@@ -125,17 +167,20 @@ isn't deep enough — prefer generalizing the underlying mechanism over adding
125
167
  special cases.
126
168
  ## Phase 2 — Apply, verify, and report
127
169
 
128
- Follow the shared **Phase 2** procedure at the end of this skill (snapshot → apply → verify → auto-revert on failure → report via `review_report`). The parallel fan-out only changes how findings are gathered (Phase 1); applying, verifying, and reporting are identical across modes. Set `fanned_out: true` in the report since the 4-agent fan-out actually ran.
170
+ Follow the shared **Phase 2** procedure at the end of this skill (snapshot → apply → verify → auto-revert on failure → report via `review_report`). The parallel fan-out only changes how findings are gathered (Phase 1 — done by the handler); applying, verifying, and reporting are identical across modes. Set `fanned_out: true` in the report since the 4-agent fan-out actually ran.
129
171
 
130
172
  ---
131
173
 
132
- # SINGLE-PASS MODE (subagent tool unavailable OR context near-full)
174
+ # SINGLE-PASS MODE (context near-full OR diff too large OR fan-out unavailable)
133
175
 
134
- `/code-simplify → subagent tool unavailable → single-pass inline cleanup → apply the fixes`
176
+ `/code-simplify → handler decided single-pass (reasons in the trigger message) → inline cleanup → apply the fixes`
135
177
 
136
- The subagent tool isn't available in this context, so the usual
137
- 4-agent fan-out can't run. Work through all four angles below yourself, in
138
- this same context, in one pass — do not skip an angle for lack of fan-out.
178
+ The handler decided against the 4-agent fan-out (context near-full, diff too
179
+ large, fan-out unavailable, or usage unmeasurable — the exact reasons are in
180
+ the trigger message), so work through all four angles below yourself, in this
181
+ same context, in one pass — do not skip an angle for lack of fan-out. Phase 0
182
+ is the same visible opening: run the exact git command from the trigger
183
+ message, read the diff, write the change-intent summary.
139
184
 
140
185
  ## Phase 1 — Review (4 cleanup angles, single pass)
141
186
 
@@ -1,16 +1,363 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
2
5
  import * as fs from "node:fs";
3
6
  import * as path from "node:path";
7
+ import {
8
+ createSpawnRegistry,
9
+ isFanoutToolAllowed,
10
+ lastAssistantText,
11
+ mapWithConcurrencyLimit,
12
+ spawnAgent,
13
+ } from "@fyeeme/pi-subagent-core";
14
+ import { getMaxConcurrency } from "../concurrency.ts";
4
15
  import { bundledSkillPath } from "../skills.ts";
5
16
 
6
17
  /** Context fraction at which we fall back to single-pass — a Pi-specific heuristic (see decideSimplifyMode). */
7
18
  const CONTEXT_NEAR_FULL_THRESHOLD = 0.8;
8
19
 
20
+ /** Diff size (chars) at which we fall back to single-pass — a Pi-specific
21
+ * heuristic (see decideSimplifyMode). ~100K tokens per task copy: the 4-copy
22
+ * fan-out would spend ~400K input tokens on prompt text alone, and each
23
+ * agent's own window would be half-spent before it explores anything. */
24
+ export const DIFF_TOO_LARGE_CHARS = 400_000;
25
+
26
+ /** Soft cap on the changed-file list in the context package (see buildContextPackage). */
27
+ export const CONTEXT_PACKAGE_MAX_FILES = 200;
28
+
29
+ /** Turn budget for each of the 4 cleanup agents (mirrors code-review's gap-hunt cap). */
30
+ const SIMPLIFY_AGENT_MAX_TURNS = 15;
31
+
32
+ /** Tool whitelist for the cleanup agents — read-only exploration, no recursion. */
33
+ const SIMPLIFY_AGENT_TOOLS = ["read", "grep", "find", "ls", "bash"] as const;
34
+
35
+ /** Monotonic sequence for unique per-invocation fan-out callIds. */
36
+ let simplifyRunSeq = 0;
37
+
9
38
  export type SimplifyMode = "parallel" | "single-pass";
10
39
 
11
40
  /** Priority order for picking a verification command from package.json scripts. */
12
41
  const VERIFY_SCRIPT_PRIORITY = ["check", "test", "lint", "typecheck"] as const;
13
42
 
43
+ /** One cleanup angle: display name + prompt. The angle definitions mirror the
44
+ * simplify skill's four angles so the command and the skill stay in sync. */
45
+ export interface SimplifyAngle {
46
+ /** Row label in the agent UI (widget/FleetView). */
47
+ displayName: string;
48
+ /** Task opening line (also becomes the agent's row description). */
49
+ headline: string;
50
+ /** Full angle definition (the cleanup guidance the agent follows). */
51
+ definition: string;
52
+ }
53
+
54
+ export const SIMPLIFY_ANGLES: SimplifyAngle[] = [
55
+ {
56
+ displayName: "Reuse",
57
+ headline: "Review the changed code for reuse cleanup opportunities.",
58
+ definition:
59
+ "Flag new code that re-implements something the codebase already has — Grep shared/utility modules and files adjacent to the change, and name the existing helper to call instead.",
60
+ },
61
+ {
62
+ displayName: "Simplification",
63
+ headline: "Review the changed code for simplification opportunities.",
64
+ definition:
65
+ "Flag unnecessary complexity the diff adds: redundant or derivable state, copy-paste with slight variation, deep nesting, dead code left behind. Name the simpler form that does the same job.",
66
+ },
67
+ {
68
+ displayName: "Efficiency",
69
+ headline: "Review the changed code for efficiency opportunities.",
70
+ definition:
71
+ "Flag wasted work the diff introduces: redundant computation or repeated I/O, independent operations run sequentially, blocking work added to startup or hot paths. Also flag long-lived objects built from closures or captured environments — they keep the entire enclosing scope alive for the object's lifetime (a memory leak when that scope holds large values); prefer a class/struct that copies only the fields it needs. Name the cheaper alternative.",
72
+ },
73
+ {
74
+ displayName: "Altitude",
75
+ headline: "Review the changed code for altitude (right-depth) issues.",
76
+ definition:
77
+ "Check that each change is implemented at the right depth, not as a fragile bandaid. Special cases layered on shared infrastructure are a sign the fix isn't deep enough — prefer generalizing the underlying mechanism over adding special cases.",
78
+ },
79
+ ];
80
+
81
+ /**
82
+ * Build the 4 cleanup-agent task specs from the diff. Pure — unit-testable.
83
+ * Each spec carries a shared zero-token context package (repo root, scope
84
+ * label, changed-file index — gathered handler-side where it costs no
85
+ * parent-context tokens), its own angle prompt, and the diff; a shared
86
+ * output-shape instruction keeps the collected findings uniformly structured.
87
+ * The angle definition rides ONLY the systemPrompt (the agent's role) —
88
+ * repeating it in the task body would send every agent the same definition
89
+ * twice for zero information gain.
90
+ */
91
+ export function buildSimplifyTasks(
92
+ diff: string,
93
+ contextPackage: string,
94
+ ): { angle: SimplifyAngle; task: string; systemPrompt: string }[] {
95
+ const shape =
96
+ "Return your findings as a concise list. For each finding: `file:line` — one-line summary — the concrete cost (what is duplicated, wasted, or harder to maintain). Do not propose applying fixes; report only.";
97
+ return SIMPLIFY_ANGLES.map((angle) => ({
98
+ angle,
99
+ task: `${contextPackage}\n\n${angle.headline}\n\n${shape}\n\nDiff to review:\n\n${diff}`,
100
+ systemPrompt: angle.definition,
101
+ }));
102
+ }
103
+
104
+ /**
105
+ * Walk up from `from` to the nearest directory containing `.git` (a directory
106
+ * or a submodule pointer file). Returns that root or null.
107
+ */
108
+ export function findGitRoot(from: string): string | null {
109
+ let dir = path.resolve(from);
110
+ for (;;) {
111
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
112
+ const parent = path.dirname(dir);
113
+ if (parent === dir) return null;
114
+ dir = parent;
115
+ }
116
+ }
117
+
118
+ /** Normalize a /code-simplify target argument: trimmed, with an optional
119
+ * path-prefix `@` PRESERVED — a real directory may itself start with `@`
120
+ * (e.g. node_modules/@scope/pkg), so the resolver tries the literal path
121
+ * first and only falls back to the @-stripped form when it does not exist.
122
+ * Single source for the scope resolver and the trigger message's
123
+ * tool-invocation text so the two cannot diverge. */
124
+ function normalizeTarget(target: string | undefined): string {
125
+ return (target ?? "").trim();
126
+ }
127
+
128
+ /** Resolve the diff scope for a `/code-simplify` target. Pure — unit-testable.
129
+ *
130
+ * - target absent/unresolvable → the nearest git root of `cwd`, full diff.
131
+ * - target is a path → its nearest git root; the relative path inside that
132
+ * root is the diff scope. Crucially this covers git SUBMODULES: a target
133
+ * like `@packages/extensions/pi-review/` resolves to the submodule's own
134
+ * git root, so the real changes inside it (invisible to the parent repo's
135
+ * `git diff`) are reviewed instead of a dirty-submodule pointer.
136
+ * - target at the git root itself (e.g. the whole submodule) → full diff.
137
+ * Returns null when no git root exists.
138
+ */
139
+ export function resolveDiffScope(
140
+ cwd: string,
141
+ target: string | undefined,
142
+ ): { gitRoot: string; relPath: string | null } | null {
143
+ const raw = normalizeTarget(target);
144
+ // The `@`-prefix path convention (`@packages/extensions/pi-review/`): try
145
+ // the literal path FIRST (a real directory may itself start with `@`, e.g.
146
+ // node_modules/@scope/pkg) and only fall back to the @-stripped form.
147
+ let abs: string | null = null;
148
+ if (raw) {
149
+ for (const candidate of raw.startsWith("@") ? [raw, raw.slice(1)] : [raw]) {
150
+ const resolved = path.resolve(cwd, candidate);
151
+ if (fs.existsSync(resolved)) {
152
+ abs = resolved;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ // Unresolvable target — absent, or a non-path (branch / PR number) that
158
+ // doesn't exist on disk — keeps the whole-diff scope of cwd's git root.
159
+ if (abs == null) {
160
+ const gitRoot = findGitRoot(cwd);
161
+ return gitRoot ? { gitRoot, relPath: null } : null;
162
+ }
163
+ const gitRoot = findGitRoot(abs);
164
+ if (!gitRoot) return null;
165
+ const relPath = path.relative(gitRoot, abs);
166
+ return { gitRoot, relPath: relPath === "" || relPath === "." ? null : relPath };
167
+ }
168
+
169
+ /** Injectably run `git` (defaults to promisified execFile — array argv, no
170
+ * shell, and non-blocking: the handler is async, so git runs on the event
171
+ * loop instead of freezing the TUI for the whole diff duration). */
172
+ export type GitRunner = (args: string[], opts: { cwd: string }) => Promise<string>;
173
+
174
+ const execFileAsync = promisify(execFile);
175
+
176
+ const defaultGitRunner: GitRunner = async (args, opts) =>
177
+ (await execFileAsync("git", args, {
178
+ cwd: opts.cwd,
179
+ encoding: "utf8",
180
+ maxBuffer: 10 * 1024 * 1024,
181
+ })).stdout;
182
+
183
+ /** Which diff range produced the diff (drives scope reporting in prompts/messages). */
184
+ export type DiffScopeKind = "upstream" | "worktree" | "staged-fresh" | "unstaged-fresh";
185
+
186
+ /** Human label per scope kind — the single place the wording lives (the kind
187
+ * itself is already carried by the map key / the outcome's scopeKind). */
188
+ export const DIFF_SCOPES: Record<DiffScopeKind, string> = {
189
+ upstream: "unpushed commits + uncommitted changes (merge-base of @{upstream} → working tree)",
190
+ worktree: "uncommitted changes (HEAD → working tree)",
191
+ "staged-fresh": "staged changes (repo has no commits yet)",
192
+ "unstaged-fresh": "unstaged changes (repo has no commits yet)",
193
+ };
194
+
195
+ /**
196
+ * Result of resolving the /code-simplify diff. `ok` carries the diff plus the
197
+ * scope kind that produced it and `gitCommand` — a shell-ready command that
198
+ * reproduces the exact diff invocation (range + path limiter + git root), so
199
+ * the trigger message can have the model re-read the SAME diff visibly
200
+ * (CC-parity Phase 0) instead of re-deriving a different range; the failure
201
+ * kinds are distinguishable so the handler can report WHY nothing was
202
+ * reviewed instead of a blanket "no changes".
203
+ */
204
+ export type DiffOutcome =
205
+ | { kind: "ok"; diff: string; gitRoot: string; scopeKind: DiffScopeKind; gitCommand: string }
206
+ | { kind: "no-repo" }
207
+ | { kind: "empty" }
208
+ | { kind: "git-error"; message: string };
209
+
210
+ /**
211
+ * Resolve the diff for the `/code-simplify` scope (see resolveDiffScope),
212
+ * widening the previously unstaged-only view to the full "changed code":
213
+ *
214
+ * 1. upstream — `git diff <merge-base @{upstream} HEAD>`: everything since
215
+ * divergence from the tracked upstream (unpushed commits + staged +
216
+ * unstaged) in one range. Matches the simplify skill's Phase 0 scope
217
+ * (`@{upstream}...HEAD` plus `git diff HEAD`): two-dot from the merge-base
218
+ * to the working tree is that union as a single unified diff. Skipped when
219
+ * no upstream is configured.
220
+ * 2. worktree — `git diff HEAD`: all uncommitted (staged + unstaged).
221
+ * 3. staged-fresh / unstaged-fresh — repos with no commits yet (HEAD doesn't
222
+ * resolve): index vs empty tree, then worktree vs index.
223
+ *
224
+ * The first candidate that yields a non-empty diff wins. All empty → `empty`;
225
+ * every candidate erroring (broken repo, diff exceeding maxBuffer) →
226
+ * `git-error` carrying the last error message; no git root → `no-repo`.
227
+ */
228
+ export async function getRepoDiff(
229
+ cwd: string,
230
+ target: string | undefined,
231
+ run: GitRunner = defaultGitRunner,
232
+ ): Promise<DiffOutcome> {
233
+ const scope = resolveDiffScope(cwd, target);
234
+ if (!scope) return { kind: "no-repo" };
235
+ const { gitRoot, relPath } = scope;
236
+ const pathArgs: string[] = relPath ? ["--", relPath] : [];
237
+ /** argv of one diff invocation — the single construction shared by the
238
+ * executed call (diffAttempt) and the reproduction command (commandFor),
239
+ * so the command shown to the model cannot drift from what ran. */
240
+ const diffArgs = (range: string[]): string[] => ["diff", "--no-color", ...range, ...pathArgs];
241
+ /** Shell-ready reproduction of a diff invocation (JSON.stringify quotes each
242
+ * path — valid POSIX quoting that also escapes embedded quotes). Note the
243
+ * relPath is re-quoted here for the DISPLAY only; the executed call uses
244
+ * the raw argv (diffArgs). A shell interpreting the displayed command
245
+ * produces the same argv, so the two cannot drift. */
246
+ const commandFor = (range: string[]): string => {
247
+ // Only shell-unsafe relPaths get quoted — a plain path stays clean in
248
+ // the displayed command, a path with spaces/glob metachars is quoted
249
+ // (JSON.stringify = valid POSIX quoting) so Phase 0 reproduces it
250
+ // exactly.
251
+ const safeRelPath = (p: string): string => (/^[A-Za-z0-9_./-]+$/.test(p) ? p : JSON.stringify(p));
252
+ return [
253
+ "git",
254
+ "-C",
255
+ JSON.stringify(gitRoot),
256
+ "diff",
257
+ "--no-color",
258
+ ...range,
259
+ ...(relPath ? ["--", safeRelPath(relPath)] : []),
260
+ ].join(" ");
261
+ };
262
+
263
+ let lastError: string | undefined;
264
+ /** `recordError` false = a failure that is a legitimate fallback signal
265
+ * (git diff HEAD on a repo with no commits yet) — it must not be mistaken
266
+ * for a broken repo, or an empty fresh repo would report git-error
267
+ * instead of empty. */
268
+ const diffAttempt = async (range: string[], recordError = true): Promise<string | null> => {
269
+ try {
270
+ const out = (await run(diffArgs(range), { cwd: gitRoot })).trim();
271
+ return out || null;
272
+ } catch (err) {
273
+ if (recordError) lastError = err instanceof Error ? err.message : String(err);
274
+ return null;
275
+ }
276
+ };
277
+ const mergeBaseWithUpstream = async (): Promise<string | null> => {
278
+ try {
279
+ return (await run(["merge-base", "@{upstream}", "HEAD"], { cwd: gitRoot })).trim() || null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ };
284
+
285
+ // Candidate ladder in priority order — the first non-empty diff wins.
286
+ // `git diff HEAD` failing (recordError false) is the EXPECTED fresh-repo
287
+ // signal, not a broken repo; the later staged/unstaged candidates carry
288
+ // the real errors so a genuinely broken repo still surfaces git-error.
289
+ const candidates: { scopeKind: DiffScopeKind; range: string[]; recordError: boolean }[] = [];
290
+ const mb = await mergeBaseWithUpstream();
291
+ if (mb) candidates.push({ scopeKind: "upstream", range: [mb], recordError: true });
292
+ candidates.push(
293
+ { scopeKind: "worktree", range: ["HEAD"], recordError: false },
294
+ { scopeKind: "staged-fresh", range: ["--staged"], recordError: true },
295
+ { scopeKind: "unstaged-fresh", range: [], recordError: true },
296
+ );
297
+ for (const c of candidates) {
298
+ const out = await diffAttempt(c.range, c.recordError);
299
+ if (out) return { kind: "ok", diff: out, gitRoot, scopeKind: c.scopeKind, gitCommand: commandFor(c.range) };
300
+ }
301
+ return lastError ? { kind: "git-error", message: lastError } : { kind: "empty" };
302
+ }
303
+
304
+ /**
305
+ * Build the zero-token context package injected into every cleanup-agent task
306
+ * (and the single-pass trigger message): repo root, the resolved diff scope,
307
+ * and a changed-file index with add/remove line counts, parsed straight out of
308
+ * the diff — no extra git calls, no drift from the diff embedded below. This
309
+ * is the context the handler can gather for free (handler-side git/parsing
310
+ * costs no parent-context tokens), so each agent skips its own 1–3 exploration
311
+ * rounds of `git diff --stat` and goes straight to its angle's grep targets.
312
+ * Pure — unit-testable.
313
+ */
314
+ export function buildContextPackage(diff: string, gitRoot: string, scopeLabel: string): string {
315
+ const churn = new Map<string, { added: number; removed: number; binary: boolean }>();
316
+ let current: string | null = null;
317
+ /** git quotes path headers with non-ASCII/special chars (core.quotepath
318
+ * default true) — accept both the plain and the quoted "a/…" "b/…" forms. */
319
+ const FILE_HEADER = /^diff --git (?:a\/(.*) b\/(.*)|"a\/(.*)" "b\/(.*)")$/;
320
+ /** `--- a/…` / `+++ b/…` (or /dev/null, or quoted variants) are file
321
+ * headers, not content lines — but a CONTENT line may itself start with
322
+ * `+`/`-` (rendered `+++x`), so only the exact header prefixes skip. */
323
+ const HEADER_PREFIXES = [
324
+ "--- a/",
325
+ "--- /dev/null",
326
+ "+++ b/",
327
+ "+++ /dev/null",
328
+ '--- "a/',
329
+ '+++ "b/',
330
+ ];
331
+ for (const line of diff.split("\n")) {
332
+ const m = FILE_HEADER.exec(line);
333
+ if (m) {
334
+ current = m[2] ?? m[4]!;
335
+ if (!churn.has(current)) churn.set(current, { added: 0, removed: 0, binary: false });
336
+ continue;
337
+ }
338
+ if (current == null) continue;
339
+ const c = churn.get(current)!;
340
+ if (line.startsWith("Binary files")) c.binary = true;
341
+ else if (HEADER_PREFIXES.some((p) => line.startsWith(p))) continue;
342
+ else if (line.startsWith("+")) c.added++;
343
+ else if (line.startsWith("-")) c.removed++;
344
+ }
345
+
346
+ // Map iterates in insertion order — the keys ARE the first-seen file order.
347
+ const files = [...churn.keys()];
348
+ const lines: string[] = [`Repo root: ${gitRoot}`, `Diff scope: ${scopeLabel}`];
349
+ if (files.length > 0) {
350
+ lines.push("Changed files (added/removed lines):");
351
+ for (const f of files.slice(0, CONTEXT_PACKAGE_MAX_FILES)) {
352
+ const c = churn.get(f)!;
353
+ lines.push(` ${f}${c.binary ? " (binary)" : ` +${c.added} -${c.removed}`}`);
354
+ }
355
+ if (files.length > CONTEXT_PACKAGE_MAX_FILES)
356
+ lines.push(` … and ${files.length - CONTEXT_PACKAGE_MAX_FILES} more (see the diff below)`);
357
+ }
358
+ return lines.join("\n");
359
+ }
360
+
14
361
  /**
15
362
  * Pick the project verification command from a package.json `scripts` map, in
16
363
  * priority order (check → test → lint → typecheck). Pure — unit-testable.
@@ -38,63 +385,423 @@ function readScriptsAt(cwd: string): Record<string, string> | null {
38
385
  }
39
386
 
40
387
  /**
41
- * Decide simplify mode deterministically from real context usage + tool availability.
42
- * Pure function — unit-testable.
388
+ * Decide simplify mode deterministically from real context usage, diff size,
389
+ * and fan-out availability. Pure — unit-testable. Returns the mode plus the
390
+ * reasons that produced it (announced in the trigger message so the decision
391
+ * stays observable).
43
392
  *
44
393
  * CC parity note: CC's /simplify guard (Dii, verified in the 2.1.227 binary) is
45
394
  * a SPAWN-DEPTH recursion limit, NOT a context check — `ok(ctx.agentContext) >= wV()`
46
395
  * where ok() returns the agent's depth (main=0) and wV() returns
47
396
  * CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH (default 3). That is N/A on Pi: the
48
397
  * `subagent` tool spawns a fresh subprocess (depth 0), so depth never accumulates.
49
- * The context-fraction heuristic below is a Pi-specific substitute (don't fan out
50
- * when the parent's context is near-full), NOT a mirror of Dii. The other Dii clause
51
- * — the Agent tool must be in the allowlist — IS mirrored here as `hasSubagent`.
398
+ * The guards below are Pi-specific substitutes, NOT mirrors of Dii:
399
+ * - context fraction (don't fan out when the parent's context is near-full);
400
+ * - diff size (don't 4× a huge diff into task prompts — DIFF_TOO_LARGE_CHARS);
401
+ * - fan-out availability (the `simplify_fanout` tool must be registered for
402
+ * THIS process — isFanoutToolAllowed(); a default-spawned child has no
403
+ * fan-out tools, so PARALLEL is only offered where it can physically run).
404
+ * Dii's other clause (the Agent-equivalent tool must be in the allowlist) is
405
+ * the Pi counterpart of that last guard. The cleanup agents' tool whitelist
406
+ * (read/grep/find/ls/bash) never includes a fan-out tool, so recursion stays
407
+ * physically bounded regardless of tool registration.
52
408
  */
53
409
  export function decideSimplifyMode(opts: {
54
410
  tokens: number | null;
55
411
  contextWindow: number;
56
- hasSubagent: boolean;
57
- }): SimplifyMode {
58
- const { tokens, contextWindow, hasSubagent } = opts;
59
- // Conservative: if we can't measure context (tokens unknown / window 0) or the
60
- // subagent tool isn't registered, don't risk fan-out — go single-pass.
61
- if (tokens == null || contextWindow <= 0 || !hasSubagent) return "single-pass";
62
- const nearFull = tokens / contextWindow >= CONTEXT_NEAR_FULL_THRESHOLD;
63
- return nearFull ? "single-pass" : "parallel";
412
+ diffChars: number;
413
+ /** Whether fan-out tools are registered in this process (top-level session:
414
+ * yes; a default-spawned child: no — the recursion guard). PARALLEL mode is
415
+ * only offered when the fan-out can physically be launched. */
416
+ fanoutAvailable: boolean;
417
+ }): { mode: SimplifyMode; reasons: string[] } {
418
+ const { tokens, contextWindow, diffChars, fanoutAvailable } = opts;
419
+ const reasons: string[] = [];
420
+ // Conservative: if we can't measure context (tokens unknown / window 0),
421
+ // don't risk fan-out — go single-pass.
422
+ if (tokens == null || contextWindow <= 0) reasons.push("context usage unknown");
423
+ if (tokens != null && contextWindow > 0 && tokens / contextWindow >= CONTEXT_NEAR_FULL_THRESHOLD)
424
+ reasons.push(`context ${Math.round((tokens / contextWindow) * 100)}% full`);
425
+ if (diffChars >= DIFF_TOO_LARGE_CHARS)
426
+ reasons.push(`diff too large (${Math.round(diffChars / 1024)} KB ≥ fan-out threshold)`);
427
+ if (!fanoutAvailable) reasons.push("fan-out unavailable in this context (subagent recursion guard)");
428
+ return { mode: reasons.length > 0 ? "single-pass" : "parallel", reasons };
429
+ }
430
+
431
+ /** One fan-out agent's collected outcome (see runSimplifyFanoutSpecs). */
432
+ export interface FanoutResult {
433
+ angle: string;
434
+ text: string;
435
+ failed: boolean;
436
+ aborted: boolean;
437
+ exitCode: number;
438
+ errorMessage?: string;
439
+ }
440
+
441
+ /** Render the 4 fan-out results as the findings markdown handed to Phase 2.
442
+ * Pure — unit-testable. Aborted (user cancel / maxTurns budget) must not
443
+ * masquerade as a clean "(no findings)" review outcome — it is marked, keeping
444
+ * any partial findings the agent did write. */
445
+ export function formatFanoutResults(results: FanoutResult[]): string {
446
+ return results
447
+ .map((res) => {
448
+ let body: string;
449
+ if (!res.failed) body = res.text || "(no findings)";
450
+ else if (res.aborted)
451
+ body = res.text
452
+ ? `[agent aborted — partial findings]\n${res.text}`
453
+ : "[agent aborted — no findings]";
454
+ else body = res.text
455
+ ? `[agent failed: ${res.errorMessage ?? `exit ${res.exitCode}`} — partial findings]\n${res.text}`
456
+ : `[agent failed: ${res.errorMessage ?? `exit ${res.exitCode}`} — no findings]`;
457
+ return `### ${res.angle}\n${body}`;
458
+ })
459
+ .join("\n\n");
460
+ }
461
+
462
+ /** Build the "verify/apply" guidance line shown to the model after fan-out. */
463
+ function verifyLine(ctx: { cwd: string }): string {
464
+ const verifyCmd = detectVerifyCommand(readScriptsAt(ctx.cwd));
465
+ return verifyCmd
466
+ ? `Verification command: \`${verifyCmd}\` (detected from package.json scripts). After applying Phase 2 fixes, run it; on failure, follow the skill's auto-revert procedure — never leave the working tree verified-broken.`
467
+ : `No verification command detected in package.json (looked for check/test/lint/typecheck). Apply fixes and report outcomes, but state in the report that no verification was run (verification is opportunistic, never blocking).`;
468
+ }
469
+
470
+ /** The Phase 2 procedure as cited by the PARALLEL trigger message and the
471
+ * fan-out tool's result — one source so the two citations cannot drift. */
472
+ const PHASE2_PROCEDURE =
473
+ "snapshot → apply → verify → auto-revert on failure → report via review_report with `fanned_out: true`";
474
+
475
+ /** The verbatim-shared Phase 0 opening (context package + the exact
476
+ * reproduction command) — identical in both trigger builders, keeping the
477
+ * "same CC-parity opening" claim true by construction. */
478
+ function phase0Block(contextPackage: string, gitCommand: string): string {
479
+ return (
480
+ `${contextPackage}\n\n` +
481
+ `Run exactly this command (the handler already resolved the scope — do not re-derive a different range):\n\n` +
482
+ ` ${gitCommand}\n\n`
483
+ );
64
484
  }
65
485
 
66
486
  /**
67
- * Register the /code-simplify command. The handler decides parallel vs single-pass from
68
- * ctx.getContextUsage() (real token count) + subagent tool availability — this is the
69
- * deterministic Jvo guard that a pure-prompt skill cannot reproduce.
487
+ * Build the SINGLE-PASS trigger message. Pure — unit-testable. Phase 0 is a
488
+ * visible, model-run step: the exact git command the handler resolved plus a
489
+ * change-intent summary before the angles are worked — same CC-parity opening
490
+ * as PARALLEL mode, minus the fan-out.
491
+ */
492
+ export function buildSinglePassTrigger(opts: {
493
+ target: string;
494
+ scopeLabel: string;
495
+ gitCommand: string;
496
+ contextPackage: string;
497
+ reasons: string[];
498
+ tooLarge: boolean;
499
+ skill: string;
500
+ verify: string;
501
+ }): string {
502
+ return (
503
+ `Clean up the changed code now. Target: ${opts.target}.\n\n` +
504
+ `Handler decided SINGLE-PASS mode (${opts.reasons.join("; ")}). Scope: ${opts.scopeLabel}.\n\n` +
505
+ `## Phase 0 — read the diff first\n\n` +
506
+ phase0Block(opts.contextPackage, opts.gitCommand) +
507
+ `Read the full diff, then write a 2–4 line change-intent summary before reviewing.\n` +
508
+ (opts.tooLarge
509
+ ? `The diff is too large to read at once — work through it file-by-file from the changed-file list above.\n`
510
+ : "") +
511
+ `\nThen load ${opts.skill} via the read tool and follow its single-pass body. ` +
512
+ `Work the four angles inline — do not fake fan-out.` +
513
+ `\n${opts.verify}`
514
+ );
515
+ }
516
+
517
+ /**
518
+ * Build the PARALLEL trigger message. Pure — unit-testable. This is the
519
+ * CC-parity opening: the model gathers the diff VISIBLY first (run the exact
520
+ * git command the handler resolved, read it, write a change-intent summary)
521
+ * and only then dispatches via the `simplify_fanout` tool — nothing spawns
522
+ * until the model has read the diff, so the session never "rushes" into
523
+ * agents. The tool (not the model) re-resolves the diff and owns the task
524
+ * packaging; its result carries the findings for Phase 2.
525
+ */
526
+ export function buildParallelTrigger(opts: {
527
+ target: string;
528
+ scopeLabel: string;
529
+ gitCommand: string;
530
+ contextPackage: string;
531
+ pct: string;
532
+ /** How to invoke the fan-out tool, preformatted (with or without a target). */
533
+ toolInvocation: string;
534
+ skill: string;
535
+ verify: string;
536
+ }): string {
537
+ return (
538
+ `Clean up the changed code now. Target: ${opts.target}.\n\n` +
539
+ `Handler decided PARALLEL mode (context ${opts.pct} full; scope ${opts.scopeLabel}). ` +
540
+ `Follow the phases IN ORDER — do not launch anything before Phase 0 is done.\n\n` +
541
+ `## Phase 0 — read the diff (visible, before any agent launches)\n\n` +
542
+ phase0Block(opts.contextPackage, opts.gitCommand) +
543
+ `Read the full diff, then write a 2–4 line change-intent summary BEFORE launching anything — ` +
544
+ `that summary and your first-hand reading are what you will use to merge, dedup, and judge ` +
545
+ `the agents' findings in Phase 2.\n\n` +
546
+ `## Phase 1 — launch the 4 cleanup agents\n\n` +
547
+ `Call ${opts.toolInvocation}. It re-resolves this same diff deterministically, embeds it in each ` +
548
+ `agent's task, and dispatches ${SIMPLIFY_ANGLES.map((a) => a.displayName).join(" / ")} as real pi subprocesses ` +
549
+ `(maxTurns ${SIMPLIFY_AGENT_MAX_TURNS}, read-only tools; they appear live in the agent widget / FleetView). Do NOT write the ` +
550
+ `agent prompts yourself or inline the diff anywhere — the tool owns the packaging. Its result ` +
551
+ `carries the four findings reports.\n\n` +
552
+ `## Phase 2 — apply, verify, report\n\n` +
553
+ `When the tool result arrives, merge/dedup the findings against your Phase 0 reading, then ` +
554
+ `load ${opts.skill} via the read tool and follow its Phase 2 (${PHASE2_PROCEDURE} — the 4-agent fan-out ` +
555
+ `actually ran). Never apply changes the findings don't justify.` +
556
+ `\n${opts.verify}`
557
+ );
558
+ }
559
+
560
+ /** Parameters for the simplify_fanout tool. */
561
+ const SimplifyFanoutParams = Type.Object({
562
+ target: Type.Optional(
563
+ Type.String({
564
+ description:
565
+ "Diff target exactly as announced in the /code-simplify trigger message (pass through verbatim; file path or @-prefixed path). Omit when the trigger says whole-diff.",
566
+ }),
567
+ ),
568
+ });
569
+
570
+ /** Details streamed via onUpdate while the fan-out runs (progress counter). */
571
+ interface SimplifyFanoutDetails {
572
+ done?: number;
573
+ total?: number;
574
+ }
575
+
576
+ /**
577
+ * The `simplify_fanout` tool — PARALLEL mode's dispatch gate (CC-parity
578
+ * opening). The command's trigger message has the model gather the diff
579
+ * visibly first; this tool is the visible "launch" moment (the counterpart of
580
+ * CC's Agent-tool call). It re-resolves the diff ITSELF — never trusting
581
+ * model-passed diff text — and re-checks the fan-out guards with FRESH
582
+ * context usage (the model has just read the whole diff, which is exactly the
583
+ * growth the command-side check could not see) before spawning the 4 agents
584
+ * through the shared subagent core. Registered only when fan-out is allowed
585
+ * for this process (same recursion guard as the `subagent` tool).
586
+ */
587
+ export const simplifyFanoutTool = defineTool<typeof SimplifyFanoutParams, SimplifyFanoutDetails>({
588
+ name: "simplify_fanout",
589
+ label: "Simplify fan-out",
590
+ description:
591
+ "Launch the 4 cleanup review agents (Reuse / Simplification / Efficiency / Altitude) for /code-simplify PARALLEL mode. Call it only as instructed by the /code-simplify trigger message, AFTER reading the diff and writing the change-intent summary. It re-resolves the diff itself (pass `target` through verbatim from the trigger message; omit for whole-diff) — never send diff text. Returns the four agents' findings reports for Phase 2.",
592
+ promptSnippet:
593
+ "Launch the 4 /code-simplify cleanup agents (Reuse/Simplification/Efficiency/Altitude); returns their findings.",
594
+ parameters: SimplifyFanoutParams,
595
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
596
+ const outcome = await getRepoDiff(ctx.cwd, params.target?.trim() || undefined);
597
+ if (outcome.kind === "no-repo")
598
+ return {
599
+ content: [
600
+ {
601
+ type: "text" as const,
602
+ text: "simplify_fanout: cwd is not inside a git repo — nothing to clean up. Say so and stop.",
603
+ },
604
+ ],
605
+ details: {},
606
+ };
607
+ if (outcome.kind === "git-error")
608
+ return {
609
+ content: [
610
+ {
611
+ type: "text" as const,
612
+ text: `simplify_fanout: git failed — ${outcome.message}. Say so and stop (do not retry — the failure is persistent).`,
613
+ },
614
+ ],
615
+ details: {},
616
+ };
617
+ if (outcome.kind === "empty")
618
+ return {
619
+ content: [
620
+ {
621
+ type: "text" as const,
622
+ text: "simplify_fanout: no changes found (the tree changed since /code-simplify ran?) — nothing to clean up. Say so and stop.",
623
+ },
624
+ ],
625
+ details: {},
626
+ };
627
+
628
+ // Fresh-usage guard: redirect to single-pass when the fan-out conditions
629
+ // no longer hold. Soft guidance (not an error) — the skill's single-pass
630
+ // body is the documented fallback.
631
+ const usage = ctx.getContextUsage();
632
+ const { mode, reasons } = decideSimplifyMode({
633
+ tokens: usage?.tokens ?? null,
634
+ contextWindow: usage?.contextWindow ?? 0,
635
+ diffChars: outcome.diff.length,
636
+ fanoutAvailable: true, // the tool only registers when fan-out is allowed
637
+ });
638
+ if (mode === "single-pass")
639
+ return {
640
+ content: [
641
+ {
642
+ type: "text" as const,
643
+ text: `Fan-out conditions no longer hold since /code-simplify ran (${reasons.join("; ")}). Do NOT launch agents — load the simplify skill via the read tool and follow its SINGLE-PASS body instead; report with \`fanned_out: false\`.`,
644
+ },
645
+ ],
646
+ details: {},
647
+ };
648
+
649
+ const scopeLabel = DIFF_SCOPES[outcome.scopeKind];
650
+ const contextPackage = buildContextPackage(outcome.diff, outcome.gitRoot, scopeLabel);
651
+ const tasks = buildSimplifyTasks(outcome.diff, contextPackage);
652
+ const registry = createSpawnRegistry();
653
+ // Explicit ceiling via the subagent tool's shared resolver — the same
654
+ // PI_MAX_CONCURRENT_SUBAGENTS env → maxConcurrency setting precedence
655
+ // on every fan-out path in this package.
656
+ let done = 0;
657
+ // Unique per-invocation callId: a re-run while the previous fan-out is
658
+ // still alive must not re-register the same monitor callId (callStarted
659
+ // replaces; the old subprocess's late callEnded would mark the NEW call
660
+ // settled early).
661
+ const runToken = `${Date.now()}-${++simplifyRunSeq}`;
662
+ const results = await mapWithConcurrencyLimit(tasks, getMaxConcurrency(), async (spec) => {
663
+ // No --model: a bare ctx.model.id is ambiguous across providers
664
+ // ("glm-5.3" matches opencode-go/zai/zai-coding-cn) and would
665
+ // fail the subprocess. Omitting it matches the subagent tool's
666
+ // default — the child runs the configured default model.
667
+ const r = await spawnAgent(registry, {
668
+ callId: `simplify-${spec.angle.displayName}-${runToken}`,
669
+ task: spec.task,
670
+ systemPrompt: spec.systemPrompt,
671
+ maxTurns: SIMPLIFY_AGENT_MAX_TURNS,
672
+ tools: [...SIMPLIFY_AGENT_TOOLS],
673
+ displayName: spec.angle.displayName,
674
+ // The diff paths / context package "Repo root:" are relative to
675
+ // the RESOLVED git root (possibly a git submodule, or a root above
676
+ // the session cwd) — agents must explore from there, not from
677
+ // process.cwd(), or every read/grep of a diff path ENOENTs.
678
+ cwd: outcome.gitRoot,
679
+ signal,
680
+ });
681
+ done++;
682
+ onUpdate?.({
683
+ content: [{ type: "text" as const, text: `${done}/${tasks.length} cleanup agents finished` }],
684
+ details: { done, total: tasks.length },
685
+ });
686
+ return {
687
+ angle: spec.angle.displayName,
688
+ text: lastAssistantText(r.messages),
689
+ // An aborted agent must not masquerade as a clean review even when
690
+ // its process exited 0 (graceful SIGTERM handler).
691
+ failed: r.exitCode !== 0 || r.aborted,
692
+ aborted: r.aborted,
693
+ exitCode: r.exitCode,
694
+ errorMessage: r.errorMessage,
695
+ };
696
+ });
697
+
698
+ return {
699
+ content: [
700
+ {
701
+ type: "text" as const,
702
+ text: `All ${results.length} cleanup agents finished (scope: ${scopeLabel}).\n\n${formatFanoutResults(results)}\n\nProceed to Phase 2 per the trigger message: merge/dedup the findings, then load the simplify skill and follow its Phase 2 (${PHASE2_PROCEDURE}).`,
703
+ },
704
+ ],
705
+ details: { done, total: results.length },
706
+ };
707
+ },
708
+ });
709
+
710
+ /**
711
+ * Register the /code-simplify command.
712
+ *
713
+ * The handler resolves the diff FIRST (widened scope — see getRepoDiff), so the
714
+ * mode decision can factor in diff size and fan-out availability alongside
715
+ * ctx.getContextUsage(), and so an unresolvable/empty diff terminates before
716
+ * any parent-context tokens are spent in either mode. Both modes then open
717
+ * the SAME way (CC parity): the trigger message carries the handler-resolved
718
+ * scope, the changed-file index, and the exact git command, and makes the
719
+ * model run Phase 0 visibly — read the diff, write a change-intent summary —
720
+ * BEFORE anything launches. In PARALLEL mode the fan-out is TOOL-GATED: the
721
+ * model dispatches by calling `simplify_fanout` (registered by the extension
722
+ * entry), whose handler re-resolves the diff and spawns the 4 agents through
723
+ * the shared subagent core; the findings come back as that tool's result for
724
+ * Phase 2. SINGLE-PASS mode delegates the four angles to the model inline.
70
725
  */
71
726
  export function registerSimplify(pi: ExtensionAPI): void {
72
727
  pi.registerCommand("code-simplify", {
73
728
  description:
74
- "Clean up the changed code (reuse/simplification/efficiency/altitude) using the simplify skill. Mode (parallel 4-agent vs single-pass) is decided by the handler from real context usage. Usage: /code-simplify [<target>]",
729
+ "Clean up the changed code (reuse/simplification/efficiency/altitude) using the simplify skill. Mode (parallel 4-agent vs single-pass) is decided by the handler from real context usage, diff size, and fan-out availability; PARALLEL opens with a visible Phase 0 (read the diff, summarize) before the simplify_fanout tool launches the agents. Usage: /code-simplify [<target>]",
75
730
  async handler(args, ctx) {
76
- const usage = ctx.getContextUsage();
77
- const hasSubagent = pi.getAllTools().some((t) => t.name === "subagent");
78
- const mode = decideSimplifyMode({
79
- tokens: usage?.tokens ?? null,
80
- contextWindow: usage?.contextWindow ?? 0,
81
- hasSubagent,
82
- });
83
- const pct = usage && usage.percent != null ? `${Math.round(usage.percent)}%` : "?";
84
- const bodyLabel = mode === "parallel" ? "PARALLEL MODE" : "SINGLE-PASS MODE";
85
- const verifyCmd = detectVerifyCommand(readScriptsAt(ctx.cwd));
86
- const verifyLine = verifyCmd
87
- ? `Verification command: \`${verifyCmd}\` (detected from package.json scripts). After applying Phase 2 fixes, run it; on failure, follow the skill's auto-revert procedure — never leave the working tree verified-broken.`
88
- : `No verification command detected in package.json (looked for check/test/lint/typecheck). Apply fixes and report outcomes, but state in the report that no verification was run (verification is opportunistic, never blocking).`;
89
- pi.sendUserMessage(
90
- `Clean up the changed code now. Target: ${args || "(whole diff)"}.\n\n` +
91
- `Handler decided ${mode} mode (context ${pct} full, subagent ${hasSubagent ? "available" : "absent"}). ` +
92
- `Load ${bundledSkillPath("simplify/SKILL.md")} via the read tool and follow the ${bodyLabel} body. ` +
93
- (mode === "parallel"
94
- ? `Use the \`subagent\` tool (mode: parallel) for the 4-agent fan-out.`
95
- : `Work the four angles inline — do not fake fan-out.`) +
96
- `\n${verifyLine}`,
97
- );
731
+ try {
732
+ const outcome = await getRepoDiff(ctx.cwd, args?.trim() || undefined);
733
+ if (outcome.kind === "no-repo") {
734
+ ctx.ui.notify(
735
+ `/code-simplify: ${ctx.cwd} is not inside a git repo — nothing to clean up.`,
736
+ "warning",
737
+ );
738
+ return;
739
+ }
740
+ if (outcome.kind === "git-error") {
741
+ ctx.ui.notify(`/code-simplify: git failed — ${outcome.message}`, "error");
742
+ return;
743
+ }
744
+ if (outcome.kind === "empty") {
745
+ ctx.ui.notify(
746
+ `/code-simplify: no changes found (checked unpushed+uncommitted vs @{upstream}, uncommitted vs HEAD, staged, unstaged) — nothing to clean up.`,
747
+ "warning",
748
+ );
749
+ return;
750
+ }
751
+
752
+ const usage = ctx.getContextUsage();
753
+ const { mode, reasons } = decideSimplifyMode({
754
+ tokens: usage?.tokens ?? null,
755
+ contextWindow: usage?.contextWindow ?? 0,
756
+ diffChars: outcome.diff.length,
757
+ fanoutAvailable: isFanoutToolAllowed(),
758
+ });
759
+ const pct = usage && usage.percent != null ? `${Math.round(usage.percent)}%` : "?";
760
+ const target = args || "(whole diff)";
761
+ const skill = bundledSkillPath("simplify/SKILL.md");
762
+ const scopeLabel = DIFF_SCOPES[outcome.scopeKind];
763
+ const contextPackage = buildContextPackage(outcome.diff, outcome.gitRoot, scopeLabel);
764
+ // The raw target travels through to the tool invocation so the tool's
765
+ // own resolveDiffScope re-resolves the SAME scope (literal @-paths
766
+ // first).
767
+ const targetArg = normalizeTarget(args);
768
+
769
+ if (mode === "single-pass") {
770
+ pi.sendUserMessage(
771
+ buildSinglePassTrigger({
772
+ target,
773
+ scopeLabel,
774
+ gitCommand: outcome.gitCommand,
775
+ contextPackage,
776
+ reasons,
777
+ tooLarge: outcome.diff.length >= DIFF_TOO_LARGE_CHARS,
778
+ skill,
779
+ verify: verifyLine({ cwd: outcome.gitRoot }),
780
+ }),
781
+ );
782
+ return;
783
+ }
784
+
785
+ // PARALLEL: tool-gated fan-out. The trigger message makes the model
786
+ // gather the diff visibly first (Phase 0) and dispatch via the
787
+ // simplify_fanout tool — no agents spawn until that call happens.
788
+ pi.sendUserMessage(
789
+ buildParallelTrigger({
790
+ target,
791
+ scopeLabel,
792
+ gitCommand: outcome.gitCommand,
793
+ contextPackage,
794
+ pct,
795
+ toolInvocation: targetArg
796
+ ? `the \`simplify_fanout\` tool with \`target: "${targetArg}"\` (pass the target through verbatim)`
797
+ : "the `simplify_fanout` tool (no arguments)",
798
+ skill,
799
+ verify: verifyLine({ cwd: outcome.gitRoot }),
800
+ }),
801
+ );
802
+ } catch (err) {
803
+ ctx.ui.notify(`/code-simplify failed: ${err instanceof Error ? err.message : String(err)}`, "error");
804
+ }
98
805
  },
99
806
  });
100
807
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * concurrency.ts — the package-level fan-out ceiling policy.
3
+ *
4
+ * Lives at src root (not under tools/) so both layers use it without the
5
+ * commands layer reaching into the tool layer — the tool layer is meant to be
6
+ * splittable into its own extension later (see index.ts layout notes).
7
+ */
8
+ import { getEffectiveMaxConcurrency, parsePositiveInt } from "@fyeeme/pi-subagent-core";
9
+
10
+ /**
11
+ * Effective concurrency ceiling for parallel fan-out — shared by the
12
+ * `subagent` tool and /code-simplify's fan-out so every fan-out path in this
13
+ * package honors the same override. Precedence:
14
+ * 1. PI_MAX_CONCURRENT_SUBAGENTS env var (power-user escape hatch, parity
15
+ * with CC's CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS; unset/invalid ignored);
16
+ * 2. the shared package setting `maxConcurrency` from
17
+ * <agentDir>/pi-subagent.json (project layer overriding) — options
18
+ * 3/5/8/10, default 5 (see @fyeeme/pi-subagent-core settings).
19
+ * Read at call time so a changed env/file takes effect without a reload.
20
+ */
21
+ export function getMaxConcurrency(): number {
22
+ return parsePositiveInt(process.env.PI_MAX_CONCURRENT_SUBAGENTS) ?? getEffectiveMaxConcurrency();
23
+ }
@@ -20,27 +20,14 @@ import * as path from "node:path";
20
20
  import {
21
21
  abortAgent,
22
22
  createSpawnRegistry,
23
+ lastAssistantText,
23
24
  mapWithConcurrencyLimit,
24
- parsePositiveInt,
25
25
  spawnAgent,
26
26
  type AgentSpawnRegistry,
27
27
  type AgentSpawnOptions,
28
28
  type AgentSpawnResult,
29
29
  } from "@fyeeme/pi-subagent-core";
30
-
31
- /** Default concurrency ceiling when PI_MAX_CONCURRENT_SUBAGENTS is unset/invalid.
32
- * 20 = CC's CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS ?? 20 (2.1.227 binary empirical). */
33
- const DEFAULT_MAX_CONCURRENCY = 20;
34
-
35
- /**
36
- * Effective concurrency ceiling, configurable via PI_MAX_CONCURRENT_SUBAGENTS
37
- * (parity with CC's CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS). Unset, missing, or
38
- * non-positive/non-integer values fall back to the default. Read at call time
39
- * so a changed env takes effect without a reload.
40
- */
41
- function getMaxConcurrency(): number {
42
- return parsePositiveInt(process.env.PI_MAX_CONCURRENT_SUBAGENTS) ?? DEFAULT_MAX_CONCURRENCY;
43
- }
30
+ import { getMaxConcurrency } from "../concurrency.ts";
44
31
 
45
32
  /**
46
33
  * Default turn budget for a fan-out agent when the caller omits maxTurns. A
@@ -51,6 +38,13 @@ function getMaxConcurrency(): number {
51
38
  */
52
39
  const DEFAULT_FANOUT_MAX_TURNS = 50;
53
40
 
41
+ /** os.tmpdir() entries swept for stale transcript dirs. */
42
+ const TRANSCRIPT_DIR_PREFIX = "pi-cr-out-";
43
+ /** Transcript dirs older than this are removed on the next fan-out call.
44
+ * Recent transcripts must survive (the model reads them after the tool
45
+ * returns); anything older than a day is dead weight on disk. */
46
+ const TRANSCRIPT_RETENTION_MS = 24 * 60 * 60 * 1000;
47
+
54
48
  // Module-level registry so abortAgent can reach in-flight calls. callIds are
55
49
  // unique per tool call (toolCallId#index), so a single registry is safe.
56
50
  const registry: AgentSpawnRegistry = createSpawnRegistry();
@@ -73,7 +67,8 @@ const SubagentParams = Type.Object({
73
67
  tools: Type.Optional(Type.Array(Type.String(), { description: "Tool whitelist for the sub-agent. Omit for default tools." })),
74
68
  parallelism: Type.Optional(
75
69
  Type.Number({
76
- description: `Max concurrent agents in parallel mode (integer ≥ 1; default min(prompts.length, ceiling)). The ceiling is PI_MAX_CONCURRENT_SUBAGENTS (default ${DEFAULT_MAX_CONCURRENCY}).`,
70
+ description:
71
+ "Max concurrent agents in parallel mode (integer ≥ 1; default min(prompts.length, ceiling)). The ceiling is PI_MAX_CONCURRENT_SUBAGENTS, else the maxConcurrency setting in pi-subagent.json (options 3/5/8/10, default 5).",
77
72
  }),
78
73
  ),
79
74
  maxTurns: Type.Optional(
@@ -103,34 +98,11 @@ interface SubagentDetails {
103
98
  stats: { agents: number; turns: number; cost: number; aborted: number };
104
99
  }
105
100
 
106
- /** Pull the assistant text out of a spawn result's messages. Defensive about
107
- * the Message.content shape (string | content-block array). */
108
- function resultText(r: AgentSpawnResult): string {
109
- const texts: string[] = [];
110
- for (const m of r.messages) {
111
- if (m.role !== "assistant") continue;
112
- const content: unknown = (m as { content?: unknown }).content;
113
- if (typeof content === "string") {
114
- texts.push(content);
115
- continue;
116
- }
117
- if (Array.isArray(content)) {
118
- for (const block of content) {
119
- if (block && typeof block === "object" && "text" in block) {
120
- const text = (block as { text?: unknown }).text;
121
- if (typeof text === "string") texts.push(text);
122
- }
123
- }
124
- }
125
- }
126
- return texts.join("\n").trim();
127
- }
128
-
129
101
  /**
130
102
  * 提取单条消息的可读文本(string content 或 content block 数组)。
131
103
  *
132
104
  * 转录用:除 text 外也渲染 thinking 与 toolCall 块,否则转录会静默丢弃中间推理与
133
- * 工具调用——与“全量转录含 tool result”的声明不符。内联预览(resultText)仍只取
105
+ * 工具调用——与“全量转录含 tool result”的声明不符。内联预览(lastAssistantText)仍只取
134
106
  * assistant 的 text,保持简短。
135
107
  */
136
108
  function messageText(m: { role?: string; content?: unknown }): string {
@@ -192,6 +164,37 @@ async function writeTranscriptFile(tmpDir: string, callId: string, text: string)
192
164
  return filePath;
193
165
  }
194
166
 
167
+ /**
168
+ * Best-effort sweep of stale transcript dirs left by earlier tool calls
169
+ * (transcripts are intentionally kept past the call — the model may read them
170
+ * later — but without a retention pass they accumulate forever). Fire-and-
171
+ * forget: never blocks or fails the spawn; errors are swallowed.
172
+ */
173
+ async function sweepStaleTranscriptDirs(): Promise<void> {
174
+ try {
175
+ const tmp = os.tmpdir();
176
+ const entries = await fs.promises.readdir(tmp, { withFileTypes: true });
177
+ const now = Date.now();
178
+ await Promise.all(
179
+ entries
180
+ .filter((e) => e.isDirectory() && e.name.startsWith(TRANSCRIPT_DIR_PREFIX))
181
+ .map(async (e) => {
182
+ const dir = path.join(tmp, e.name);
183
+ try {
184
+ const st = await fs.promises.stat(dir);
185
+ if (now - st.mtimeMs > TRANSCRIPT_RETENTION_MS) {
186
+ await fs.promises.rm(dir, { recursive: true, force: true });
187
+ }
188
+ } catch {
189
+ /* per-dir errors are non-fatal */
190
+ }
191
+ }),
192
+ );
193
+ } catch {
194
+ /* tmpdir unreadable — skip hygiene, never fail the call */
195
+ }
196
+ }
197
+
195
198
  /**
196
199
  * 单个 agent 的展示预览:最终文本用 pi 内置截断(默认 50KB / 2000 行)呈现——
197
200
  * 正常体量输出直接完整内联;超限时保留截断内容并标注。无论是否超限,都附上
@@ -278,11 +281,13 @@ export const subagentTool = defineTool<typeof SubagentParams, SubagentDetails>({
278
281
  };
279
282
 
280
283
  // 本次 tool call 内所有 agent 共享一个临时目录(N 个 agent → 1 个目录),
281
- // 取代每个 agent 各自 mkdtemp。转录需保留供模型稍后 read,故此处不清理。
284
+ // 取代每个 agent 各自 mkdtemp。转录需保留供模型稍后 read,调用结束后不删除;
285
+ // 陈旧目录(>24h)由下一次 fan-out 惰性清扫。
282
286
  let sharedTmpDir: string | null = null;
283
287
  const getTmpDir = async (): Promise<string> => {
284
288
  if (!sharedTmpDir) {
285
- sharedTmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-cr-out-"));
289
+ void sweepStaleTranscriptDirs(); // disk hygiene, never blocks the spawn
290
+ sharedTmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), TRANSCRIPT_DIR_PREFIX));
286
291
  }
287
292
  return sharedTmpDir;
288
293
  };
@@ -296,7 +301,7 @@ export const subagentTool = defineTool<typeof SubagentParams, SubagentDetails>({
296
301
  const entry: SubagentEntry = {
297
302
  index,
298
303
  exitCode: r.exitCode,
299
- text: resultText(r),
304
+ text: lastAssistantText(r.messages),
300
305
  aborted: r.aborted,
301
306
  maxTurnsReached: r.maxTurnsReached,
302
307
  errorMessage: