@fyeeme/pi-review 1.0.3 → 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 +20 -6
- package/index.ts +17 -7
- package/package.json +4 -3
- package/skills/code-review/SKILL.md +21 -0
- package/skills/simplify/SKILL.md +69 -24
- package/src/commands/code-simplify.ts +747 -40
- package/src/concurrency.ts +23 -0
- package/src/tools/subagent.ts +48 -43
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`)
|
|
6
|
-
`subagent` tool that spawns parallel pi subprocesses
|
|
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)
|
|
58
|
-
|
|
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
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
|
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())
|
|
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.
|
|
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.
|
|
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):
|
package/skills/simplify/SKILL.md
CHANGED
|
@@ -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
|
|
54
|
-
(tokens/contextWindow >= 0.8 → single-pass), a
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 (
|
|
174
|
+
# SINGLE-PASS MODE (context near-full OR diff too large OR fan-out unavailable)
|
|
133
175
|
|
|
134
|
-
`/code-simplify →
|
|
176
|
+
`/code-simplify → handler decided single-pass (reasons in the trigger message) → inline cleanup → apply the fixes`
|
|
135
177
|
|
|
136
|
-
The
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
|
42
|
-
* Pure
|
|
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
|
|
50
|
-
* when the parent's context is near-full)
|
|
51
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
}
|
package/src/tools/subagent.ts
CHANGED
|
@@ -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:
|
|
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”的声明不符。内联预览(
|
|
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
|
-
|
|
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:
|
|
304
|
+
text: lastAssistantText(r.messages),
|
|
300
305
|
aborted: r.aborted,
|
|
301
306
|
maxTurnsReached: r.maxTurnsReached,
|
|
302
307
|
errorMessage:
|