@fyeeme/pi-review 2.0.1 → 2.1.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/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
+ }
@@ -31,12 +31,19 @@ import * as path from "node:path";
31
31
  const VERDICT_VALUES = ["CONFIRMED", "PLAUSIBLE"] as const;
32
32
  const Verdict = StringEnum(VERDICT_VALUES);
33
33
 
34
- const OUTCOME_VALUES = ["fixed", "skipped", "no_change_needed"] as const;
35
34
  /** CC ReportFindings `outcome` 三档(2.1.227 二进制实证)。fixed-later 再上报时更新。 */
35
+ export const OUTCOME_VALUES = ["fixed", "skipped", "no_change_needed"] as const;
36
36
  const Outcome = StringEnum(OUTCOME_VALUES);
37
37
 
38
+ /** --loop 的 blocking 阈值:P0/P1 触发修复→再评审一轮(P2/P3 只入报告)。 */
39
+ const PRIORITY_VALUES = ["P0", "P1", "P2", "P3"] as const;
40
+ const Priority = StringEnum(PRIORITY_VALUES, {
41
+ description:
42
+ "优先级 P0(阻断,立刻修)/ P1(高)/ P2(中)/ P3(低)。--loop 循环修复以 P0/P1 为 blocking 阈值;省略视为 P2。",
43
+ });
44
+
38
45
  // 供 SKILL-schema 同步测试引用(防漂移:SKILL 流程契约不得与常量脱节)。
39
- export { OUTCOME_VALUES, VERDICT_VALUES };
46
+ export { PRIORITY_VALUES, VERDICT_VALUES };
40
47
 
