@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/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
+ }
@@ -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
+ }