@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
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
|
+
}
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/dispatch.ts — the generic prompt dispatcher (v2's only command layer).
|
|
3
|
+
*
|
|
4
|
+
* Per invocation: parse leading arguments, gather deterministic runtime
|
|
5
|
+
* variables (resolved diff via the candidate ladder, context usage,
|
|
6
|
+
* changed-file context package, sticky last-effort state), evaluate the
|
|
7
|
+
* guards declared in the selected template's frontmatter, pick the template
|
|
8
|
+
* variant, substitute {{var}} placeholders, and hand the rendered message to
|
|
9
|
+
* the session via sendUserMessage.
|
|
10
|
+
*
|
|
11
|
+
* The parallel-strategy decisions live in prompts/*.md frontmatter (data);
|
|
12
|
+
* this module only executes them. Adding a new prompt/skill requires no
|
|
13
|
+
* change here — the templates and skills are the registration surface.
|
|
14
|
+
*/
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { isFanoutToolAllowed } from "@fyeeme/pi-subagents";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { loadTurnBudgets } from "./config.ts";
|
|
23
|
+
import { DIFF_SCOPES, buildContextPackage, getRepoDiff, verifyLine } from "./diff.ts";
|
|
24
|
+
import { bundledSkillPath } from "./skills.ts";
|
|
25
|
+
import { parseGuards, selectVariant } from "./strategy.ts";
|
|
26
|
+
|
|
27
|
+
// This file lives at <pkg>/src/ → ".." is the package root.
|
|
28
|
+
const PKG_ROOT = fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
29
|
+
|
|
30
|
+
/** Absolute path to a prompt template bundled in this package's prompts/ dir. */
|
|
31
|
+
function bundledPromptPath(rel: string): string {
|
|
32
|
+
return path.join(PKG_ROOT, "prompts", rel);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Load a bundled template: frontmatter map + body. */
|
|
36
|
+
function loadTemplate(rel: string): { frontmatter: Record<string, unknown>; body: string } {
|
|
37
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(
|
|
38
|
+
fs.readFileSync(bundledPromptPath(rel), "utf8"),
|
|
39
|
+
);
|
|
40
|
+
return { frontmatter, body };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Substitute {{var}} placeholders. An unknown placeholder is an error at
|
|
44
|
+
* load time in dev, but rendering must never crash a command — unfilled
|
|
45
|
+
* placeholders are left visible (they self-report in the rendered message).
|
|
46
|
+
* Pure — unit-testable. */
|
|
47
|
+
export function render(body: string, vars: Record<string, string>): string {
|
|
48
|
+
return body.replace(/\{\{([a-z-]+)\}\}/g, (whole, name: string) =>
|
|
49
|
+
name in vars ? vars[name]! : whole,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// /review — effort-level code review (v1 command semantics, relocated)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** Effort levels the /review command accepts (mirrors CC's effort enum). */
|
|
58
|
+
export const REVIEW_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
59
|
+
export type ReviewLevel = (typeof REVIEW_LEVELS)[number];
|
|
60
|
+
|
|
61
|
+
const DEFAULT_LEVEL: ReviewLevel = "low";
|
|
62
|
+
|
|
63
|
+
/** Where the last explicitly-typed effort is persisted (CC 2.1.223 codeReviewLastEffort). */
|
|
64
|
+
const STATE_FILE = path.join(os.homedir(), ".pi", ".pi-review-state.json");
|
|
65
|
+
|
|
66
|
+
export type EffortSource = "explicit" | "last-used" | "default";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a leading effort level out of raw args; the remainder (flags + target)
|
|
70
|
+
* is returned verbatim. Pure — unit-testable.
|
|
71
|
+
*/
|
|
72
|
+
export function parseReviewArgs(args: string): { level: ReviewLevel | undefined; rest: string } {
|
|
73
|
+
const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
74
|
+
if (tokens.length === 0) return { level: undefined, rest: "" };
|
|
75
|
+
const first = tokens[0]!.toLowerCase();
|
|
76
|
+
const isLevel = (REVIEW_LEVELS as readonly string[]).includes(first);
|
|
77
|
+
return {
|
|
78
|
+
level: isLevel ? (first as ReviewLevel) : undefined,
|
|
79
|
+
rest: isLevel ? tokens.slice(1).join(" ") : tokens.join(" "),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the effective effort + where it came from. Pure — unit-testable.
|
|
85
|
+
* Explicit wins; otherwise the last-typed level; otherwise low.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveEffort(
|
|
88
|
+
explicit: ReviewLevel | undefined,
|
|
89
|
+
lastUsed: ReviewLevel | undefined,
|
|
90
|
+
): { level: ReviewLevel; source: EffortSource } {
|
|
91
|
+
if (explicit) return { level: explicit, source: "explicit" };
|
|
92
|
+
if (lastUsed) return { level: lastUsed, source: "last-used" };
|
|
93
|
+
return { level: DEFAULT_LEVEL, source: "default" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Best-effort persistence — sticky-effort is a convenience, not a correctness
|
|
97
|
+
// invariant; a read/write failure must not break the review.
|
|
98
|
+
function readLastEffort(): ReviewLevel | undefined {
|
|
99
|
+
try {
|
|
100
|
+
const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8")) as { codeReviewLastEffort?: unknown };
|
|
101
|
+
const v = raw.codeReviewLastEffort;
|
|
102
|
+
return typeof v === "string" && (REVIEW_LEVELS as readonly string[]).includes(v)
|
|
103
|
+
? (v as ReviewLevel)
|
|
104
|
+
: undefined;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function writeLastEffort(level: ReviewLevel): void {
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
112
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify({ codeReviewLastEffort: level }));
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore — non-critical */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// /simplify — cleanup fan-out with the declared parallel strategy
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/** v1 DIFF_TOO_LARGE_CHARS — kept for the single-pass "too large to read at
|
|
123
|
+
* once" note (the strategy threshold itself lives in the template). */
|
|
124
|
+
const DIFF_TOO_LARGE_CHARS = 400_000;
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Registration
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export function registerDispatcher(pi: ExtensionAPI): void {
|
|
131
|
+
pi.registerCommand("review", {
|
|
132
|
+
description:
|
|
133
|
+
"Review the current diff using the review skill. Usage: /review [low|medium|high|xhigh|max] [--fix] [--comment] [--share] [<pr#>|<branch>|<path>]",
|
|
134
|
+
getArgumentCompletions(prefix) {
|
|
135
|
+
const tokens = ["low", "medium", "high", "xhigh", "max", "--fix", "--comment", "--share"];
|
|
136
|
+
return tokens.filter((t) => t.startsWith(prefix)).map((t) => ({ label: t, value: t }));
|
|
137
|
+
},
|
|
138
|
+
async handler(args, ctx) {
|
|
139
|
+
const { level: explicit, rest } = parseReviewArgs(args ?? "");
|
|
140
|
+
// Skip the read when we just wrote it — resolveEffort returns `explicit` unchanged.
|
|
141
|
+
const lastUsed = explicit ? undefined : readLastEffort();
|
|
142
|
+
if (explicit) writeLastEffort(explicit); // remember the explicit level
|
|
143
|
+
const { level, source } = resolveEffort(explicit, lastUsed);
|
|
144
|
+
const { body } = loadTemplate("review.md");
|
|
145
|
+
const budgets = loadTurnBudgets();
|
|
146
|
+
pi.sendUserMessage(
|
|
147
|
+
render(body, {
|
|
148
|
+
effort: level,
|
|
149
|
+
"effort-source": source,
|
|
150
|
+
"extra-args": rest ? `; extra args: ${rest}` : "",
|
|
151
|
+
skill: bundledSkillPath("review/SKILL.md"),
|
|
152
|
+
"finder-max-turns": String(budgets.subagent),
|
|
153
|
+
"verifier-max-turns": String(budgets.verifier),
|
|
154
|
+
"gap-hunt-max-turns": String(budgets.gapHunt),
|
|
155
|
+
// Consumed by the skill's --fix flow (apply → verify → re-report).
|
|
156
|
+
verify: verifyLine(ctx.cwd),
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
pi.registerCommand("simplify", {
|
|
163
|
+
description:
|
|
164
|
+
"Clean up the changed code (reuse/simplification/efficiency/altitude) using the simplify skill. Mode (parallel 4-agent vs single-pass) is decided from the strategy declared in prompts/simplify.*.md (context usage, diff size, fan-out availability); PARALLEL opens with a visible Phase 0 before the subagent tool launches the agents. Usage: /simplify [<target>]",
|
|
165
|
+
async handler(args, ctx) {
|
|
166
|
+
try {
|
|
167
|
+
// ctx.signal (undefined while idle) lets Esc abort an in-flight diff.
|
|
168
|
+
const outcome = await getRepoDiff(ctx.cwd, args?.trim() || undefined, undefined, ctx.signal);
|
|
169
|
+
if (outcome.kind === "no-repo") {
|
|
170
|
+
ctx.ui.notify(`/simplify: ${ctx.cwd} is not inside a git repo — nothing to clean up.`, "warning");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (outcome.kind === "git-error") {
|
|
174
|
+
ctx.ui.notify(`/simplify: git failed — ${outcome.message}`, "error");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (outcome.kind === "empty") {
|
|
178
|
+
ctx.ui.notify(
|
|
179
|
+
`/simplify: no changes found (checked unpushed+uncommitted vs @{upstream}, uncommitted vs HEAD, staged, unstaged) — nothing to clean up.`,
|
|
180
|
+
"warning",
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const usage = ctx.getContextUsage();
|
|
186
|
+
const budgets = loadTurnBudgets(ctx.cwd);
|
|
187
|
+
const parallelTemplate = loadTemplate("simplify.parallel.md");
|
|
188
|
+
const { variant, reasons } = selectVariant(parseGuards(parallelTemplate.frontmatter), {
|
|
189
|
+
tokens: usage?.tokens ?? null,
|
|
190
|
+
contextWindow: usage?.contextWindow ?? 0,
|
|
191
|
+
diffChars: outcome.diff.length,
|
|
192
|
+
fanoutAvailable: isFanoutToolAllowed(),
|
|
193
|
+
});
|
|
194
|
+
const pct = usage && usage.percent != null ? `${Math.round(usage.percent)}%` : "?";
|
|
195
|
+
const target = args || "(whole diff)";
|
|
196
|
+
const skill = bundledSkillPath("simplify/SKILL.md");
|
|
197
|
+
const scopeLabel = DIFF_SCOPES[outcome.scopeKind];
|
|
198
|
+
const contextPackage = buildContextPackage(outcome.diff, outcome.gitRoot, scopeLabel);
|
|
199
|
+
const verify = verifyLine(outcome.gitRoot);
|
|
200
|
+
|
|
201
|
+
if (variant === "single-pass") {
|
|
202
|
+
const { body } = loadTemplate("simplify.single.md");
|
|
203
|
+
pi.sendUserMessage(
|
|
204
|
+
render(body, {
|
|
205
|
+
target,
|
|
206
|
+
reasons: reasons.join("; "),
|
|
207
|
+
"scope-label": scopeLabel,
|
|
208
|
+
"too-large":
|
|
209
|
+
outcome.diff.length >= DIFF_TOO_LARGE_CHARS
|
|
210
|
+
? `\nThe diff is too large to read at once — work through it file-by-file from the changed-file list above.\n`
|
|
211
|
+
: "",
|
|
212
|
+
"git-command": outcome.gitCommand,
|
|
213
|
+
"context-package": contextPackage,
|
|
214
|
+
skill,
|
|
215
|
+
verify,
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
pi.sendUserMessage(
|
|
222
|
+
render(parallelTemplate.body, {
|
|
223
|
+
target,
|
|
224
|
+
pct,
|
|
225
|
+
"scope-label": scopeLabel,
|
|
226
|
+
"git-command": outcome.gitCommand,
|
|
227
|
+
"context-package": contextPackage,
|
|
228
|
+
skill,
|
|
229
|
+
verify,
|
|
230
|
+
"simplify-max-turns": String(budgets.simplify),
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
ctx.ui.notify(`/simplify failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|