41
48
  const Level = StringEnum([
42
49
  "low",
@@ -59,6 +66,7 @@ const FindingParams = Type.Object({
59
66
  "产生该发现的角度 slug:correctness / reuse / simplification / efficiency / altitude / conventions(或更具体如 test-coverage)。",
60
67
  }),
61
68
  verdict: Type.Optional(Verdict),
69
+ priority: Type.Optional(Priority),
62
70
  short_summary: Type.Optional(
63
71
  Type.String({
64
72
  description:
@@ -91,6 +99,31 @@ const ReviewReportParams = Type.Object({
91
99
  ),
92
100
  });
93
101
 
102
+ /**
103
+ * 结果 schema——与落盘 `.pi/review/*.json` 对象同构(design D2 单一事实源):
104
+ * `structuredContent` 与磁盘 JSON 消费方(codemode 脚本 vs CI/--fix)观察同值。
105
+ * 字段单一来源:复用 FindingParams.properties(入参与出参同形,仅追加 note)。
106
+ */
107
+ const FindingOutput = Type.Object({
108
+ ...FindingParams.properties,
109
+ /** normalize 附注(非法 outcome 归一化说明),仅详情渲染用。 */
110
+ note: Type.Optional(Type.String()),
111
+ });
112
+
113
+ const ReviewReportOutput = Type.Object({
114
+ level: Level,
115
+ reportId: Type.Union([Type.String(), Type.Null()]),
116
+ target: Type.Union([Type.String(), Type.Null()]),
117
+ filesChanged: Type.Union([Type.Number(), Type.Null()]),
118
+ fannedOut: Type.Union([Type.Boolean(), Type.Null()]),
119
+ generatedAt: Type.String(),
120
+ findings: Type.Array(FindingOutput),
121
+ });
122
+
123
+ /** Static 派生类型:reportData 的类型即出参 schema,类型层单一事实源(消除断言)。 */
124
+ type FindingOut = Static<typeof FindingOutput>;
125
+ type ReviewReportOut = Static<typeof ReviewReportOutput>;
126
+
94
127
  interface ReviewReportDetails {
95
128
  level: string;
96
129
  findingsCount: number;
@@ -108,6 +141,7 @@ type LooseFinding = {
108
141
  line?: number;
109
142
  category: string;
110
143
  verdict?: string;
144
+ priority?: string;
111
145
  short_summary?: string;
112
146
  summary: string;
113
147
  failure_scenario: string;
@@ -126,7 +160,10 @@ function sanitizeFinding(f: LooseFinding): { f: LooseFinding; note?: string } |
126
160
  note = `(outcome "${outcome}" 非法,已归一化为 skipped)`;
127
161
  outcome = "skipped";
128
162
  }
129
- return { f: { ...f, outcome }, note };
163
+ // 非法 priority 静默丢弃(降至未标注),不影响该条 finding 存活。
164
+ const priority =
165
+ f.priority !== undefined && (PRIORITY_VALUES as readonly string[]).includes(f.priority) ? f.priority : undefined;
166
+ return { f: { ...f, outcome, priority }, note };
130
167
  }
131
168
 
132
169
  /**
@@ -150,25 +187,13 @@ function normalizeFindings(findings: LooseFinding[]): { findings: LooseFinding[]
150
187
 
151
188
  // --- render -----------------------------------------------------------------
152
189
 
153
- interface FindingInput {
154
- file: string;
155
- line?: number;
156
- category: string;
157
- verdict?: string;
158
- short_summary?: string;
159
- summary: string;
160
- failure_scenario: string;
161
- outcome?: string;
162
- /** normalize 附注(如非法 outcome 归一化说明),仅渲染进详情块。 */
163
- note?: string;
164
- }
165
190
  interface ReportInput {
166
191
  level: string;
167
192
  target?: string;
168
193
  files_changed?: number;
169
194
  fanned_out?: boolean;
170
195
  reportId?: string;
171
- findings: FindingInput[];
196
+ findings: FindingOut[];
172
197
  }
173
198
 
174
199
  function fmtLoc(f: { file: string; line?: number }): string {
@@ -202,13 +227,14 @@ function renderReport(p: ReportInput): string {
202
227
  lines.push("|---|------|------|------|------|");
203
228
  for (let i = 0; i < p.findings.length; i++) {
204
229
  const f = p.findings[i]!;
205
- lines.push(`| ${i + 1} | ${escapeCell(f.verdict ?? "")} | ${escapeCell(f.category)} | ${escapeCell(fmtLoc(f))} | ${escapeCell(f.short_summary ?? f.summary)} |`);
230
+ const verdictCell = [f.priority, f.verdict].filter(Boolean).join(" · ");
231
+ lines.push(`| ${i + 1} | ${escapeCell(verdictCell)} | ${escapeCell(f.category)} | ${escapeCell(fmtLoc(f))} | ${escapeCell(f.short_summary ?? f.summary)} |`);
206
232
  }
207
233
  lines.push("");
208
234
  lines.push("**详情**");
209
235
  lines.push("");
210
236
  p.findings.forEach((f, i) => {
211
- const v = f.verdict ? ` *(${f.verdict})*` : "";
237
+ const v = [f.priority, f.verdict].filter(Boolean).length > 0 ? ` *(${[f.priority, f.verdict].filter(Boolean).join(" · ")})*` : "";
212
238
  const out = f.outcome ? `\n修复结果:\`${f.outcome}\`` : "";
213
239
  const note = f.note ? `\n${f.note}` : "";
214
240
  lines.push(`**${i + 1}. ${fmtLoc(f)} — ${f.category}**${v}`);
@@ -233,6 +259,9 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
233
259
  "Use `review_report` only when the code-review skill instructs reporting findings; otherwise follow the active output format.",
234
260
  ],
235
261
  parameters: ReviewReportParams,
262
+ // codemode 脚本/程序化调用方拿到 structuredContent(与磁盘 JSON 同构)而非文本;
263
+ // 模型侧行为(content/renderResult/落盘)不变。
264
+ outputSchema: ReviewReportOutput,
236
265
 
237
266
  // 主防御:schema 校验之前清洗非法值(模型路径下校验失败即 throw、工具不执行,
238
267
  // 因此 execute 内的防御对模型不可达)。返回符合 schema 的对象——非法 verdict
@@ -254,7 +283,38 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
254
283
  const { findings: cleaned, notes } = normalizeFindings(
255
284
  (params.findings ?? []) as unknown as LooseFinding[],
256
285
  );
257
- const findings: FindingInput[] = cleaned.map((f, i) => ({ ...f, note: notes.get(i) }));
286
+ // 仅含已赋值字段的 finding:显式 undefined 键会让磁盘 JSON(serialize 丢弃)与
287
+ // structuredContent(原样返回)键集分叉;条件赋值保证两个视图逐键一致。
288
+ const findings: FindingOut[] = cleaned.map((f, i) => {
289
+ const note = notes.get(i);
290
+ const out: FindingOut = {
291
+ file: f.file,
292
+ category: f.category,
293
+ summary: f.summary,
294
+ failure_scenario: f.failure_scenario,
295
+ };
296
+ if (f.line !== undefined) out.line = f.line;
297
+ // 值域由 sanitizeFinding/normalizeFindings 保证(非法值已剔除/归一化),枚举收窄安全。
298
+ if (f.verdict !== undefined) out.verdict = f.verdict as FindingOut["verdict"];
299
+ if (f.priority !== undefined) out.priority = f.priority as FindingOut["priority"];
300
+ if (f.short_summary !== undefined) out.short_summary = f.short_summary;
301
+ if (f.outcome !== undefined) out.outcome = f.outcome as FindingOut["outcome"];
302
+ if (note !== undefined) out.note = note;
303
+ return out;
304
+ });
305
+
306
+ // 单一事实源(design D2):同一对象写盘 + 作 structuredContent;类型即出参
307
+ // schema 的 Static 派生,无需断言。
308
+ const now = new Date();
309
+ const reportData: ReviewReportOut = {
310
+ level: params.level,
311
+ reportId: params.report_id ?? null,
312
+ target: params.target ?? null,
313
+ filesChanged: params.files_changed ?? null,
314
+ fannedOut: params.fanned_out ?? null,
315
+ generatedAt: now.toISOString(),
316
+ findings,
317
+ };
258
318
 
259
319
  const report = renderReport({
260
320
  level: params.level,
@@ -267,7 +327,6 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
267
327
 
268
328
  let outFile: string | null = null;
269
329
  let writeError: string | null = null;
270
- const now = new Date();
271
330
  try {
272
331
  const dir = path.join(ctx.cwd, CONFIG_DIR_NAME, "review");
273
332
  await fs.promises.mkdir(dir, { recursive: true });
@@ -276,19 +335,7 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
276
335
  const fp = path.join(dir, `${ts}-${safeId}.json`);
277
336
  await fs.promises.writeFile(
278
337
  fp,
279
- JSON.stringify(
280
- {
281
- level: params.level,
282
- reportId: params.report_id ?? null,
283
- target: params.target ?? null,
284
- filesChanged: params.files_changed ?? null,
285
- fannedOut: params.fanned_out ?? null,
286
- generatedAt: now.toISOString(),
287
- findings,
288
- },
289
- null,
290
- 2,
291
- ),
338
+ JSON.stringify(reportData, null, 2),
292
339
  { encoding: "utf-8", mode: 0o600 },
293
340
  );
294
341
  outFile = fp;
@@ -308,6 +355,7 @@ export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewRepo
308
355
  };
309
356
  return {
310
357
  content: [{ type: "text" as const, text: report + tail }],
358
+ structuredContent: reportData,
311
359
  details,
312
360
  };
313
361
  },