@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.
@@ -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 /code-review for that. v3 (from Claude Code CLI v2.1.227, symbol-level verified) — 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."
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 /code-simplify.
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 /code-review: c$e (Phase 0), m7t/u$e/d$e/p$e (the 4 cleanup angles).
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: /code-simplify [<target>]
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 heuristic
54
- (tokens/contextWindow >= 0.8 → single-pass), a Pi addition NOT a mirror of
55
- Dii; (b) is mirrored as "the `subagent` tool must be registered". The
56
- decision is made DETERMINISTICALLY by the /code-simplify handler — it can
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: /code-simplify.
60
-
61
- Prerequisite: the `subagent` tool (provided by the pi-review extension) for
62
- PARALLEL MODE, and the `review_report` tool (same extension) for
63
- the Phase 2 structured outcome report. SINGLE-PASS MODE runs
64
- standalone apart from `review_report`.
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 `/code-review` is for.
97
+ find. Do not look for correctness bugs — that is what `/review` is for.
70
98
 
71
- The `/code-simplify` handler has already chosen the mode (PARALLEL or
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
- Run `git diff @{upstream}...HEAD` (or `git diff main...HEAD` / `git diff HEAD~1`
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 (subagent tool available AND context not near-full)
126
+ # PARALLEL MODE (context not near-full AND diff under the fan-out threshold AND fan-out available)
87
127
 
88
- `/code-simplify → 4 cleanup agents in parallel → apply the fixes`
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
- Launch **4 independent review agents** via the subagent tool, all in a
93
- single message so they run concurrently. Pass each agent the diff and one of
94
- the four angles below. Each returns its findings with `file`, `line`, a
95
- one-line `summary`, and the concrete cost (what is duplicated, wasted, or
96
- harder to maintain).
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 is implemented at the right depth, not as a fragile
123
- bandaid. Special cases layered on shared infrastructure are a sign the fix
124
- isn't deep enough — prefer generalizing the underlying mechanism over adding
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 (subagent tool unavailable OR context near-full)
185
+ # SINGLE-PASS MODE (context near-full OR diff too large OR fan-out unavailable)
133
186
 
134
- `/code-simplify → subagent tool unavailable → single-pass inline cleanup → apply the fixes`
187
+ `/simplify → handler decided single-pass (reasons in the trigger message) → inline cleanup → apply the fixes`
135
188
 
136
- The subagent tool isn't available in this context, so the usual
137
- 4-agent fan-out can't run. Work through all four angles below yourself, in
138
- this same context, in one pass — do not skip an angle for lack of fan-out.
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 is implemented at the right depth, not as a fragile
171
- bandaid. Special cases layered on shared infrastructure are a sign the fix
172
- isn't deep enough — prefer generalizing the underlying mechanism over adding
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 `/code-simplify` from
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 `/code-simplify`'s fixes,
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
+ }