@fyeeme/pi-review 1.1.1 → 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,807 +0,0 @@
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";
5
- import * as fs from "node:fs";
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";
15
- import { bundledSkillPath } from "../skills.ts";
16
-
17
- /** Context fraction at which we fall back to single-pass — a Pi-specific heuristic (see decideSimplifyMode). */
18
- const CONTEXT_NEAR_FULL_THRESHOLD = 0.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
-
38
- export type SimplifyMode = "parallel" | "single-pass";
39
-
40
- /** Priority order for picking a verification command from package.json scripts. */
41
- const VERIFY_SCRIPT_PRIORITY = ["check", "test", "lint", "typecheck"] as const;
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
-
361
- /**
362
- * Pick the project verification command from a package.json `scripts` map, in
363
- * priority order (check → test → lint → typecheck). Pure — unit-testable.
364
- * Returns the runnable command (e.g. `npm run check`) or null when none exists.
365
- */
366
- export function detectVerifyCommand(scripts: Record<string, string> | null): string | null {
367
- if (!scripts) return null;
368
- for (const key of VERIFY_SCRIPT_PRIORITY) {
369
- const v = scripts[key];
370
- if (typeof v === "string" && v.trim() !== "") return `npm run ${key}`;
371
- }
372
- return null;
373
- }
374
-
375
- /** Read package.json scripts from `cwd`; returns null when absent/unparseable. */
376
- function readScriptsAt(cwd: string): Record<string, string> | null {
377
- try {
378
- const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) as {
379
- scripts?: Record<string, string>;
380
- };
381
- return pkg.scripts ?? null;
382
- } catch {
383
- return null;
384
- }
385
- }
386
-
387
- /**
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).
392
- *
393
- * CC parity note: CC's /simplify guard (Dii, verified in the 2.1.227 binary) is
394
- * a SPAWN-DEPTH recursion limit, NOT a context check — `ok(ctx.agentContext) >= wV()`
395
- * where ok() returns the agent's depth (main=0) and wV() returns
396
- * CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH (default 3). That is N/A on Pi: the
397
- * `subagent` tool spawns a fresh subprocess (depth 0), so depth never accumulates.
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.
408
- */
409
- export function decideSimplifyMode(opts: {
410
- tokens: number | null;
411
- contextWindow: number;
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
- );
484
- }
485
-
486
- /**
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.
725
- */
726
- export function registerSimplify(pi: ExtensionAPI): void {
727
- pi.registerCommand("code-simplify", {
728
- description:
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>]",
730
- async handler(args, ctx) {
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
- }
805
- },
806
- });
807
- }