@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.
- package/README.md +77 -146
- 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 -39
- package/package.json +18 -17
- 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 +113 -33
- package/skills/simplify/SKILL.md +56 -44
- 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 -807
- package/src/concurrency.ts +0 -23
- package/src/tools/subagent.ts +0 -372
|
@@ -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
|
-
}
|