@fyeeme/pi-review 2.0.0 → 2.0.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 +77 -132
- package/agents/cleaner-altitude.md +18 -0
- package/agents/cleaner-efficiency.md +19 -0
- package/agents/cleaner-reuse.md +16 -0
- package/agents/cleaner-simplification.md +16 -0
- package/agents/finder-conventions.md +23 -0
- package/agents/finder-cross-file.md +21 -0
- package/agents/finder-diff-scan.md +23 -0
- package/agents/finder-language-pitfall.md +21 -0
- package/agents/finder-removed-behavior.md +21 -0
- package/agents/finder-wrapper-proxy.md +23 -0
- package/agents/gap-hunter.md +24 -0
- package/agents/verifier.md +33 -0
- package/index.ts +34 -29
- package/package.json +17 -15
- package/prompts/review.md +23 -0
- package/prompts/simplify.parallel.md +45 -0
- package/prompts/simplify.single.md +23 -0
- package/skills/{code-review → review}/SKILL.md +134 -33
- package/skills/simplify/SKILL.md +98 -41
- package/src/config.ts +103 -0
- package/src/diff.ts +306 -0
- package/src/dispatch.ts +238 -0
- package/src/strategy.ts +76 -0
- package/src/tools/review_report.ts +17 -15
- package/src/commands/code-review.ts +0 -100
- package/src/commands/code-simplify.ts +0 -100
- package/src/tools/subagent.ts +0 -367
package/skills/simplify/SKILL.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: simplify
|
|
3
|
-
description: "Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /
|
|
3
|
+
description: "Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /review for that. v3 (from Claude Code CLI v2.1.227, symbol-level verified; re-verified against v2.1.261 on 2026-09-05 — bodies unchanged except Altitude) — 4 cleanup agents fan out in parallel when context allows, else a single-pass inline cleanup; either way the fixes are applied, verified against the project's check command, and auto-reverted on failure, then reported as structured outcomes via review_report."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
<!--
|
|
7
7
|
Origin: Claude Code built-in skill `/simplify` (CLI v2.1.227), reverse-
|
|
8
|
-
engineered from bin/claude.exe raw bytes. Pi registers it as /
|
|
8
|
+
engineered from bin/claude.exe raw bytes. Pi registers it as /simplify.
|
|
9
9
|
|
|
10
10
|
Lineage:
|
|
11
11
|
v2.1.220 → the first reconstruction (v1)
|
|
@@ -18,6 +18,16 @@ description: "Review the changed code for reuse, simplification, efficiency, and
|
|
|
18
18
|
VBv/KBv (with interpolated c$e / m7t / u$e / d$e / p$e) are
|
|
19
19
|
unchanged; the mode guard is Dii (see below); fan-out defaults
|
|
20
20
|
nJu=20 / lKs=50 are now mirrored in the subagent tool.
|
|
21
|
+
v2.1.261 → re-verified 2026-09-05 from raw bytes: the two mode bodies,
|
|
22
|
+
the 4-angle set, and the Phase 2 apply rules are unchanged;
|
|
23
|
+
the Altitude angle gained CC's root-cause phrasing + "name
|
|
24
|
+
that change" (synced here and in the review skill). The command
|
|
25
|
+
description ("Clean up the changed code without changing
|
|
26
|
+
behavior"; "Quality only — it does not hunt for bugs; use
|
|
27
|
+
/code-review for that") and the Agent-tool fan-out ("all in a
|
|
28
|
+
single message so they run concurrently") are unchanged.
|
|
29
|
+
The /code-review↔/simplify division of labor is now stated
|
|
30
|
+
explicitly in both skills upstream — same as here.
|
|
21
31
|
|
|
22
32
|
CC 2.1.227 empirical evidence (symbol-level, extracted from bin/claude.exe):
|
|
23
33
|
- $u({name: "simplify", ..., getPromptForCommand(args, ctx)}) registers the
|
|
@@ -31,14 +41,14 @@ description: "Review the changed code for reuse, simplification, efficiency, and
|
|
|
31
41
|
default 3, feature flag tengu_hazel_trellis) OR the Agent tool is not in
|
|
32
42
|
the options.tools allowlist (Pa matches by name/aliases).
|
|
33
43
|
- VBv / KBv — the two mode-body templates; interpolated variables shared
|
|
34
|
-
with
|
|
44
|
+
with the review skill: c$e (Phase 0), m7t/u$e/d$e/p$e (the 4 cleanup angles).
|
|
35
45
|
- nJu() = CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS ?? 20; lKs =
|
|
36
46
|
FORKED_AGENT_DEFAULT_MAX_TURNS = 50 — mirrored as the subagent tool's
|
|
37
47
|
defaults (PI_MAX_CONCURRENT_SUBAGENTS env still overrides the ceiling).
|
|
38
48
|
|
|
39
49
|
Bundled: ships inside the pi-review extension (skills/simplify/SKILL.md).
|
|
40
50
|
|
|
41
|
-
Invocation: /
|
|
51
|
+
Invocation: /simplify [<target>]
|
|
42
52
|
target = file path | PR number | branch name
|
|
43
53
|
|
|
44
54
|
════════════════════════════════════════════════════════════════════════
|
|
@@ -50,50 +60,92 @@ description: "Review the changed code for reuse, simplification, efficiency, and
|
|
|
50
60
|
agent depth >= CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH (default 3); (b) the
|
|
51
61
|
Agent tool must be in the allowlist. On Pi: (a) is N/A — the `subagent`
|
|
52
62
|
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
|
-
|
|
63
|
+
— so decideSimplifyMode substitutes three Pi-added guards: a context-fraction
|
|
64
|
+
heuristic (tokens/contextWindow >= 0.8 → single-pass), a diff-size
|
|
65
|
+
guard (diff >= 400K chars → single-pass; the 4-copy fan-out would burn
|
|
66
|
+
~400K input tokens on prompt text alone), and fan-out availability
|
|
67
|
+
(the fan-out tools must be registered for this process — the Pi
|
|
68
|
+
counterpart of Dii's allowlist clause) — Pi additions NOT mirrors of
|
|
69
|
+
Dii. The cleanup agents' tool whitelist (read/grep/find/ls/bash) never
|
|
70
|
+
includes a fan-out tool, so recursion stays physically bounded
|
|
71
|
+
regardless of tool registration. The decision is made
|
|
72
|
+
DETERMINISTICALLY by the /simplify handler — it can
|
|
57
73
|
read ctx.getContextUsage(), which a pure-prompt skill cannot — and announced
|
|
58
74
|
in the trigger message; this skill just provides the two mode bodies.
|
|
59
|
-
3. Command — CC: /simplify; Pi: /
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
3. Command — CC: /simplify; Pi: /simplify.
|
|
76
|
+
4. Dispatch — CC's lead model writes the 4 Agent prompts itself after its
|
|
77
|
+
visible Phase 0. Pi keeps the same TIMELINE but moves the packaging into
|
|
78
|
+
code: the trigger message carries the handler-resolved scope, the
|
|
79
|
+
changed-file index, and the exact `git -C … diff …` command; the model
|
|
80
|
+
runs it, reads the diff, writes a change-intent summary, and THEN calls
|
|
81
|
+
the `subagent` tool in parallel mode (the counterpart of CC's Agent
|
|
82
|
+
call) with the 4 bundled cleaner agents; the tool result carries the
|
|
83
|
+
findings back into the same turn for Phase 2.
|
|
84
|
+
|
|
85
|
+
Prerequisite: the `review_report` tool (provided by the pi-review extension)
|
|
86
|
+
for the Phase 2 structured outcome report. PARALLEL MODE
|
|
87
|
+
additionally needs the `subagent` tool (@fyeeme/pi-subagents;
|
|
88
|
+
registered whenever fan-out is allowed for this process — the
|
|
89
|
+
recursion guard; the dispatcher only picks PARALLEL when it
|
|
90
|
+
is) plus the bundled agents cleaner-reuse /
|
|
91
|
+
cleaner-simplification / cleaner-efficiency / cleaner-altitude.
|
|
92
|
+
SINGLE-PASS MODE runs standalone apart from `review_report`.
|
|
65
93
|
-->
|
|
66
94
|
|
|
67
95
|
You are improving the quality of the changed code, not hunting for bugs. Review
|
|
68
96
|
it for reuse, simplification, efficiency, and altitude issues, then fix what you
|
|
69
|
-
find. Do not look for correctness bugs — that is what `/
|
|
97
|
+
find. Do not look for correctness bugs — that is what `/review` is for.
|
|
70
98
|
|
|
71
|
-
The `/
|
|
99
|
+
The `/simplify` handler has already chosen the mode (PARALLEL or
|
|
72
100
|
SINGLE-PASS) from real context usage and announced it in the trigger message.
|
|
73
101
|
Follow the body that matches; do not fake the mode you weren't asked to run.
|
|
102
|
+
Both modes open the same way: the trigger message carries the handler-resolved
|
|
103
|
+
scope, a changed-file index, and the exact git command — Phase 0 below is a
|
|
104
|
+
VISIBLE, model-run step before anything launches.
|
|
74
105
|
|
|
75
106
|
## Phase 0 — Gather the diff
|
|
76
107
|
|
|
77
|
-
|
|
108
|
+
When the trigger message carries a handler-resolved scope (it always does for
|
|
109
|
+
/simplify), use THAT: run the exact `git -C … diff …` command the trigger
|
|
110
|
+
provides — the handler already ran the cascade (merge-base → HEAD → staged →
|
|
111
|
+
unstaged) to pick it — read the full diff, and write a 2–4 line change-intent
|
|
112
|
+
summary before anything else. Do not re-derive a different range. That summary
|
|
113
|
+
and your first-hand reading are what you will use to merge, dedup, and judge
|
|
114
|
+
findings in Phase 2.
|
|
115
|
+
|
|
116
|
+
(No trigger scope — e.g. the skill invoked standalone? Then: run
|
|
117
|
+
`git diff @{upstream}...HEAD` (or `git diff main...HEAD` / `git diff HEAD~1`
|
|
78
118
|
if there's no upstream) to get the unified diff under review. If there are
|
|
79
119
|
uncommitted changes, or the range diff is empty, also run `git diff HEAD` and
|
|
80
120
|
include the working-tree changes in scope — the review often runs before the
|
|
81
121
|
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.
|
|
122
|
+
review that target instead. Treat this diff as the review scope.)
|
|
83
123
|
|
|
84
124
|
---
|
|
85
125
|
|
|
86
|
-
# PARALLEL MODE (
|
|
126
|
+
# PARALLEL MODE (context not near-full AND diff under the fan-out threshold AND fan-out available)
|
|
87
127
|
|
|
88
|
-
`/
|
|
128
|
+
`/simplify → visible Phase 0 (read the diff, summarize) → subagent tool (parallel, 4 cleaner agents) → apply the fixes`
|
|
89
129
|
|
|
90
130
|
## Phase 1 — Review (4 cleanup agents in parallel)
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
After your Phase 0 summary, call the `subagent` tool exactly as the trigger
|
|
133
|
+
message instructs: parallel mode, 4 tasks, one per bundled agent —
|
|
134
|
+
cleaner-reuse, cleaner-simplification, cleaner-efficiency, cleaner-altitude —
|
|
135
|
+
each with `maxTurns: 15` (set it on the call; 15 is the built-in default — a
|
|
136
|
+
different budget stated in the trigger message wins). The agents' angle guidance
|
|
137
|
+
rides their own definitions (read-only tool whitelist read/grep/find/ls/bash);
|
|
138
|
+
each returns its findings with `file`, `line`, a one-line `summary`, and the
|
|
139
|
+
concrete cost (what is duplicated, wasted, or harder to maintain). The agent
|
|
140
|
+
rows appear live in the agent widget / FleetView and respect the
|
|
141
|
+
`maxConcurrency` setting.
|
|
142
|
+
|
|
143
|
+
Do NOT write the four agent prompts yourself or inline the diff into any
|
|
144
|
+
prompt. If the fan-out conditions no longer hold (context grew while you read
|
|
145
|
+
the diff), fall back to the SINGLE-PASS body below and report `fanned_out:
|
|
146
|
+
false`. When the tool result arrives, merge and deduplicate the findings
|
|
147
|
+
against your first-hand Phase 0 reading. The four angles below are what the
|
|
148
|
+
agents were asked to find.
|
|
97
149
|
|
|
98
150
|
### Reuse
|
|
99
151
|
|
|
@@ -119,23 +171,27 @@ alternative.
|
|
|
119
171
|
|
|
120
172
|
### Altitude
|
|
121
173
|
|
|
122
|
-
Check that each change
|
|
123
|
-
bandaid. Special cases layered on shared
|
|
124
|
-
isn't deep enough — prefer
|
|
125
|
-
special cases
|
|
174
|
+
Check that each change fixes the root cause at the right depth rather than
|
|
175
|
+
patching a symptom with a fragile bandaid. Special cases layered on shared
|
|
176
|
+
infrastructure are a sign the fix isn't deep enough — prefer the simpler,
|
|
177
|
+
more general change to the underlying mechanism over adding special cases,
|
|
178
|
+
and name that change.
|
|
126
179
|
## Phase 2 — Apply, verify, and report
|
|
127
180
|
|
|
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.
|
|
181
|
+
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
182
|
|
|
130
183
|
---
|
|
131
184
|
|
|
132
|
-
# SINGLE-PASS MODE (
|
|
185
|
+
# SINGLE-PASS MODE (context near-full OR diff too large OR fan-out unavailable)
|
|
133
186
|
|
|
134
|
-
`/
|
|
187
|
+
`/simplify → handler decided single-pass (reasons in the trigger message) → inline cleanup → apply the fixes`
|
|
135
188
|
|
|
136
|
-
The
|
|
137
|
-
|
|
138
|
-
|
|
189
|
+
The handler decided against the 4-agent fan-out (context near-full, diff too
|
|
190
|
+
large, fan-out unavailable, or usage unmeasurable — the exact reasons are in
|
|
191
|
+
the trigger message), so work through all four angles below yourself, in this
|
|
192
|
+
same context, in one pass — do not skip an angle for lack of fan-out. Phase 0
|
|
193
|
+
is the same visible opening: run the exact git command from the trigger
|
|
194
|
+
message, read the diff, write the change-intent summary.
|
|
139
195
|
|
|
140
196
|
## Phase 1 — Review (4 cleanup angles, single pass)
|
|
141
197
|
|
|
@@ -167,10 +223,11 @@ alternative.
|
|
|
167
223
|
|
|
168
224
|
### Altitude
|
|
169
225
|
|
|
170
|
-
Check that each change
|
|
171
|
-
bandaid. Special cases layered on shared
|
|
172
|
-
isn't deep enough — prefer
|
|
173
|
-
special cases
|
|
226
|
+
Check that each change fixes the root cause at the right depth rather than
|
|
227
|
+
patching a symptom with a fragile bandaid. Special cases layered on shared
|
|
228
|
+
infrastructure are a sign the fix isn't deep enough — prefer the simpler,
|
|
229
|
+
more general change to the underlying mechanism over adding special cases,
|
|
230
|
+
and name that change.
|
|
174
231
|
## Phase 2 — Apply, verify, and report
|
|
175
232
|
|
|
176
233
|
Follow the shared **Phase 2** procedure at the end of this skill (snapshot → apply → verify → auto-revert on failure → report via `review_report`). Single-pass vs parallel only changes how findings are gathered (Phase 1); applying, verifying, and reporting are identical across modes. Set `fanned_out: false` in the report so a reader is not misled into thinking the 4-agent fan-out ran.
|
|
@@ -180,7 +237,7 @@ Follow the shared **Phase 2** procedure at the end of this skill (snapshot → a
|
|
|
180
237
|
# Phase 2 — Apply, verify, and report (shared by both modes)
|
|
181
238
|
|
|
182
239
|
Dedup findings that point at the same line or mechanism first. Then apply,
|
|
183
|
-
verify, and report. This safety net is what distinguishes `/
|
|
240
|
+
verify, and report. This safety net is what distinguishes `/simplify` from
|
|
184
241
|
a blind cleanup: a finding is only "done" once it is applied AND the project
|
|
185
242
|
still verifies — otherwise it is reverted.
|
|
186
243
|
|
|
@@ -201,7 +258,7 @@ for any file in a subdirectory. If a fix CREATES a new file, record its path so
|
|
|
201
258
|
Step 3a can remove it on rollback (it has no baseline entry).
|
|
202
259
|
|
|
203
260
|
This baseline captures the working-tree state **including** the user's
|
|
204
|
-
uncommitted changes — reverting to it undoes only `/
|
|
261
|
+
uncommitted changes — reverting to it undoes only `/simplify`'s fixes,
|
|
205
262
|
never the user's diff. Do **not** use `git checkout` / `git restore` to revert:
|
|
206
263
|
that would discard the user's intended changes too.
|
|
207
264
|
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/config.ts — file-based turn-budget configuration, mirroring the
|
|
3
|
+
* pi-subagents `pi-subagent.json` pattern (read-only, lenient, two layers
|
|
4
|
+
* with project overriding global):
|
|
5
|
+
*
|
|
6
|
+
* - Global: <agentDir>/pi-review.json (user-wide defaults)
|
|
7
|
+
* - Project: <cwd>/.pi/pi-review.json (overrides global on load)
|
|
8
|
+
*
|
|
9
|
+
* Schema (all keys optional):
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "maxTurns": {
|
|
13
|
+
* "subagent": 20, // per-call budget for each /review finder batch
|
|
14
|
+
* "gapHunt": 15, // budget for the /review Phase 3 gap-hunter
|
|
15
|
+
* "simplify": 15 // budget for each /simplify PARALLEL cleaner agent
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The defaults here are the numbers the bundled prompts and skills were
|
|
20
|
+
* written with (finder 20 / gap-hunt 15 / simplify 15). With no config file
|
|
21
|
+
* — or with any key absent or invalid — the rendered instructions carry
|
|
22
|
+
* exactly those numbers, so absence of configuration changes nothing.
|
|
23
|
+
*
|
|
24
|
+
* Read at command time (like pi-subagents' maxConcurrency): an edited file
|
|
25
|
+
* takes effect on the next /review or /simplify without a restart. Malformed
|
|
26
|
+
* files are ignored with a stderr warning (never fatal); unknown/garbage
|
|
27
|
+
* fields are dropped on read.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
|
|
33
|
+
/** Settings file name (both layers). */
|
|
34
|
+
const CONFIG_FILE = "pi-review.json";
|
|
35
|
+
|
|
36
|
+
/** The four dispatchable turn budgets, keyed by what they throttle. */
|
|
37
|
+
export interface TurnBudgets {
|
|
38
|
+
/** `maxTurns` set on each /review finder-batch `subagent` call. */
|
|
39
|
+
subagent: number;
|
|
40
|
+
/** `maxTurns` set on each /review Phase 2 verifier `subagent` call. */
|
|
41
|
+
verifier: number;
|
|
42
|
+
/** `maxTurns` set on the /review Phase 3 gap-hunt `subagent` call. */
|
|
43
|
+
gapHunt: number;
|
|
44
|
+
/** `maxTurns` set on each /simplify PARALLEL cleaner `subagent` call. */
|
|
45
|
+
simplify: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Built-in budgets — identical to the literals in prompts/ and skills/. */
|
|
49
|
+
export const DEFAULT_TURN_BUDGETS: TurnBudgets = {
|
|
50
|
+
subagent: 20,
|
|
51
|
+
verifier: 15,
|
|
52
|
+
gapHunt: 15,
|
|
53
|
+
simplify: 15,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function globalPath(): string {
|
|
57
|
+
return join(getAgentDir(), CONFIG_FILE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function projectPath(cwd: string): string {
|
|
61
|
+
return join(cwd, ".pi", CONFIG_FILE);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Positive integers only; anything else (floats, 0, negatives, strings) is
|
|
65
|
+
* dropped so the built-in default applies. */
|
|
66
|
+
function sanitizeBudget(value: unknown): number | undefined {
|
|
67
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read one config file; missing file → {} (the normal case, silent).
|
|
71
|
+
* Unparseable file → warn + {}. Unknown fields are dropped. */
|
|
72
|
+
function readBudgetsFile(path: string): Partial<TurnBudgets> {
|
|
73
|
+
if (!existsSync(path)) return {};
|
|
74
|
+
try {
|
|
75
|
+
const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
76
|
+
if (!raw || typeof raw !== "object") return {};
|
|
77
|
+
const maxTurns = (raw as Record<string, unknown>).maxTurns;
|
|
78
|
+
const mt = maxTurns && typeof maxTurns === "object" ? (maxTurns as Record<string, unknown>) : {};
|
|
79
|
+
const out: Partial<TurnBudgets> = {};
|
|
80
|
+
const subagent = sanitizeBudget(mt.subagent);
|
|
81
|
+
if (subagent !== undefined) out.subagent = subagent;
|
|
82
|
+
const verifier = sanitizeBudget(mt.verifier);
|
|
83
|
+
if (verifier !== undefined) out.verifier = verifier;
|
|
84
|
+
const gapHunt = sanitizeBudget(mt.gapHunt);
|
|
85
|
+
if (gapHunt !== undefined) out.gapHunt = gapHunt;
|
|
86
|
+
const simplify = sanitizeBudget(mt.simplify);
|
|
87
|
+
if (simplify !== undefined) out.simplify = simplify;
|
|
88
|
+
return out;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
91
|
+
console.warn(`[pi-review] Ignoring malformed config at ${path}: ${reason}`);
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Load the effective turn budgets: built-in defaults ← global ← project. */
|
|
97
|
+
export function loadTurnBudgets(cwd: string = process.cwd()): TurnBudgets {
|
|
98
|
+
return {
|
|
99
|
+
...DEFAULT_TURN_BUDGETS,
|
|
100
|
+
...readBudgetsFile(globalPath()),
|
|
101
|
+
...readBudgetsFile(projectPath(cwd)),
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/diff.ts — deterministic diff resolution for the review prompts (v1
|
|
3
|
+
* semantics, relocated verbatim from src/commands/code-simplify.ts).
|
|
4
|
+
*
|
|
5
|
+
* The candidate ladder widens "changed code" as far as it resolves:
|
|
6
|
+
* upstream merge-base → HEAD worktree → staged-fresh → unstaged-fresh, and
|
|
7
|
+
* covers git submodules (a target inside a submodule resolves to the
|
|
8
|
+
* submodule's own root, so the real changes are reviewed instead of a dirty
|
|
9
|
+
* pointer). Pure functions — unit-testable, injected GitRunner.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
/** Soft cap on the changed-file list in the context package (see buildContextPackage). */
|
|
17
|
+
export const CONTEXT_PACKAGE_MAX_FILES = 200;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Walk up from `from` to the nearest directory containing `.git` (a directory
|
|
21
|
+
* or a submodule pointer file). Returns that root or null.
|
|
22
|
+
*/
|
|
23
|
+
export function findGitRoot(from: string): string | null {
|
|
24
|
+
let dir = path.resolve(from);
|
|
25
|
+
for (;;) {
|
|
26
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
27
|
+
const parent = path.dirname(dir);
|
|
28
|
+
if (parent === dir) return null;
|
|
29
|
+
dir = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normalize a /simplify target argument: trimmed, with an optional
|
|
34
|
+
* path-prefix `@` PRESERVED — a real directory may itself start with `@`
|
|
35
|
+
* (e.g. node_modules/@scope/pkg), so the resolver tries the literal path
|
|
36
|
+
* first and only falls back to the @-stripped form when it does not exist. */
|
|
37
|
+
function normalizeTarget(target: string | undefined): string {
|
|
38
|
+
return (target ?? "").trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolve the diff scope for a review/simplify target. Pure — unit-testable.
|
|
42
|
+
*
|
|
43
|
+
* - target absent/unresolvable → the nearest git root of `cwd`, full diff.
|
|
44
|
+
* - target is a path → its nearest git root; the relative path inside that
|
|
45
|
+
* root is the diff scope. Crucially this covers git SUBMODULES: a target
|
|
46
|
+
* like `@packages/extensions/pi-review/` resolves to the submodule's own
|
|
47
|
+
* git root, so the real changes inside it (invisible to the parent repo's
|
|
48
|
+
* `git diff`) are reviewed instead of a dirty-submodule pointer.
|
|
49
|
+
* - target at the git root itself (e.g. the whole submodule) → full diff.
|
|
50
|
+
* Returns null when no git root exists.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveDiffScope(
|
|
53
|
+
cwd: string,
|
|
54
|
+
target: string | undefined,
|
|
55
|
+
): { gitRoot: string; relPath: string | null } | null {
|
|
56
|
+
const raw = normalizeTarget(target);
|
|
57
|
+
// The `@`-prefix path convention (`@packages/extensions/pi-review/`): try
|
|
58
|
+
// the literal path FIRST (a real directory may itself start with `@`, e.g.
|
|
59
|
+
// node_modules/@scope/pkg) and only fall back to the @-stripped form.
|
|
60
|
+
let abs: string | null = null;
|
|
61
|
+
if (raw) {
|
|
62
|
+
for (const candidate of raw.startsWith("@") ? [raw, raw.slice(1)] : [raw]) {
|
|
63
|
+
const resolved = path.resolve(cwd, candidate);
|
|
64
|
+
if (fs.existsSync(resolved)) {
|
|
65
|
+
abs = resolved;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Unresolvable target — absent, or a non-path (branch / PR number) that
|
|
71
|
+
// doesn't exist on disk — keeps the whole-diff scope of cwd's git root.
|
|
72
|
+
if (abs == null) {
|
|
73
|
+
const gitRoot = findGitRoot(cwd);
|
|
74
|
+
return gitRoot ? { gitRoot, relPath: null } : null;
|
|
75
|
+
}
|
|
76
|
+
const gitRoot = findGitRoot(abs);
|
|
77
|
+
if (!gitRoot) return null;
|
|
78
|
+
const relPath = path.relative(gitRoot, abs);
|
|
79
|
+
return { gitRoot, relPath: relPath === "" || relPath === "." ? null : relPath };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Injectably run `git` (defaults to promisified execFile — array argv, no
|
|
83
|
+
* shell, and non-blocking: the caller is async, so git runs on the event
|
|
84
|
+
* loop instead of freezing the TUI for the whole diff duration). `signal`
|
|
85
|
+
* (optional) lets the caller abort an in-flight diff via ctx.signal. */
|
|
86
|
+
export type GitRunner = (args: string[], opts: { cwd: string; signal?: AbortSignal }) => Promise<string>;
|
|
87
|
+
|
|
88
|
+
const execFileAsync = promisify(execFile);
|
|
89
|
+
|
|
90
|
+
const defaultGitRunner: GitRunner = async (args, opts) =>
|
|
91
|
+
(await execFileAsync("git", args, {
|
|
92
|
+
cwd: opts.cwd,
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
95
|
+
signal: opts.signal,
|
|
96
|
+
})).stdout;
|
|
97
|
+
|
|
98
|
+
/** Which diff range produced the diff (drives scope reporting in prompts/messages). */
|
|
99
|
+
export type DiffScopeKind = "upstream" | "worktree" | "staged-fresh" | "unstaged-fresh";
|
|
100
|
+
|
|
101
|
+
/** Human label per scope kind — the single place the wording lives. */
|
|
102
|
+
export const DIFF_SCOPES: Record<DiffScopeKind, string> = {
|
|
103
|
+
upstream: "unpushed commits + uncommitted changes (merge-base of @{upstream} → working tree)",
|
|
104
|
+
worktree: "uncommitted changes (HEAD → working tree)",
|
|
105
|
+
"staged-fresh": "staged changes (repo has no commits yet)",
|
|
106
|
+
"unstaged-fresh": "unstaged changes (repo has no commits yet)",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Result of resolving the diff. `ok` carries the diff plus the scope kind
|
|
111
|
+
* that produced it and `gitCommand` — a shell-ready command that reproduces
|
|
112
|
+
* the exact diff invocation (range + path limiter + git root), so the
|
|
113
|
+
* rendered prompt can have the model re-read the SAME diff visibly instead
|
|
114
|
+
* of re-deriving a different range; the failure kinds are distinguishable so
|
|
115
|
+
* the caller can report WHY nothing was reviewed instead of a blanket "no
|
|
116
|
+
* changes".
|
|
117
|
+
*/
|
|
118
|
+
export type DiffOutcome =
|
|
119
|
+
| { kind: "ok"; diff: string; gitRoot: string; scopeKind: DiffScopeKind; gitCommand: string }
|
|
120
|
+
| { kind: "no-repo" }
|
|
121
|
+
| { kind: "empty" }
|
|
122
|
+
| { kind: "git-error"; message: string };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the diff for the resolved scope (see resolveDiffScope), widening
|
|
126
|
+
* the view to the full "changed code":
|
|
127
|
+
*
|
|
128
|
+
* 1. upstream — `git diff <merge-base @{upstream} HEAD>`: everything since
|
|
129
|
+
* divergence from the tracked upstream (unpushed commits + staged +
|
|
130
|
+
* unstaged) in one range. Skipped when no upstream is configured.
|
|
131
|
+
* 2. worktree — `git diff HEAD`: all uncommitted (staged + unstaged).
|
|
132
|
+
* 3. staged-fresh / unstaged-fresh — repos with no commits yet (HEAD doesn't
|
|
133
|
+
* resolve): index vs empty tree, then worktree vs index.
|
|
134
|
+
*
|
|
135
|
+
* The first candidate that yields a non-empty diff wins. All empty → `empty`;
|
|
136
|
+
* every candidate erroring (broken repo, diff exceeding maxBuffer) →
|
|
137
|
+
* `git-error` carrying the last error message; no git root → `no-repo`.
|
|
138
|
+
*/
|
|
139
|
+
export async function getRepoDiff(
|
|
140
|
+
cwd: string,
|
|
141
|
+
target: string | undefined,
|
|
142
|
+
run: GitRunner = defaultGitRunner,
|
|
143
|
+
signal?: AbortSignal,
|
|
144
|
+
): Promise<DiffOutcome> {
|
|
145
|
+
const scope = resolveDiffScope(cwd, target);
|
|
146
|
+
if (!scope) return { kind: "no-repo" };
|
|
147
|
+
const { gitRoot, relPath } = scope;
|
|
148
|
+
const pathArgs: string[] = relPath ? ["--", relPath] : [];
|
|
149
|
+
/** argv of one diff invocation — the single construction shared by the
|
|
150
|
+
* executed call (diffAttempt) and the reproduction command (commandFor),
|
|
151
|
+
* so the command shown to the model cannot drift from what ran. */
|
|
152
|
+
const diffArgs = (range: string[]): string[] => ["diff", "--no-color", ...range, ...pathArgs];
|
|
153
|
+
/** Shell-ready reproduction of a diff invocation (JSON.stringify quotes each
|
|
154
|
+
* path — valid POSIX quoting that also escapes embedded quotes). The
|
|
155
|
+
* relPath is re-quoted here for the DISPLAY only; the executed call uses
|
|
156
|
+
* the raw argv (diffArgs). A shell interpreting the displayed command
|
|
157
|
+
* produces the same argv, so the two cannot drift. */
|
|
158
|
+
const commandFor = (range: string[]): string => {
|
|
159
|
+
// Only shell-unsafe relPaths get quoted — a plain path stays clean in
|
|
160
|
+
// the displayed command, a path with spaces/glob metachars is quoted
|
|
161
|
+
// (JSON.stringify = valid POSIX quoting) so the visible re-run
|
|
162
|
+
// reproduces it exactly.
|
|
163
|
+
const safeRelPath = (p: string): string => (/^[A-Za-z0-9_./-]+$/.test(p) ? p : JSON.stringify(p));
|
|
164
|
+
return [
|
|
165
|
+
"git",
|
|
166
|
+
"-C",
|
|
167
|
+
JSON.stringify(gitRoot),
|
|
168
|
+
"diff",
|
|
169
|
+
"--no-color",
|
|
170
|
+
...range,
|
|
171
|
+
...(relPath ? ["--", safeRelPath(relPath)] : []),
|
|
172
|
+
].join(" ");
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
let lastError: string | undefined;
|
|
176
|
+
/** `recordError` false = a failure that is a legitimate fallback signal
|
|
177
|
+
* (git diff HEAD on a repo with no commits yet) — it must not be mistaken
|
|
178
|
+
* for a broken repo, or an empty fresh repo would report git-error
|
|
179
|
+
* instead of empty. */
|
|
180
|
+
const diffAttempt = async (range: string[], recordError = true): Promise<string | null> => {
|
|
181
|
+
try {
|
|
182
|
+
const out = (await run(diffArgs(range), { cwd: gitRoot, signal })).trim();
|
|
183
|
+
return out || null;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (recordError) lastError = err instanceof Error ? err.message : String(err);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const mergeBaseWithUpstream = async (): Promise<string | null> => {
|
|
190
|
+
try {
|
|
191
|
+
return (await run(["merge-base", "@{upstream}", "HEAD"], { cwd: gitRoot, signal })).trim() || null;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Candidate ladder in priority order — the first non-empty diff wins.
|
|
198
|
+
// `git diff HEAD` failing (recordError false) is the EXPECTED fresh-repo
|
|
199
|
+
// signal, not a broken repo; the later staged/unstaged candidates carry
|
|
200
|
+
// the real errors so a genuinely broken repo still surfaces git-error.
|
|
201
|
+
const candidates: { scopeKind: DiffScopeKind; range: string[]; recordError: boolean }[] = [];
|
|
202
|
+
const mb = await mergeBaseWithUpstream();
|
|
203
|
+
if (mb) candidates.push({ scopeKind: "upstream", range: [mb], recordError: true });
|
|
204
|
+
candidates.push(
|
|
205
|
+
{ scopeKind: "worktree", range: ["HEAD"], recordError: false },
|
|
206
|
+
{ scopeKind: "staged-fresh", range: ["--staged"], recordError: true },
|
|
207
|
+
{ scopeKind: "unstaged-fresh", range: [], recordError: true },
|
|
208
|
+
);
|
|
209
|
+
for (const c of candidates) {
|
|
210
|
+
const out = await diffAttempt(c.range, c.recordError);
|
|
211
|
+
if (out) return { kind: "ok", diff: out, gitRoot, scopeKind: c.scopeKind, gitCommand: commandFor(c.range) };
|
|
212
|
+
}
|
|
213
|
+
return lastError ? { kind: "git-error", message: lastError } : { kind: "empty" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build the zero-token context package injected into every rendered prompt:
|
|
218
|
+
* repo root, the resolved diff scope, and a changed-file index with
|
|
219
|
+
* add/remove line counts, parsed straight out of the diff — no extra git
|
|
220
|
+
* calls, no drift from the diff embedded below. Gathered dispatcher-side
|
|
221
|
+
* where it costs no parent-context tokens, so each agent skips its own 1–3
|
|
222
|
+
* exploration rounds of `git diff --stat`. Pure — unit-testable.
|
|
223
|
+
*/
|
|
224
|
+
export function buildContextPackage(diff: string, gitRoot: string, scopeLabel: string): string {
|
|
225
|
+
const churn = new Map<string, { added: number; removed: number; binary: boolean }>();
|
|
226
|
+
let current: string | null = null;
|
|
227
|
+
/** git quotes path headers with non-ASCII/special chars (core.quotepath
|
|
228
|
+
* default true) — accept both the plain and the quoted "a/…" "b/…" forms. */
|
|
229
|
+
const FILE_HEADER = /^diff --git (?:a\/(.*) b\/(.*)|"a\/(.*)" "b\/(.*)")$/;
|
|
230
|
+
/** `--- a/…` / `+++ b/…` (or /dev/null, or quoted variants) are file
|
|
231
|
+
* headers, not content lines — but a CONTENT line may itself start with
|
|
232
|
+
* `+`/`-` (rendered `+++x`), so only the exact header prefixes skip. */
|
|
233
|
+
const HEADER_PREFIXES = [
|
|
234
|
+
"--- a/",
|
|
235
|
+
"--- /dev/null",
|
|
236
|
+
"+++ b/",
|
|
237
|
+
"+++ /dev/null",
|
|
238
|
+
'--- "a/',
|
|
239
|
+
'+++ "b/',
|
|
240
|
+
];
|
|
241
|
+
for (const line of diff.split("\n")) {
|
|
242
|
+
const m = FILE_HEADER.exec(line);
|
|
243
|
+
if (m) {
|
|
244
|
+
current = m[2] ?? m[4]!;
|
|
245
|
+
if (!churn.has(current)) churn.set(current, { added: 0, removed: 0, binary: false });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (current == null) continue;
|
|
249
|
+
const c = churn.get(current)!;
|
|
250
|
+
if (line.startsWith("Binary files")) c.binary = true;
|
|
251
|
+
else if (HEADER_PREFIXES.some((p) => line.startsWith(p))) continue;
|
|
252
|
+
else if (line.startsWith("+")) c.added++;
|
|
253
|
+
else if (line.startsWith("-")) c.removed++;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Map iterates in insertion order — the keys ARE the first-seen file order.
|
|
257
|
+
const files = [...churn.keys()];
|
|
258
|
+
const lines: string[] = [`Repo root: ${gitRoot}`, `Diff scope: ${scopeLabel}`];
|
|
259
|
+
if (files.length > 0) {
|
|
260
|
+
lines.push("Changed files (added/removed lines):");
|
|
261
|
+
for (const f of files.slice(0, CONTEXT_PACKAGE_MAX_FILES)) {
|
|
262
|
+
const c = churn.get(f)!;
|
|
263
|
+
lines.push(` ${f}${c.binary ? " (binary)" : ` +${c.added} -${c.removed}`}`);
|
|
264
|
+
}
|
|
265
|
+
if (files.length > CONTEXT_PACKAGE_MAX_FILES)
|
|
266
|
+
lines.push(` … and ${files.length - CONTEXT_PACKAGE_MAX_FILES} more (see the diff below)`);
|
|
267
|
+
}
|
|
268
|
+
return lines.join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Priority order for picking a verification command from package.json scripts. */
|
|
272
|
+
const VERIFY_SCRIPT_PRIORITY = ["check", "test", "lint", "typecheck"] as const;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Pick the project verification command from a package.json `scripts` map, in
|
|
276
|
+
* priority order (check → test → lint → typecheck). Pure — unit-testable.
|
|
277
|
+
* Returns the runnable command (e.g. `npm run check`) or null when none exists.
|
|
278
|
+
*/
|
|
279
|
+
export function detectVerifyCommand(scripts: Record<string, string> | null): string | null {
|
|
280
|
+
if (!scripts) return null;
|
|
281
|
+
for (const key of VERIFY_SCRIPT_PRIORITY) {
|
|
282
|
+
const v = scripts[key];
|
|
283
|
+
if (typeof v === "string" && v.trim() !== "") return `npm run ${key}`;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Read package.json scripts from `cwd`; returns null when absent/unparseable. */
|
|
289
|
+
export function readScriptsAt(cwd: string): Record<string, string> | null {
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) as {
|
|
292
|
+
scripts?: Record<string, string>;
|
|
293
|
+
};
|
|
294
|
+
return pkg.scripts ?? null;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Build the "verify/apply" guidance line rendered into the prompts. */
|
|
301
|
+
export function verifyLine(cwd: string): string {
|
|
302
|
+
const verifyCmd = detectVerifyCommand(readScriptsAt(cwd));
|
|
303
|
+
return verifyCmd
|
|
304
|
+
? `Verification command: \`${verifyCmd}\` (detected from package.json scripts). After applying fixes, run it; on failure, follow the skill's auto-revert procedure — never leave the working tree verified-broken.`
|
|
305
|
+
: `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).`;
|
|
306
|
+
}
|