@fyeeme/pi-review 2.0.0 → 2.1.0

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.
@@ -0,0 +1,301 @@
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 { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
17
+ import { isFanoutToolAllowed } from "@fyeeme/pi-subagents";
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { loadTurnBudgets } from "./config.ts";
22
+ import { DIFF_SCOPES, buildContextPackage, getRepoDiff, verifyLine } from "./diff.ts";
23
+ import { extractLoopFlag, runLoopFixing } from "./loop.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
+ // /code-review — effort-level code review (v1 command semantics, relocated)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Effort levels the /code-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
64
+ * codeReviewLastEffort). Shares the config file so users have a single
65
+ * pi-review.json; the field is written alongside `maxTurns`, never over it. */
66
+ const STATE_FILE = path.join(getAgentDir(), "pi-review.json");
67
+
68
+ export type EffortSource = "explicit" | "last-used" | "default";
69
+
70
+ /**
71
+ * Parse a leading effort level out of raw args; the remainder (flags + target)
72
+ * is returned verbatim. Pure — unit-testable.
73
+ */
74
+ /** True when the effort level uses the xhigh/max finder/verifier fan-out;
75
+ * false for the single-pass levels (low/medium/high). Pure — unit-testable. */
76
+ export function usesFanout(level: ReviewLevel): boolean {
77
+ return level === "xhigh" || level === "max";
78
+ }
79
+
80
+ export function parseReviewArgs(args: string): { level: ReviewLevel | undefined; rest: string } {
81
+ const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
82
+ if (tokens.length === 0) return { level: undefined, rest: "" };
83
+ const first = tokens[0]!.toLowerCase();
84
+ const isLevel = (REVIEW_LEVELS as readonly string[]).includes(first);
85
+ return {
86
+ level: isLevel ? (first as ReviewLevel) : undefined,
87
+ rest: isLevel ? tokens.slice(1).join(" ") : tokens.join(" "),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Resolve the effective effort + where it came from. Pure — unit-testable.
93
+ * Explicit wins; otherwise the last-typed level; otherwise low.
94
+ */
95
+ export function resolveEffort(
96
+ explicit: ReviewLevel | undefined,
97
+ lastUsed: ReviewLevel | undefined,
98
+ ): { level: ReviewLevel; source: EffortSource } {
99
+ if (explicit) return { level: explicit, source: "explicit" };
100
+ if (lastUsed) return { level: lastUsed, source: "last-used" };
101
+ return { level: DEFAULT_LEVEL, source: "default" };
102
+ }
103
+
104
+ // Best-effort persistence — sticky-effort is a convenience, not a correctness
105
+ // invariant; a read/write failure must not break the review.
106
+ function readLastEffort(): ReviewLevel | undefined {
107
+ try {
108
+ const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8")) as { codeReviewLastEffort?: unknown };
109
+ const v = raw.codeReviewLastEffort;
110
+ return typeof v === "string" && (REVIEW_LEVELS as readonly string[]).includes(v)
111
+ ? (v as ReviewLevel)
112
+ : undefined;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ function writeLastEffort(level: ReviewLevel): void {
118
+ try {
119
+ // The state field shares a file with user-authored config (maxTurns), so
120
+ // read-modify-write instead of overwriting, and never clobber a
121
+ // hand-edited file we cannot parse.
122
+ let existing: Record<string, unknown> = {};
123
+ if (fs.existsSync(STATE_FILE)) {
124
+ let raw: unknown;
125
+ try {
126
+ raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
127
+ } catch {
128
+ return;
129
+ }
130
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
131
+ existing = raw as Record<string, unknown>;
132
+ }
133
+ existing.codeReviewLastEffort = level;
134
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
135
+ fs.writeFileSync(STATE_FILE, `${JSON.stringify(existing, null, 2)}\n`);
136
+ } catch {
137
+ /* ignore — non-critical */
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // /code-simplify — cleanup fan-out with the declared parallel strategy
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /** v1 DIFF_TOO_LARGE_CHARS — kept for the single-pass "too large to read at
146
+ * once" note (the strategy threshold itself lives in the template). */
147
+ const DIFF_TOO_LARGE_CHARS = 400_000;
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Registration
151
+ // ---------------------------------------------------------------------------
152
+
153
+ export function registerDispatcher(pi: ExtensionAPI): void {
154
+ pi.registerCommand("code-review", {
155
+ description:
156
+ "Review the current diff using the code-review skill. low/medium/high review in a single pass in this session; xhigh/max fan out finder/verifier agents. Usage: /code-review [low|medium|high|xhigh|max] [--fix] [--loop] [--comment] [--share] [<pr#>|<branch>|<path>]",
157
+ getArgumentCompletions(prefix) {
158
+ const tokens = ["low", "medium", "high", "xhigh", "max", "--fix", "--loop", "--comment", "--share"];
159
+ return tokens.filter((t) => t.startsWith(prefix)).map((t) => ({ label: t, value: t }));
160
+ },
161
+ async handler(args, ctx) {
162
+ const { level: explicit, rest } = parseReviewArgs(args ?? "");
163
+ // Skip the read when we just wrote it — resolveEffort returns `explicit` unchanged.
164
+ const lastUsed = explicit ? undefined : readLastEffort();
165
+ if (explicit) writeLastEffort(explicit); // remember the explicit level
166
+ const { level, source } = resolveEffort(explicit, lastUsed);
167
+ const budgets = loadTurnBudgets();
168
+
169
+ // Effort split: low/medium/high review in ONE pass in the main session
170
+ // (no subprocess fan-out); xhigh/max keep the finder/verifier pipeline.
171
+ const fanout = usesFanout(level);
172
+
173
+ // --loop is extension-level (drives fix→re-review rounds against the
174
+ // structured report), so it never reaches the skill text. It needs a
175
+ // single report turn to loop on — fan-out levels have no such turn.
176
+ const { wantLoop, rest: flagsRest } = extractLoopFlag(rest);
177
+ const loopArmed = wantLoop && !fanout;
178
+ if (wantLoop && fanout) {
179
+ ctx.ui.notify(
180
+ `/code-review: --loop applies to single-pass levels (low/medium/high) — ignored for ${level}.`,
181
+ "warning",
182
+ );
183
+ }
184
+
185
+ const { body } = loadTemplate(fanout ? "review.parallel.md" : "review.single.md");
186
+ const shared = {
187
+ effort: level,
188
+ "effort-source": source,
189
+ "extra-args": flagsRest ? `; extra args: ${flagsRest}` : "",
190
+ skill: bundledSkillPath("code-review/SKILL.md"),
191
+ // Consumed by the skill's --fix flow (apply → verify → re-report).
192
+ verify: verifyLine(ctx.cwd),
193
+ };
194
+ void pi.sendUserMessage(
195
+ render(
196
+ body,
197
+ fanout
198
+ ? {
199
+ ...shared,
200
+ "finder-max-turns": String(budgets.subagent),
201
+ "verifier-max-turns": String(budgets.verifier),
202
+ "gap-hunt-max-turns": String(budgets.gapHunt),
203
+ }
204
+ : {
205
+ ...shared,
206
+ // Told to the session so every finding carries a P0–P3 priority.
207
+ "loop-note": loopArmed
208
+ ? `\nLoop fixing is armed: after your report, the extension drives up to ${budgets.loop} fix→re-review rounds until no P0/P1 findings remain — so tag every finding with a priority (P0–P3).\n`
209
+ : "",
210
+ },
211
+ ),
212
+ );
213
+ if (loopArmed) {
214
+ await runLoopFixing(pi, ctx, {
215
+ level,
216
+ passes: budgets.loop,
217
+ // Must match where review_report writes (CONFIG_DIR_NAME,
218
+ // not necessarily ".pi") or the loop never finds a report.
219
+ reviewDir: path.join(ctx.cwd, CONFIG_DIR_NAME, "review"),
220
+ });
221
+ }
222
+ },
223
+ });
224
+
225
+ pi.registerCommand("code-simplify", {
226
+ description:
227
+ "Clean up the changed code (reuse/simplification/efficiency/altitude) using the code-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: /code-simplify [<target>]",
228
+ async handler(args, ctx) {
229
+ try {
230
+ // ctx.signal (undefined while idle) lets Esc abort an in-flight diff.
231
+ const outcome = await getRepoDiff(ctx.cwd, args?.trim() || undefined, undefined, ctx.signal);
232
+ if (outcome.kind === "no-repo") {
233
+ ctx.ui.notify(`/code-simplify: ${ctx.cwd} is not inside a git repo — nothing to clean up.`, "warning");
234
+ return;
235
+ }
236
+ if (outcome.kind === "git-error") {
237
+ ctx.ui.notify(`/code-simplify: git failed — ${outcome.message}`, "error");
238
+ return;
239
+ }
240
+ if (outcome.kind === "empty") {
241
+ ctx.ui.notify(
242
+ `/code-simplify: no changes found (checked unpushed+uncommitted vs @{upstream}, uncommitted vs HEAD, staged, unstaged) — nothing to clean up.`,
243
+ "warning",
244
+ );
245
+ return;
246
+ }
247
+
248
+ const usage = ctx.getContextUsage();
249
+ const budgets = loadTurnBudgets(ctx.cwd);
250
+ const parallelTemplate = loadTemplate("simplify.parallel.md");
251
+ const { variant, reasons } = selectVariant(parseGuards(parallelTemplate.frontmatter), {
252
+ tokens: usage?.tokens ?? null,
253
+ contextWindow: usage?.contextWindow ?? 0,
254
+ diffChars: outcome.diff.length,
255
+ fanoutAvailable: isFanoutToolAllowed(),
256
+ });
257
+ const pct = usage && usage.percent != null ? `${Math.round(usage.percent)}%` : "?";
258
+ const target = args || "(whole diff)";
259
+ const skill = bundledSkillPath("code-simplify/SKILL.md");
260
+ const scopeLabel = DIFF_SCOPES[outcome.scopeKind];
261
+ const contextPackage = buildContextPackage(outcome.diff, outcome.gitRoot, scopeLabel);
262
+ const verify = verifyLine(outcome.gitRoot);
263
+
264
+ if (variant === "single-pass") {
265
+ const { body } = loadTemplate("simplify.single.md");
266
+ pi.sendUserMessage(
267
+ render(body, {
268
+ target,
269
+ reasons: reasons.join("; "),
270
+ "scope-label": scopeLabel,
271
+ "too-large":
272
+ outcome.diff.length >= DIFF_TOO_LARGE_CHARS
273
+ ? `\nThe diff is too large to read at once — work through it file-by-file from the changed-file list above.\n`
274
+ : "",
275
+ "git-command": outcome.gitCommand,
276
+ "context-package": contextPackage,
277
+ skill,
278
+ verify,
279
+ }),
280
+ );
281
+ return;
282
+ }
283
+
284
+ pi.sendUserMessage(
285
+ render(parallelTemplate.body, {
286
+ target,
287
+ pct,
288
+ "scope-label": scopeLabel,
289
+ "git-command": outcome.gitCommand,
290
+ "context-package": contextPackage,
291
+ skill,
292
+ verify,
293
+ "simplify-max-turns": String(budgets.simplify),
294
+ }),
295
+ );
296
+ } catch (err) {
297
+ ctx.ui.notify(`/code-simplify failed: ${err instanceof Error ? err.message : String(err)}`, "error");
298
+ }
299
+ },
300
+ });
301
+ }
package/src/loop.ts ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * src/loop.ts — /code-review --loop: extension-driven fix→re-review cycles.
3
+ *
4
+ * Ported from the standalone Codex-style review extension's loop fixing
5
+ * (review → blocking-check → fix → re-review, bounded), with one deliberate
6
+ * deviation: the blocking decision reads the structured `review_report` JSON
7
+ * the skill must have written under the project's pi config dir
8
+ * (<cwd>/.pi/review/ by default — CONFIG_DIR_NAME) instead of scraping the
9
+ * assistant's markdown. The tool call is the report, so the JSON is the
10
+ * reliable artifact; markdown scraping was only ever a fallback.
11
+ *
12
+ * Loop shape (single-pass levels only — low/medium/high):
13
+ * review turn → read newest report → OPEN P0/P1 findings (P0/P1 without a
14
+ * decided outcome — a fix turn's re-report marks its findings
15
+ * fixed/skipped/no_change_needed)?
16
+ * none → done
17
+ * some, rounds left → fix prompt (followUp) → idle → re-review prompt → next round
18
+ * some, rounds spent → stop with a safety-limit note
19
+ * Esc/abort or a missing report stops the loop.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
24
+ import { OUTCOME_VALUES } from "./tools/review_report.ts";
25
+
26
+ // --- pure helpers (unit-tested) ---------------------------------------------
27
+
28
+ export const BLOCKING_PRIORITIES = ["P0", "P1"] as const;
29
+
30
+ /** Strip a --loop flag out of the trailing args; report whether it was there.
31
+ * Pure — unit-testable. */
32
+ export function extractLoopFlag(rest: string): { wantLoop: boolean; rest: string } {
33
+ const tokens = (rest ?? "").split(/\s+/).filter(Boolean);
34
+ const kept = tokens.filter((t) => t !== "--loop");
35
+ return { wantLoop: kept.length !== tokens.length, rest: kept.join(" ") };
36
+ }
37
+
38
+ /** A blocking finding as surfaced back to the fix prompt. */
39
+ export interface BlockingFinding {
40
+ file: string;
41
+ line?: number;
42
+ priority: string;
43
+ summary: string;
44
+ }
45
+
46
+ /** Extract P0/P1 findings from a parsed review_report JSON (lenient: any
47
+ * shape mismatch → no findings rather than a throw). Pure — unit-testable. */
48
+ export function blockingFindings(report: unknown): BlockingFinding[] {
49
+ if (!report || typeof report !== "object" || Array.isArray(report)) return [];
50
+ const raw = (report as { findings?: unknown }).findings;
51
+ if (!Array.isArray(raw)) return [];
52
+ const out: BlockingFinding[] = [];
53
+ for (const f of raw) {
54
+ if (!f || typeof f !== "object" || Array.isArray(f)) continue;
55
+ const rec = f as Record<string, unknown>;
56
+ if (rec.priority !== "P0" && rec.priority !== "P1") continue;
57
+ if (typeof rec.file !== "string" || rec.file.length === 0) continue;
58
+ // A decided outcome (a fix turn re-reports its findings with one, per
59
+ // the skill's fixed-later obligation) un-blocks the finding —
60
+ // re-prompting a fixed/skipped/declined finding just burns rounds.
61
+ if (typeof rec.outcome === "string" && (OUTCOME_VALUES as readonly string[]).includes(rec.outcome)) {
62
+ continue;
63
+ }
64
+ out.push({
65
+ file: rec.file,
66
+ line: typeof rec.line === "number" ? rec.line : undefined,
67
+ priority: rec.priority,
68
+ summary: typeof rec.summary === "string" ? rec.summary : "",
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Newest `*.json` report under `dir` modified after `sinceMs`, or null.
75
+ * Missing/unreadable dir → null. Pure — unit-testable. */
76
+ export function latestReportFile(dir: string, sinceMs: number): string | null {
77
+ let entries: fs.Dirent[];
78
+ try {
79
+ entries = fs.readdirSync(dir, { withFileTypes: true });
80
+ } catch {
81
+ return null;
82
+ }
83
+ let newest: { file: string; mtime: number } | null = null;
84
+ for (const e of entries) {
85
+ if (!e.isFile() || !e.name.endsWith(".json")) continue;
86
+ const file = path.join(dir, e.name);
87
+ try {
88
+ const mtime = fs.statSync(file).mtimeMs;
89
+ if (mtime <= sinceMs) continue;
90
+ if (!newest || mtime > newest.mtime) newest = { file, mtime };
91
+ } catch {
92
+ /* stat failed — skip this entry */
93
+ }
94
+ }
95
+ return newest?.file ?? null;
96
+ }
97
+
98
+ /** Parse a report JSON file; garbage → null (the loop must not crash on a
99
+ * half-written or hand-edited file). */
100
+ function readReport(file: string): unknown {
101
+ try {
102
+ return JSON.parse(fs.readFileSync(file, "utf8"));
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ // --- loop driver -------------------------------------------------------------
109
+
110
+ /** Minimal session view for the quiescence wait — structural, so
111
+ * ExtensionCommandContext satisfies it and tests can drive the logic
112
+ * without a live session. */
113
+ export interface QuiescenceView {
114
+ isIdle(): boolean;
115
+ hasPendingMessages(): boolean;
116
+ waitForIdle(): Promise<void>;
117
+ signal?: { aborted: boolean };
118
+ }
119
+
120
+ /** Wait until the session is fully quiescent: nothing running AND nothing
121
+ * queued. A single `waitForIdle()` is NOT enough — it resolves at the first
122
+ * idle point even while follow-ups are still queued (a queued message does
123
+ * not flip `isIdle` until its run actually starts), which is exactly the
124
+ * window between `sendUserMessage(…, followUp)` and that turn's first
125
+ * token. Aborts return false; there is no timeout — Esc is the escape
126
+ * hatch, same as for the bare waitForIdle call. */
127
+ export async function waitForQuiescent(session: QuiescenceView): Promise<boolean> {
128
+ for (;;) {
129
+ if (session.signal?.aborted) return false;
130
+ if (!session.isIdle() || session.hasPendingMessages()) {
131
+ await session.waitForIdle();
132
+ continue;
133
+ }
134
+ return true;
135
+ }
136
+ }
137
+
138
+ /** Poll until the review turn has started (idle → busy or a new assistant
139
+ * message appears), then wait for it to finish. Returns false on timeout
140
+ * or abort. Mirrors the reference extension's waitForLoopTurnToStart. */
141
+ async function waitForTurnSettled(ctx: ExtensionCommandContext, baselineAssistantId: string): Promise<boolean> {
142
+ const START_TIMEOUT_MS = 15_000;
143
+ const POLL_MS = 50;
144
+ const deadline = Date.now() + START_TIMEOUT_MS;
145
+
146
+ const lastAssistantId = (): string | undefined => {
147
+ const branch = ctx.sessionManager.getBranch();
148
+ for (let i = branch.length - 1; i >= 0; i--) {
149
+ const entry = branch[i]!;
150
+ if (entry.type === "message" && entry.message.role === "assistant") return entry.id;
151
+ }
152
+ return undefined;
153
+ };
154
+
155
+ while (Date.now() < deadline) {
156
+ if (ctx.signal?.aborted) return false;
157
+ const current = lastAssistantId();
158
+ if (!ctx.isIdle() || ctx.hasPendingMessages() || (current && current !== baselineAssistantId)) {
159
+ // Wait past every queued message, not merely to the next idle
160
+ // point — waitForIdle() alone returns inside the gap between
161
+ // queueing a followUp and its run actually starting.
162
+ return waitForQuiescent(ctx);
163
+ }
164
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
165
+ }
166
+ return false;
167
+ }
168
+
169
+ function baselineAssistantId(ctx: ExtensionCommandContext): string {
170
+ const branch = ctx.sessionManager.getBranch();
171
+ for (let i = branch.length - 1; i >= 0; i--) {
172
+ const entry = branch[i]!;
173
+ if (entry.type === "message" && entry.message.role === "assistant") return entry.id;
174
+ }
175
+ return "";
176
+ }
177
+
178
+ function fixPrompt(findings: BlockingFinding[]): string {
179
+ const list = findings
180
+ .map((f) => `- \`${f.file}${f.line != null ? `:${f.line}` : ""}\` [${f.priority}] ${f.summary}`)
181
+ .join("\n");
182
+ return [
183
+ "Fix the following blocking findings from the code review you just reported",
184
+ "(full failure scenarios are in the latest report JSON under .pi/review/):",
185
+ "",
186
+ list,
187
+ "",
188
+ "Apply minimal, surgical fixes — no drive-by refactors. Then re-report these",
189
+ "findings via the `review_report` tool with `outcome` set per finding",
190
+ "(fixed / skipped / no_change_needed) and run the verification guidance from",
191
+ "the review trigger message. Never leave the working tree verified-broken.",
192
+ ].join("\n");
193
+ }
194
+
195
+ function reReviewPrompt(level: string): string {
196
+ return [
197
+ `Fixes applied. Re-run the code-review SINGLE-PASS flow for effort ${level} now`,
198
+ "— the diff has changed: re-resolve it, re-check the fixed locations and",
199
+ "sweep for regressions or newly exposed issues, then report via the",
200
+ "`review_report` tool again (fresh findings list, empty array if clean).",
201
+ ].join("\n");
202
+ }
203
+
204
+ export interface LoopOptions {
205
+ /** The effort level the review runs at (echoed in re-review prompts). */
206
+ level: string;
207
+ /** Max fix→re-review rounds (config maxTurns.loop). */
208
+ passes: number;
209
+ /** Directory the review_report JSON files land in. */
210
+ reviewDir: string;
211
+ }
212
+
213
+ /**
214
+ * Drive the fix→re-review rounds after the FIRST review prompt has already
215
+ * been sent by the dispatcher. Each iteration reads the newest report (the
216
+ * first iteration sees the initial review's report, later ones the previous
217
+ * round's re-review) and sends at most one fix prompt. Runs at most `passes`
218
+ * fix→re-review rounds; the final iteration only reads the last re-review's
219
+ * verdict for the safety-limit message. Returns the number of fix rounds run.
220
+ */
221
+ export async function runLoopFixing(
222
+ pi: ExtensionAPI,
223
+ ctx: ExtensionCommandContext,
224
+ options: LoopOptions,
225
+ ): Promise<number> {
226
+ const { level, passes, reviewDir } = options;
227
+ const baseline = baselineAssistantId(ctx);
228
+ const loopStart = Date.now();
229
+
230
+ let fixes = 0;
231
+ for (;;) {
232
+ if (!(await waitForTurnSettled(ctx, baseline))) return fixes;
233
+
234
+ const reportFile = latestReportFile(reviewDir, loopStart);
235
+ if (reportFile === null) {
236
+ ctx.ui.notify("/code-review --loop: no review_report JSON found — stopping the loop.", "warning");
237
+ return fixes;
238
+ }
239
+ const findings = blockingFindings(readReport(reportFile));
240
+ if (findings.length === 0) {
241
+ ctx.ui.notify(
242
+ fixes === 0
243
+ ? "/code-review --loop: no P0/P1 findings — nothing to fix, loop done."
244
+ : `/code-review --loop: clean after ${fixes} fix round(s) — no open P0/P1 findings remain.`,
245
+ "info",
246
+ );
247
+ return fixes;
248
+ }
249
+ if (fixes === passes) {
250
+ ctx.ui.notify(
251
+ `/code-review --loop: ${findings.length} P0/P1 finding(s) still open after ${passes} fix round(s) — safety limit reached, stopping.`,
252
+ "warning",
253
+ );
254
+ return fixes;
255
+ }
256
+
257
+ fixes++;
258
+ ctx.ui.notify(
259
+ `/code-review --loop: ${findings.length} blocking finding(s) — fixing (round ${fixes}/${passes})…`,
260
+ "info",
261
+ );
262
+ await pi.sendUserMessage(fixPrompt(findings), { deliverAs: "followUp" });
263
+ if (!(await waitForTurnSettled(ctx, baseline))) return fixes;
264
+ pi.sendUserMessage(reReviewPrompt(level), { deliverAs: "followUp" });
265
+ }
266
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * src/strategy.ts — declarative parallel-strategy evaluation.
3
+ *
4
+ * v1's decideSimplifyMode hardcoded its thresholds in the command handler;
5
+ * v2 declares them as data in the prompt templates' frontmatter and this
6
+ * module evaluates the declared guards against runtime variables. Same
7
+ * semantics, different authority: editing a template's `parallel-when`
8
+ * block changes the strategy with no code change.
9
+ *
10
+ * Fallback-when-unguarded rules (v1 semantics, kept in code because they
11
+ * are safety invariants, not tuning knobs): unmeasurable context and an
12
+ * unavailable fan-out tool always select single-pass.
13
+ */
14
+
15
+ /** Guards declared in a template's `parallel-when` frontmatter block. */
16
+ export interface ParallelGuards {
17
+ /** Select single-pass when context usage is at or above this fraction. */
18
+ readonly contextBelow?: number;
19
+ /** Select single-pass when the diff reaches this many chars. */
20
+ readonly diffCharsBelow?: number;
21
+ }
22
+
23
+ /** Runtime variables gathered by the dispatcher. */
24
+ export interface StrategyRuntime {
25
+ readonly tokens: number | null;
26
+ readonly contextWindow: number;
27
+ readonly diffChars: number;
28
+ /** Whether fan-out tools are registered in this process (the recursion
29
+ * guard): single-pass is the only option when false. */
30
+ readonly fanoutAvailable: boolean;
31
+ }
32
+
33
+ export type ReviewVariant = "parallel" | "single-pass";
34
+
35
+ /**
36
+ * Evaluate the declared guards. Returns the variant plus the reasons that
37
+ * produced it (rendered into the trigger message so the decision stays
38
+ * observable). Pure — unit-testable.
39
+ */
40
+ export function selectVariant(
41
+ guards: ParallelGuards,
42
+ runtime: StrategyRuntime,
43
+ ): { variant: ReviewVariant; reasons: string[] } {
44
+ const { tokens, contextWindow, diffChars, fanoutAvailable } = runtime;
45
+ const reasons: string[] = [];
46
+ // Conservative safety invariant (not a tunable): if we can't measure
47
+ // context (tokens unknown / window 0), don't risk fan-out.
48
+ if (tokens == null || contextWindow <= 0) reasons.push("context usage unknown");
49
+ if (
50
+ guards.contextBelow != null &&
51
+ tokens != null &&
52
+ contextWindow > 0 &&
53
+ tokens / contextWindow >= guards.contextBelow
54
+ )
55
+ reasons.push(`context ${Math.round((tokens / contextWindow) * 100)}% full`);
56
+ if (guards.diffCharsBelow != null && diffChars >= guards.diffCharsBelow)
57
+ reasons.push(`diff too large (${Math.round(diffChars / 1024)} KB ≥ fan-out threshold)`);
58
+ if (!fanoutAvailable) reasons.push("fan-out unavailable in this context (subagent recursion guard)");
59
+ return { variant: reasons.length > 0 ? "single-pass" : "parallel", reasons };
60
+ }
61
+
62
+ /** Parse the `parallel-when` block out of a template's frontmatter map.
63
+ * Unknown/garbage fields are dropped (silent — garbage becomes absent). */
64
+ export function parseGuards(frontmatter: Record<string, unknown>): ParallelGuards {
65
+ const raw = frontmatter["parallel-when"];
66
+ if (!raw || typeof raw !== "object") return {};
67
+ const r = raw as Record<string, unknown>;
68
+ const out: { contextBelow?: number; diffCharsBelow?: number } = {};
69
+ if (typeof r["context-below"] === "number" && r["context-below"] > 0 && r["context-below"] <= 1) {
70
+ out.contextBelow = r["context-below"];
71
+ }
72
+ if (typeof r["diff-chars-below"] === "number" && r["diff-chars-below"] > 0) {
73
+ out.diffCharsBelow = r["diff-chars-below"];
74
+ }
75
+ return out;
76
+ }