pi-pr-review 1.15.2 → 1.15.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.15.3](https://github.com/10ego/pi-pr-review/compare/v1.15.2...v1.15.3) (2026-08-25)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **review:** harden the nonempty refusal check and document the contract ([#92](https://github.com/10ego/pi-pr-review/issues/92)) ([e8339a0](https://github.com/10ego/pi-pr-review/commit/e8339a029b1d12722f941339d1e8164e6d1ef09d))
9
+
3
10
  ## [1.15.2](https://github.com/10ego/pi-pr-review/compare/v1.15.1...v1.15.2) (2026-08-24)
4
11
 
5
12
 
@@ -429,6 +429,8 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
429
429
  return { command: "pi", args };
430
430
  }
431
431
 
432
+ const NONEMPTY_CONTRACT_DESCRIPTION = "After a successful terminal stop, nonempty output is exact Markdown with no wrapper: the first nonblank line must be exactly `Review status: COMPLETE` with no leading or trailing whitespace. If you could not inspect required evidence for any reason, return the top-level exact line `Review status: INCOMPLETE` followed by a concise reason; INCOMPLETE and every other status are partial. COMPLETE must be followed in exact order by meaningful one-line `Overview: ...`, `Strengths: ...`, and `Risk areas: ...` sections, then either the exact standalone `NO FINDINGS.` line or one or more complete candidate blocks. Nonblank contract lines have no trailing spaces or tabs. Framing may use plain labels, bold labels, or an ATX label followed immediately by exactly one top-level value line; do not use list-indented or quoted contract lines. Candidate fields are in exact order and each appears once: title prefixed [P0], [P1], [P2], [P3], or [nit] (the tag must match severity); severity P0/P1/P2/P3/nit; meaningful why; location as repo-relative/path:positive-line or repo-relative/path:positive-start-positive-end (ordered), or repo-wide; side RIGHT/LEFT; in_diff yes/no; pr_related yes/no; confidence 0.0-1.0. Candidate labels may be plain/bold or use exactly one top-level `- ` list marker; no other indentation or list marker is accepted. A why value may continue only on lines beginning with exactly two spaces plus a non-list character until the next reserved field. Scalar and continuation values must not contain status/field/severity/sentinel productions, code fences, HTML/container wrappers, blockquotes, lists, or code indentation. Do not emit code fences, blockquotes, JSON, HTML/container wrappers, duplicate/out-of-order fields, arbitrary prose, or placeholder values (none, n/a, unavailable, unknown, skipped, error, review complete). Status/refusal/access/tool/model/error reasons belong in an INCOMPLETE status; inability discussed inside a valid candidate why is allowed. COMPLETE is the primary completion attestation; only obvious exact failure placeholders are rejected as defense-in-depth."
433
+
432
434
  const TIER_GUIDANCE: Record<Tier, string> = {
433
435
  light:
434
436
  "You are a fast overview reviewer. Produce a concise overview of what the change does and how, list genuine strengths, and note high-level risk areas worth closer specialist review. Do not deep-dive into defects.",
@@ -467,26 +469,27 @@ function buildSubagentSystemPrompt(tier: Tier, majorOnly = false, minorHygiene =
467
469
  lines.push(
468
470
  "Start from the supplied complete diff. Use repository tools only to substantiate a concrete candidate caused by that diff; never browse for unrelated issues or run broad repository audits/tests.",
469
471
  "Before your first tool call, identify the evidence needed for all current candidates. Issue independent reads/searches/checks together when the interface supports concurrent calls, grouped to avoid rereading the same file. Use at most one follow-up tool turn, only for a dependency revealed by the first evidence wave. This schedules validation efficiently but never permits skipping evidence needed to substantiate a finding.",
470
- "Return your findings as a concise Markdown list. For each finding include, on its own lines:",
471
- majorOnly
472
- ? "- title: an imperative summary prefixed with [P0], [P1], or [P2]"
473
- : "- title: an imperative summary prefixed with a severity tag [P0]|[P1]|[P2]|[P3]|[nit]",
474
- majorOnly
475
- ? "- severity: one of P0, P1, P2 (P0/P1 are blocking)"
476
- : "- severity: one of P0, P1, P2, P3, nit (P0/P1 are blocking)",
477
- "- why: the impact and the exact input/environment needed for it to bite",
478
- "- location: <repo-relative file path>:<start-end lines exactly as they appear in the diff> (or 'repo-wide')",
479
- "- side: RIGHT for added/context lines, LEFT for removed lines",
480
- "- in_diff: yes if those lines are inside the PR diff (so an inline comment can be posted), otherwise no",
481
- "- pr_related: yes only if this PR introduces or provably affects the issue (drop pre-existing/unrelated issues)",
482
- "- confidence: a float 0.0-1.0",
483
- // A nonempty completion contract lets the pass carry framing prose
484
- // (overview, strengths, risk) around its findings; the exact-match
485
- // sentinel would forbid that and strand the lane as partial.
486
472
  expectedOutput === "nonempty"
487
- ? majorOnly
488
- ? "If there are no substantiated P0-P2 findings, state that plainly in prose after your overview; do not emit the candidate fields."
489
- : "If there are genuinely no findings at any severity, state that plainly in prose after your overview; do not emit the candidate fields."
473
+ ? NONEMPTY_CONTRACT_DESCRIPTION
474
+ : "Return your findings as a concise Markdown list. For each finding include, on its own lines:",
475
+ expectedOutput === "nonempty"
476
+ ? "For nonempty output, begin with the exact first nonblank top-level line `Review status: COMPLETE` with no trailing whitespace; use the exact top-level `Review status: INCOMPLETE` plus a concise reason whenever required evidence cannot be inspected. COMPLETE then requires Overview, Strengths, and Risk areas in order, followed immediately by NO FINDINGS. or complete candidate blocks; do not add other prose."
477
+ : majorOnly
478
+ ? "- title: an imperative summary prefixed with [P0], [P1], or [P2]"
479
+ : "- title: an imperative summary prefixed with a severity tag [P0]|[P1]|[P2]|[P3]|[nit]",
480
+ ...(expectedOutput === "nonempty" ? [] : [
481
+ majorOnly
482
+ ? "- severity: one of P0, P1, P2 (P0/P1 are blocking)"
483
+ : "- severity: one of P0, P1, P2, P3, nit (P0/P1 are blocking)",
484
+ "- why: the impact and the exact input/environment needed for it to bite",
485
+ "- location: <repo-relative file path>:<start-end lines exactly as they appear in the diff> (or 'repo-wide')",
486
+ "- side: RIGHT for added/context lines, LEFT for removed lines",
487
+ "- in_diff: yes if those lines are inside the PR diff (so an inline comment can be posted), otherwise no",
488
+ "- pr_related: yes only if this PR introduces or provably affects the issue (drop pre-existing/unrelated issues)",
489
+ "- confidence: a float 0.0-1.0",
490
+ ]),
491
+ expectedOutput === "nonempty"
492
+ ? "After the exact COMPLETE status and three framing sections, the clean sentinel is one top-level standalone uppercase line `NO FINDINGS.`; never use `No findings.` or append a sentence. Candidate fields are top-level or use exactly one `- ` marker, with no arbitrary indentation; candidate `why` may use exactly-two-space non-list continuation lines, but never absorb a reserved field, status, sentinel, or wrapper."
490
493
  : majorOnly
491
494
  ? "If there are no substantiated P0-P2 findings, reply exactly with: NO FINDINGS."
492
495
  : "If there are genuinely no findings at any severity, reply exactly with: NO FINDINGS.",
@@ -1424,7 +1427,7 @@ const ReviewSubagentsParams = Type.Object({
1424
1427
  Type.Object({
1425
1428
  expected_output: Type.Optional(
1426
1429
  StringEnum(["review_lane", "nonempty"] as const, {
1427
- description: "Completion contract for this pass. review_lane (default): candidate-evidence fields or exactly NO FINDINGS. nonempty: any nonempty terminal output completes; use for an integrated pass that carries framing prose around its findings.",
1430
+ description: `Completion contract for this pass. review_lane (default): candidate-evidence fields or exactly NO FINDINGS. nonempty: ${NONEMPTY_CONTRACT_DESCRIPTION}`,
1428
1431
  }),
1429
1432
  ),
1430
1433
  id: Type.Optional(
@@ -114,34 +114,327 @@ function hasMeaningfulLightSection(text: string, field: string, fields: readonly
114
114
  .some((line) => line.replace(/^[ \\t]*(?:[-*>][ \\t]*)+/, "").trim().length > 0);
115
115
  }
116
116
 
117
+ /**
118
+ * The `nonempty` lane is a small Markdown grammar, not a prose detector. Its
119
+ * first nonblank line is the exact `Review status: COMPLETE` attestation;
120
+ * `INCOMPLETE` and every other status are partial. The remaining productions
121
+ * are documented in the public tool schema and both reviewer prompts: framing
122
+ * labels may be plain (`Overview:`), bold (`**Overview:**`), or ATX headings
123
+ * followed by one top-level value line; candidate fields may be plain/bold or
124
+ * use the one exact top-level `- ` list marker. Blockquotes, code fences, JSON,
125
+ * and other wrappers are not part of the contract. Nonblank contract lines do
126
+ * not carry trailing horizontal whitespace, and a `why` continuation is exactly
127
+ * two spaces plus a non-list line.
128
+ */
129
+ const CANDIDATE_FIELDS = ["title", "severity", "why", "location", "side", "in_diff", "pr_related", "confidence"] as const;
130
+ const CANDIDATE_SEVERITIES = new Set(["P0", "P1", "P2", "P3", "nit"]);
131
+ const FRAMING_LABELS = ["Overview", "Strengths", "Risk areas"] as const;
132
+ const PLACEHOLDER_ONLY = /^(?:none|n\/?a|na|unavailable|unknown|skipped|error|review complete|no findings|nothing to review)(?:\s+(?:identified|found|available|present))?[.!]?$/i;
133
+ const NO_FINDINGS_SENTINEL = "NO FINDINGS.";
134
+ const CODE_FENCE = /^ {0,3}(?:`{3,}|~{3,})/m;
135
+ const COMMONMARK_HTML_BLOCK_TAGS = [
136
+ "address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center", "col",
137
+ "colgroup", "dd", "details", "dialog", "dir", "div", "dl", "dt", "fieldset", "figcaption", "figure",
138
+ "footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hr",
139
+ "html", "iframe", "legend", "li", "link", "main", "menu", "menuitem", "nav", "noframes", "ol",
140
+ "optgroup", "option", "p", "param", "search", "section", "summary", "table", "tbody", "td", "tfoot",
141
+ "th", "thead", "title", "tr", "track", "ul",
142
+ ].join("|");
143
+ const COMMONMARK_BLANK_TERMINATED_HTML = new RegExp(
144
+ `^ {0,3}</?(?:${COMMONMARK_HTML_BLOCK_TAGS})(?:[ \\t]+|/?>|$)`,
145
+ "i",
146
+ );
147
+ // CommonMark type 7 accepts a complete open or closing tag for any tag name,
148
+ // including custom elements. Unlike types 1–6, it cannot interrupt a paragraph.
149
+ const COMMONMARK_COMPLETE_HTML_TAG = /^ {0,3}(?:<[A-Za-z][A-Za-z0-9-]*(?:[ \t]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \t]*=[ \t]*(?:[^ "'=<>`\u0000-\u0020]+|'[^']*'|"[^"]*"))?)*[ \t]*\/?>|<\/[A-Za-z][A-Za-z0-9-]*[ \t]*>)[ \t]*$/;
150
+ // These are CommonMark HTML block openers. The opener itself is sufficient:
151
+ // an assistant must not be able to hide later contract lines behind an
152
+ // unterminated comment, declaration, raw block tag, or other container.
153
+ function htmlBlockOpener(line: string): boolean {
154
+ return /^ {0,3}<(?:script|pre|style|textarea)(?:[ \t]+|>|$)/i.test(line) ||
155
+ /^ {0,3}<!--/.test(line) ||
156
+ /^ {0,3}<\?/.test(line) ||
157
+ /^ {0,3}<![A-Z]/i.test(line) ||
158
+ /^ {0,3}<!\[CDATA\[/i.test(line) ||
159
+ COMMONMARK_BLANK_TERMINATED_HTML.test(line) ||
160
+ COMMONMARK_COMPLETE_HTML_TAG.test(line);
161
+ }
162
+
163
+ function hasHtmlContainer(text: string): boolean {
164
+ return text.split("\n").some((line) => htmlBlockOpener(line));
165
+ }
166
+
167
+ const CONTAINER_PREFIX = /^(?:[-*+>]|#{1,6})[ \t]+/;
168
+ const RESERVED_LABEL_PRODUCTION = /(?:^|[ \t])(?:\*\*|__)?(?:Overview|Strengths|Risk areas|title|severity|why|location|side|in_diff|pr_related|confidence)(?:\*\*|__)?[ \t]*:/i;
169
+ const SEVERITY_TAG_PRODUCTION = /\[P[0-3]\]/gi;
170
+
171
+ interface MarkdownLabel {
172
+ readonly field: string;
173
+ readonly value: string;
174
+ readonly kind: "inline" | "heading";
175
+ }
176
+
177
+ interface CandidateBlock {
178
+ readonly start: number;
179
+ readonly end: number;
180
+ readonly fields: ReadonlyMap<string, string>;
181
+ }
182
+
183
+ function normalizeReviewText(text: string): string {
184
+ // Only CRLF is transport normalization. In particular, do not trim or
185
+ // rewrite line-leading whitespace before applying the grammar.
186
+ return text.replace(/\r\n/g, "\n");
187
+ }
188
+
189
+ function hasTrailingHorizontalWhitespace(text: string): boolean {
190
+ return text.split("\n").some((line) => /[ \t]+$/.test(line));
191
+ }
192
+
193
+ function escapePattern(value: string): string {
194
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
195
+ }
196
+
197
+ /** Parse the exact framing forms; headings are accepted only for framing. */
198
+ function framingLabel(line: string): MarkdownLabel | undefined {
199
+ if (/^[ \t]/.test(line)) return undefined;
200
+ let match = /^\*\*(Overview|Strengths|Risk areas):\*\*[ \t]*(.*)$/.exec(line);
201
+ if (match) return { field: match[1]!, value: match[2] ?? "", kind: "inline" };
202
+ match = /^__(Overview|Strengths|Risk areas):__[ \t]*(.*)$/.exec(line);
203
+ if (match) return { field: match[1]!, value: match[2] ?? "", kind: "inline" };
204
+ match = /^(Overview|Strengths|Risk areas):[ \t]*(.*)$/.exec(line);
205
+ if (match) return { field: match[1]!, value: match[2] ?? "", kind: "inline" };
206
+ match = /^#{1,6}[ \t]+(?:(?:\*\*)(Overview|Strengths|Risk areas):(?:\*\*)|(?:__)(Overview|Strengths|Risk areas):(?:__)|(Overview|Strengths|Risk areas):?)[ \t]*$/.exec(line);
207
+ if (match) return { field: (match[1] ?? match[2] ?? match[3])!, value: "", kind: "heading" };
208
+ return undefined;
209
+ }
210
+
211
+ /** Candidate fields are top-level one-line values with optional bold syntax or one exact `- ` marker. */
212
+ function candidateLabel(line: string): MarkdownLabel | undefined {
213
+ const names = CANDIDATE_FIELDS.map(escapePattern).join("|");
214
+ const match = new RegExp(`^(?:- )?(?:(?:\\*\\*)(${names}):(?:\\*\\*)|(?:__)(${names}):(?:__)|(${names}):)[ \t]*(.*)$`).exec(line);
215
+ if (!match) return undefined;
216
+ return { field: (match[1] ?? match[2] ?? match[3])!, value: match[4] ?? "", kind: "inline" };
217
+ }
218
+
219
+ function canonicalField(field: string): string {
220
+ return field.toLowerCase();
221
+ }
222
+
223
+ function meaningfulValue(value: string): boolean {
224
+ const normalized = value.trim();
225
+ const words = normalized.match(/[\p{L}\p{N}]+/gu) ?? [];
226
+ return words.length >= 2 && !PLACEHOLDER_ONLY.test(normalized) &&
227
+ !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized);
228
+ }
229
+
230
+ function confidenceValue(value: string): boolean {
231
+ const normalized = value.trim();
232
+ if (!/^(?:0(?:\.\d+)?|1(?:\.0+)?)$/.test(normalized)) return false;
233
+ const number = Number(normalized);
234
+ return Number.isFinite(number) && number >= 0 && number <= 1;
235
+ }
236
+
237
+ /** Validate the prompt's repo-relative/path:positive-line-range location syntax. */
238
+ function safeLocation(value: string): boolean {
239
+ const normalized = value.trim();
240
+ if (normalized === "repo-wide") return true;
241
+ const match = /^(.+):(\d+)(?:-(\d+))?$/.exec(normalized);
242
+ if (!match) return false;
243
+ const locationPath = match[1]!;
244
+ const start = Number(match[2]);
245
+ const end = match[3] === undefined ? start : Number(match[3]);
246
+ if (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(end) || end < start) return false;
247
+ if (!locationPath || locationPath.startsWith("/") || locationPath.startsWith("~") || /^[A-Za-z]:/.test(locationPath)) return false;
248
+ if (/[\\\u0000-\u001f\u007f]/.test(locationPath)) return false;
249
+ const segments = locationPath.split("/");
250
+ return segments.length > 0 && segments.every((segment) => segment.length > 0 && segment === segment.trim() && segment !== "." && segment !== ".." && !segment.includes(":"));
251
+ }
252
+
253
+ function hasReservedStatusProduction(line: string): boolean {
254
+ return /^(?:[-*+>][ \t]+)?Review status\s*:/i.test(line.trim());
255
+ }
256
+
257
+ /**
258
+ * Values are prose, but they may not contain a line-shaped contract
259
+ * production. This is deliberately limited to the documented grammar (and
260
+ * containers), rather than an English keyword denylist. Every line is
261
+ * checked independently so a multiline `why` cannot hide a fence or an
262
+ * unterminated HTML block opener in a continuation.
263
+ */
264
+ function hasReservedContractProduction(value: string): boolean {
265
+ if (!value) return false;
266
+ if (value.includes(NO_FINDINGS_SENTINEL) || /Review status\s*:/i.test(value) || RESERVED_LABEL_PRODUCTION.test(value)) return true;
267
+ if (hasHtmlContainer(value)) return true;
268
+ return value.split("\n").some((line) => {
269
+ const structural = line.trim();
270
+ return CODE_FENCE.test(line) || htmlBlockOpener(line) ||
271
+ structural === NO_FINDINGS_SENTINEL || hasReservedStatusProduction(structural) ||
272
+ !!framingLabel(structural) || !!candidateLabel(structural) || CONTAINER_PREFIX.test(structural);
273
+ });
274
+ }
275
+
276
+ function hasReservedCandidateProduction(field: string, value: string): boolean {
277
+ if (hasReservedContractProduction(value)) return true;
278
+ let allowedLeadingTitleTag = field === "title";
279
+ for (const [lineIndex, line] of value.split("\n").entries()) {
280
+ for (const match of line.matchAll(SEVERITY_TAG_PRODUCTION)) {
281
+ if (allowedLeadingTitleTag && lineIndex === 0 && match.index === 0) {
282
+ allowedLeadingTitleTag = false;
283
+ continue;
284
+ }
285
+ return true;
286
+ }
287
+ }
288
+ return false;
289
+ }
290
+
291
+ function validCandidateFields(fields: ReadonlyMap<string, string>): boolean {
292
+ if (CANDIDATE_FIELDS.some((field) => !fields.has(field))) return false;
293
+ if ([...fields.entries()].some(([field, value]) => hasReservedCandidateProduction(field, value))) return false;
294
+ const title = fields.get("title")!.trim();
295
+ const titleMatch = /^\[(P0|P1|P2|P3|nit)\][ \t]+(.+)$/.exec(title);
296
+ const severity = fields.get("severity")!.trim();
297
+ const why = fields.get("why")!;
298
+ const location = fields.get("location")!;
299
+ const side = fields.get("side")!.trim();
300
+ const inDiff = fields.get("in_diff")!.trim();
301
+ const prRelated = fields.get("pr_related")!.trim();
302
+ return !!titleMatch && meaningfulValue(titleMatch[2]!) &&
303
+ CANDIDATE_SEVERITIES.has(severity) && titleMatch[1] === severity &&
304
+ meaningfulValue(why) && safeLocation(location) &&
305
+ /^(?:RIGHT|LEFT)$/.test(side) &&
306
+ /^(?:yes|no)$/.test(inDiff) &&
307
+ /^(?:yes|no)$/.test(prRelated) &&
308
+ confidenceValue(fields.get("confidence")!);
309
+ }
310
+
311
+ function framingValueHasReservedProduction(value: string): boolean {
312
+ return hasReservedContractProduction(value);
313
+ }
314
+
315
+ function isReservedContractLine(line: string): boolean {
316
+ return hasReservedContractProduction(line);
317
+ }
318
+
319
+ /** Only obvious, deterministic placeholders are defense-in-depth; COMPLETE is the primary contract. */
320
+ function topLevelFailure(text: string): boolean {
321
+ return text.split("\n").some((line) => /^(?:internal server error|fatal error|access denied|review (?:failed|unavailable|skipped)|no (?:diff|patch|source context|review context)(?: was)? (?:provided|available|accessible))[.!]?$/i.test(line.trim()));
322
+ }
323
+
324
+ /**
325
+ * Parse and validate the integrated deep-lane grammar statefully. COMPLETE is
326
+ * an explicit reviewer attestation: every other status, a missing/later/
327
+ * wrapped/duplicate status, or a malformed production is partial. Framing has
328
+ * exactly one nonempty value line per field. Candidate `why` may have
329
+ * indented continuation lines; reserved productions cannot be continuations.
330
+ */
331
+ function parseIntegratedCompletion(text: string): boolean {
332
+ if (hasTrailingHorizontalWhitespace(text) || CODE_FENCE.test(text) || hasHtmlContainer(text)) return false;
333
+ const lines = text.split("\n");
334
+ let cursor = 0;
335
+ while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
336
+ if (cursor >= lines.length || lines[cursor] !== "Review status: COMPLETE") return false;
337
+ const statusIndex = cursor;
338
+ if (lines.some((line, index) => index !== statusIndex && hasReservedStatusProduction(line))) return false;
339
+ cursor++;
340
+ const framingValues: string[] = [];
341
+ for (const expected of FRAMING_LABELS) {
342
+ while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
343
+ const label = framingLabel(lines[cursor] ?? "");
344
+ if (!label || canonicalField(label.field) !== expected.toLowerCase()) return false;
345
+ cursor++;
346
+ let value = label.value;
347
+ if (label.kind === "heading") {
348
+ const valueLine = lines[cursor] ?? "";
349
+ if (!valueLine || !valueLine.trim() || /^[ \t]/.test(valueLine) || CONTAINER_PREFIX.test(valueLine) || isReservedContractLine(valueLine)) return false;
350
+ value = valueLine;
351
+ cursor++;
352
+ }
353
+ if (!meaningfulValue(value) || framingValueHasReservedProduction(value)) return false;
354
+ framingValues.push(value);
355
+ }
356
+ if (topLevelFailure(framingValues.join("\n"))) return false;
357
+
358
+ while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
359
+ if (cursor < lines.length && lines[cursor] === NO_FINDINGS_SENTINEL) {
360
+ cursor++;
361
+ while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
362
+ return cursor === lines.length;
363
+ }
364
+
365
+ const candidates: CandidateBlock[] = [];
366
+ while (cursor < lines.length) {
367
+ while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
368
+ if (cursor >= lines.length) break;
369
+ const start = cursor;
370
+ const first = candidateLabel(lines[cursor]!);
371
+ if (!first || canonicalField(first.field) !== "title") return false;
372
+ const fields = new Map<string, string>();
373
+ for (const expected of CANDIDATE_FIELDS) {
374
+ while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
375
+ const field = candidateLabel(lines[cursor] ?? "");
376
+ if (!field || canonicalField(field.field) !== expected || fields.has(expected)) return false;
377
+ if (hasReservedCandidateProduction(expected, field.value)) return false;
378
+ fields.set(expected, field.value);
379
+ cursor++;
380
+ if (expected !== "why") continue;
381
+ const whyLines = [field.value];
382
+ while (cursor < lines.length) {
383
+ const continuation = lines[cursor]!;
384
+ if (!continuation.trim()) break;
385
+ if (isReservedContractLine(continuation)) break;
386
+ if (!/^ {2}\S/.test(continuation) || /^ {2}(?:[-*+>]|#{1,6})[ \t]+/.test(continuation)) return false;
387
+ if (hasReservedCandidateProduction("why", continuation.slice(2))) return false;
388
+ whyLines.push(continuation.slice(2));
389
+ cursor++;
390
+ }
391
+ fields.set("why", whyLines.join("\n"));
392
+ }
393
+ if (!validCandidateFields(fields)) return false;
394
+ candidates.push({ start, end: cursor, fields });
395
+ }
396
+ return candidates.length > 0;
397
+ }
398
+
117
399
  function hasMeaningfulField(text: string, field: string): boolean {
118
- const match = new RegExp(`^[ \\t]*(?:[-*][ \\t]*)?${field}[ \\t]*:[ \\t]*(.+?)[ \\t]*\\r?$`, "im").exec(text);
400
+ const match = fieldPattern(field).exec(text);
119
401
  return !!match?.[1]?.trim();
120
402
  }
121
403
 
404
+ function fieldPattern(field: string): RegExp {
405
+ return new RegExp(`^[ \\t]*(?:[-*+][ \\t]*)?(?:#{1,6}[ \\t]*)?(?:\\*\\*|__)?${escapePattern(field)}(?:\\*\\*|__)?[ \\t]*:[ \\t]*(.+?)[ \\t]*\\r?$`, "im");
406
+ }
407
+
408
+ function hasMeaningfulLightSection(text: string, field: string, fields: readonly string[]): boolean {
409
+ const escapedFields = fields.map((candidate) => escapePattern(candidate)).join("|");
410
+ const heading = new RegExp(`^[ \\t]*(?:#{1,6}[ \\t]*)?${escapePattern(field)}[ \\t]*:?[ \\t]*(.*)\\r?$`, "im").exec(text);
411
+ if (!heading || heading.index === undefined) return false;
412
+ const inlineValue = heading[1] ?? "";
413
+ const bodyStart = heading.index + heading[0].length;
414
+ const nextHeading = new RegExp(`^[ \\t]*(?:#{1,6}[ \\t]*)?(?:${escapedFields})[ \\t]*:?[ \\t]*`, "im").exec(text.slice(bodyStart));
415
+ const body = nextHeading ? text.slice(bodyStart, bodyStart + nextHeading.index) : text.slice(bodyStart);
416
+ return `${inlineValue}\n${body}`
417
+ .split("\n")
418
+ .some((line) => line.replace(/^[ \\t]*(?:[-*>][ \\t]*)+/, "").trim().length > 0);
419
+ }
420
+
122
421
  function expectedLaneSections(input: ReviewLaneCompletionInput): boolean {
123
- const text = input.rawText.trim();
124
- if (!text) return false;
125
- // The nonempty contract accepts framing prose around findings, but not
126
- // degenerate refusals: the lane must either carry candidate evidence or a
127
- // substantive statement (overview/no-findings text), not a bare refusal.
128
- if (input.expectedOutput === "nonempty") {
129
- if (["title", "severity", "why", "overview"].some((field) => hasMeaningfulField(text, field))) return true;
130
- return text.length >= 16 && !/^(?:i could not|unable to|cannot|i cannot|error|failed)[^.]*\.*$/i.test(text);
131
- }
422
+ const normalized = normalizeReviewText(input.rawText);
423
+ if (!normalized.trim()) return false;
424
+ if (input.expectedOutput === "nonempty") return parseIntegratedCompletion(normalized);
425
+ const text = normalized.trim();
132
426
  if (input.tier === "light") {
133
427
  const fields = input.minorHygiene ? ["overview", "strengths", "minor candidates"] : ["overview", "strengths"];
134
428
  return fields.every((field) => hasMeaningfulLightSection(text, field, fields));
135
429
  }
136
- if (text === "NO FINDINGS.") return true;
137
- return ["title", "severity", "why", "location", "side", "in_diff", "pr_related", "confidence"]
138
- .every((field) => hasMeaningfulField(text, field));
430
+ if (text === NO_FINDINGS_SENTINEL) return true;
431
+ return CANDIDATE_FIELDS.every((field) => hasMeaningfulField(text, field));
139
432
  }
140
433
 
141
434
  /** Process exit is necessary but insufficient: only a terminal stop with valid lane output is complete. */
142
435
  export function classifyReviewLane(input: ReviewLaneCompletionInput): ReviewLaneLifecycle {
143
- const reason = input.stopReason?.toLowerCase();
144
- const error = input.errorMessage?.toLowerCase();
436
+ const reason = typeof input.stopReason === "string" ? input.stopReason.toLowerCase() : undefined;
437
+ const error = typeof input.errorMessage === "string" ? input.errorMessage.toLowerCase() : undefined;
145
438
  if (reason?.includes("timeout") || error?.includes("timed out") || error?.includes("timeout")) return "timed_out";
146
439
  const hasText = input.rawText.length > 0;
147
440
  if (input.exitCode === 0 && reason === "stop" && expectedLaneSections(input)) return "complete";
@@ -162,11 +455,16 @@ export class ReviewLaneArtifactRegistry {
162
455
  expect(generation: number, lanes: readonly ExpectedReviewLane[]): boolean {
163
456
  if (
164
457
  this.generation !== generation || lanes.length === 0 ||
165
- lanes.some((lane) => !lane.key || !new Set(["light", "medium", "heavy"]).has(lane.tier))
458
+ lanes.some((lane) => !lane.key || !new Set(["light", "medium", "heavy"]).has(lane.tier) ||
459
+ (lane.expectedOutput !== undefined && !new Set(["review_lane", "nonempty"]).has(lane.expectedOutput)))
166
460
  ) return false;
167
461
  for (const lane of lanes) {
168
462
  const existing = this.expectedLanes.get(lane.key);
169
- if (existing && (existing.tier !== lane.tier || existing.minorHygiene !== lane.minorHygiene)) return false;
463
+ if (existing && (
464
+ existing.tier !== lane.tier ||
465
+ existing.minorHygiene !== lane.minorHygiene ||
466
+ (existing.expectedOutput ?? "review_lane") !== (lane.expectedOutput ?? "review_lane")
467
+ )) return false;
170
468
  this.expectedLanes.set(lane.key, Object.freeze({ ...lane }));
171
469
  }
172
470
  return true;
@@ -664,14 +664,44 @@ function parsePersistedInvocation(value: unknown): ReviewInvocation | undefined
664
664
  function parsePersistedLaneArtifacts(value: unknown): readonly ReviewLaneArtifact[] | undefined {
665
665
  if (!Array.isArray(value)) return undefined;
666
666
  const lifecycles = new Set(["complete", "partial", "timed_out", "failed"]);
667
+ const tiers = new Set(["light", "medium", "heavy"]);
668
+ const deadlineKinds = new Set(["total", "synthesis"]);
669
+ const deadlineSources = new Set(["default", "user", "project"]);
670
+ const optional = (object: Record<string, unknown>, key: string, predicate: (value: unknown) => boolean): boolean =>
671
+ object[key] === undefined || predicate(object[key]);
672
+ const isFiniteNumber = (item: unknown): boolean => typeof item === "number" && Number.isFinite(item);
673
+ const isString = (item: unknown): boolean => typeof item === "string";
674
+ const validAttempt = (attempt: unknown): attempt is Record<string, unknown> => {
675
+ if (!isObject(attempt)) return false;
676
+ return Number.isInteger(attempt.ordinal) &&
677
+ (attempt.kind === undefined || new Set(["primary", "fallback", "nearest", "default"]).has(String(attempt.kind))) &&
678
+ typeof attempt.rawText === "string" && Number.isInteger(attempt.exitCode) && lifecycles.has(String(attempt.lifecycle)) &&
679
+ (typeof attempt.retryable === "boolean" || attempt.retryable === undefined) &&
680
+ optional(attempt, "requestedModel", isString) && optional(attempt, "observedModel", isString) &&
681
+ optional(attempt, "usedTier", (item) => tiers.has(String(item))) &&
682
+ optional(attempt, "processSignal", isString) && optional(attempt, "stopReason", isString) &&
683
+ optional(attempt, "errorMessage", isString) && optional(attempt, "deadlineExpired", (item) => deadlineKinds.has(String(item))) &&
684
+ optional(attempt, "elapsedMs", isFiniteNumber) && optional(attempt, "firstEventMs", isFiniteNumber) &&
685
+ optional(attempt, "firstAssistantMs", isFiniteNumber) && optional(attempt, "toolElapsedMs", isFiniteNumber) &&
686
+ optional(attempt, "toolCallCount", (item) => Number.isInteger(item)) && optional(attempt, "timedOut", (item) => typeof item === "boolean") &&
687
+ optional(attempt, "terminationGraceMs", isFiniteNumber) && optional(attempt, "forcedTermination", (item) => typeof item === "boolean") &&
688
+ optional(attempt, "deadlineMs", isFiniteNumber) && optional(attempt, "configuredDeadlineMs", isFiniteNumber);
689
+ };
667
690
  const lanes = value.filter((lane): lane is Record<string, unknown> => isObject(lane));
668
691
  if (lanes.length !== value.length || lanes.some((lane) =>
669
692
  !Number.isInteger(lane.generation) || typeof lane.key !== "string" || typeof lane.passId !== "string" ||
670
- !new Set(["light", "medium", "heavy"]).has(String(lane.tier)) ||
693
+ !tiers.has(String(lane.tier)) ||
671
694
  (lane.minorHygiene !== undefined && typeof lane.minorHygiene !== "boolean") || typeof lane.rawText !== "string" ||
672
- !Number.isInteger(lane.exitCode) || !lifecycles.has(String(lane.lifecycle)) || !Array.isArray(lane.attempts) ||
673
- lane.attempts.some((attempt) => !isObject(attempt) || !Number.isInteger(attempt.ordinal) ||
674
- typeof attempt.rawText !== "string" || !Number.isInteger(attempt.exitCode) || !lifecycles.has(String(attempt.lifecycle)))
695
+ !Number.isInteger(lane.exitCode) || !lifecycles.has(String(lane.lifecycle)) ||
696
+ !Array.isArray(lane.attempts) || lane.attempts.some((attempt) => !validAttempt(attempt)) ||
697
+ !optional(lane, "requestedModel", isString) || !optional(lane, "observedModel", isString) ||
698
+ !optional(lane, "processSignal", isString) || !optional(lane, "stopReason", isString) ||
699
+ !optional(lane, "errorMessage", isString) || !optional(lane, "deadlineExpired", (item) => deadlineKinds.has(String(item))) ||
700
+ !optional(lane, "firstEventMs", isFiniteNumber) || !optional(lane, "firstAssistantMs", isFiniteNumber) ||
701
+ !optional(lane, "startOffsetMs", isFiniteNumber) || !optional(lane, "endOffsetMs", isFiniteNumber) ||
702
+ !optional(lane, "fallbackBudgetRejected", (item) => typeof item === "boolean") ||
703
+ !optional(lane, "deadlineSource", (item) => deadlineSources.has(String(item))) ||
704
+ !optional(lane, "batchDeadlineMs", isFiniteNumber) || !optional(lane, "totalDeadlineMs", isFiniteNumber)
675
705
  )) return undefined;
676
706
  return lanes as unknown as readonly ReviewLaneArtifact[];
677
707
  }
@@ -800,7 +830,8 @@ export class CompletedReviewCache {
800
830
  const expectedLaneDescriptors = Array.isArray(value.expectedLaneDescriptors) &&
801
831
  value.expectedLaneDescriptors.length <= 200 && value.expectedLaneDescriptors.every((lane) =>
802
832
  isObject(lane) && typeof lane.key === "string" && !!lane.key &&
803
- new Set(["light", "medium", "heavy"]).has(String(lane.tier)) && typeof lane.minorHygiene === "boolean") &&
833
+ new Set(["light", "medium", "heavy"]).has(String(lane.tier)) && typeof lane.minorHygiene === "boolean" &&
834
+ (lane.expectedOutput === undefined || lane.expectedOutput === "review_lane" || lane.expectedOutput === "nonempty")) &&
804
835
  new Set(value.expectedLaneDescriptors.map((lane) => (lane as Record<string, unknown>).key)).size ===
805
836
  value.expectedLaneDescriptors.length
806
837
  ? value.expectedLaneDescriptors as unknown as ExpectedReviewLane[]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.15.2",
3
+ "version": "1.15.3",
4
4
  "description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and host-gated COMMENT or qualified APPROVE publishing.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -41,7 +41,7 @@ If the `review_subagents` batch tool is available, prefer it over multiple singl
41
41
 
42
42
  By default, and when `--balanced` is present, call `review_subagents` with `major_only: true`, `minor_hygiene: true`, and exactly these five passes: `overview`, `correctness`, `correctness-contracts`, `security-performance`, and `performance-resources`. Do **not** dispatch `conventions-maintainability`. For an ordinary diff use `max_parallel: 5`; for 200,000–399,999 byte multi-file diffs use `shard_count: 2` and `max_parallel: 10`; for diffs at least 400,000 bytes with at least three changed files use `shard_count: 3` and `max_parallel: 15`. The four heavy passes remain P0–P2-only. The overview remains context-only and may return at most three direct-diff P3/nit candidates; it must not use tools or conduct a repository audit.
43
43
 
44
- When `--deep` is present, call `review_subagents` with exactly one pass: id `deep-review`, `tier: heavy`, `tool_policy: configured`, `major_only: false`, `minor_hygiene: false`, `expected_output: "nonempty"`, and `max_parallel: 1`. The objective must be the integrated whole-PR review: read the complete diff and the PR's stated intent, explore the surrounding repository with configured tools (callers, tests, sibling implementations) as needed to confirm or refute each candidate, and return every substantiated finding from nit through P0 with severity proportional to real impact, in the reviewer's standard candidate-evidence field format (title, severity, why, location, side, confidence), preceded by a short overview of what the PR does, its strengths, and the highest-risk areas. When there are no findings, state that plainly in prose instead of emitting the fields — the nonempty completion contract accepts either shape. This pass replaces the Step 3 overview and every Step 5 lens: there is no separate overview lane and no sharding, whatever the diff size. You still own the terminal Markdown synthesis, verification handling, and final validation exactly as in any other mode.
44
+ When `--deep` is present, call `review_subagents` with exactly one pass: id `deep-review`, `tier: heavy`, `tool_policy: configured`, `major_only: false`, `minor_hygiene: false`, `expected_output: "nonempty"`, and `max_parallel: 1`. The objective must be the integrated whole-PR review: read the complete diff and the PR's stated intent, explore the surrounding repository with configured tools (callers, tests, sibling implementations) as needed to confirm or refute each candidate, and return every substantiated finding from nit through P0 with severity proportional to real impact in the reviewer's standard candidate-evidence field format. The exact Markdown completion contract is fail-closed and explicit: the first nonblank line MUST be the top-level byte-exact `Review status: COMPLETE` with no leading or trailing whitespace when all required evidence was inspected; if required evidence could not be inspected for any reason, return the top-level byte-exact `Review status: INCOMPLETE` followed by a concise reason, and every non-COMPLETE/missing/duplicate/later/wrapped status is partial. Nonblank contract lines must not have trailing spaces or tabs. After COMPLETE, require exactly this order: one meaningful nonempty-line `Overview: ...`, `Strengths: ...`, and `Risk areas: ...` section (plain labels, bold labels, or an ATX label followed immediately by one top-level value line are accepted; no list-indented, quoted, fenced, HTML, or other wrapped framing), then either one top-level standalone uppercase `NO FINDINGS.` line or one or more complete candidate blocks. Candidate fields are exactly once and in order: `title` prefixed `[P0]`, `[P1]`, `[P2]`, `[P3]`, or `[nit]` with a tag matching `severity`; `severity` from `P0|P1|P2|P3|nit`; meaningful `why` (which may continue only on lines beginning with exactly two spaces plus a non-list character until the next reserved field); `location` as a safe repository-relative path plus a positive line or ordered positive line range, or `repo-wide`; `side` as `RIGHT|LEFT`; `in_diff` and `pr_related` as `yes|no`; and `confidence` as a decimal from `0.0` through `1.0`. Candidate labels may be plain/bold or use exactly one top-level `- ` list marker; arbitrary indentation and other list markers are invalid. Scalar and continuation values must not contain embedded status/field/severity/sentinel productions, code fences, HTML/container wrappers, blockquotes, lists, or code indentation. Do not emit arbitrary prose, code fences, blockquotes, JSON, HTML/container wrappers, duplicate/out-of-order/reserved productions, or placeholder values (`none`, `n/a`, `unavailable`, `unknown`, `skipped`, `error`, `review complete`); status/refusal/access/tool/model/error reasons belong in INCOMPLETE, while inability discussed inside a valid candidate why is allowed. COMPLETE is the primary contract; only obvious exact failure placeholders are deterministic defense-in-depth rejection. This pass replaces the Step 3 overview and every Step 5 lens: there is no separate overview lane and no sharding, whatever the diff size. You still own the terminal Markdown synthesis, verification handling, and final validation exactly as in any other mode.
45
45
 
46
46
  When `--major-only` is present, use the same five passes and parallelism with `major_only: true` and `minor_hygiene: false`. This removes P3/nit exploration without removing any major correctness/security/resource lens.
47
47