pi-pr-review 1.1.2

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,280 @@
1
+ /**
2
+ * review-table
3
+ *
4
+ * Renders the /pr-review final JSON response as a full, readable review in the TUI:
5
+ * header, verification, overview, strengths, a findings table (nit → P0) with
6
+ * per-finding details, correctness/security/performance notes, and the verdict.
7
+ *
8
+ * Only rewrites in interactive TUI mode. In print / json / rpc modes the raw JSON is
9
+ * left untouched so piping and automation keep a machine-readable payload.
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+
14
+ type Severity = "P0" | "P1" | "P2" | "P3" | "nit";
15
+
16
+ interface Finding {
17
+ title?: string;
18
+ body?: string;
19
+ severity?: string;
20
+ blocking?: boolean;
21
+ confidence_score?: number;
22
+ priority?: number | null;
23
+ code_location?: {
24
+ absolute_file_path?: string | null;
25
+ line_range?: { start?: number; end?: number };
26
+ side?: string | null;
27
+ commentable?: boolean;
28
+ } | null;
29
+ }
30
+
31
+ interface Review {
32
+ pr?: { number?: number | null; title?: string | null } | null;
33
+ verification?: string;
34
+ overview?: string;
35
+ strengths?: string[];
36
+ findings: Finding[];
37
+ notes?: { correctness?: string; security?: string; performance?: string } | null;
38
+ verdict?: string;
39
+ overall_correctness?: string;
40
+ overall_explanation?: string;
41
+ overall_confidence_score?: number;
42
+ }
43
+
44
+ type MessagePart = { type: string; text?: string };
45
+
46
+ function assistantText(message: { content?: MessagePart[] }): string {
47
+ if (!Array.isArray(message.content)) return "";
48
+ return message.content
49
+ .filter((p) => p.type === "text" && typeof p.text === "string")
50
+ .map((p) => p.text as string)
51
+ .join("");
52
+ }
53
+
54
+ function hasToolCall(message: { content?: MessagePart[] }): boolean {
55
+ return Array.isArray(message.content) && message.content.some((p) => p.type === "toolCall");
56
+ }
57
+
58
+ /** Extract the balanced {...} object starting at index `start` (string-literal aware). */
59
+ function sliceBalancedFrom(s: string, start: number): string | null {
60
+ let depth = 0;
61
+ let inStr = false;
62
+ let esc = false;
63
+ for (let i = start; i < s.length; i++) {
64
+ const c = s[i];
65
+ if (inStr) {
66
+ if (esc) esc = false;
67
+ else if (c === "\\") esc = true;
68
+ else if (c === '"') inStr = false;
69
+ continue;
70
+ }
71
+ if (c === '"') inStr = true;
72
+ else if (c === "{") depth++;
73
+ else if (c === "}") {
74
+ depth--;
75
+ if (depth === 0) return s.slice(start, i + 1);
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function isReviewShape(v: unknown): v is Review {
82
+ if (!v || typeof v !== "object") return false;
83
+ const r = v as Review;
84
+ return Array.isArray(r.findings) && (typeof r.overall_correctness === "string" || typeof r.verdict === "string");
85
+ }
86
+
87
+ /**
88
+ * Find the review JSON even if the model wrapped it in fences or prepended prose
89
+ * that itself contains braces. Scans every `{` in each source and returns the LAST
90
+ * valid review-shaped object (the real payload is normally last).
91
+ */
92
+ function parseReview(text: string): Review | null {
93
+ const sources: string[] = [];
94
+ const fenceRe = /```(?:json)?\s*([\s\S]*?)```/gi;
95
+ for (let m = fenceRe.exec(text); m; m = fenceRe.exec(text)) {
96
+ if (m[1]) sources.push(m[1]);
97
+ }
98
+ sources.push(text);
99
+
100
+ let best: Review | null = null;
101
+ for (const src of sources) {
102
+ for (let i = 0; i < src.length; i++) {
103
+ if (src[i] !== "{") continue;
104
+ const objStr = sliceBalancedFrom(src, i);
105
+ if (!objStr) continue;
106
+ try {
107
+ const parsed = JSON.parse(objStr);
108
+ if (isReviewShape(parsed)) best = parsed;
109
+ } catch {
110
+ /* not JSON starting here; keep scanning */
111
+ }
112
+ }
113
+ if (best) return best; // prefer a match from an earlier (more specific) source
114
+ }
115
+ return best;
116
+ }
117
+
118
+ const SEVERITY_RANK: Record<Severity, number> = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 };
119
+
120
+ function severityOf(f: Finding): Severity | null {
121
+ const raw = (f.severity ?? "").toString().trim().toLowerCase();
122
+ if (raw === "nit") return "nit";
123
+ if (/^p[0-3]$/.test(raw)) return raw.toUpperCase() as Severity;
124
+ if (typeof f.priority === "number" && f.priority >= 0 && f.priority <= 3) return `P${f.priority}` as Severity;
125
+ const m = (f.title ?? "").match(/\[?\s*(p[0-3]|nit)\s*\]?/i);
126
+ if (m) return m[1].toLowerCase() === "nit" ? "nit" : (m[1].toUpperCase() as Severity);
127
+ return null;
128
+ }
129
+
130
+ function severityLabel(f: Finding): string {
131
+ return severityOf(f) ?? "—";
132
+ }
133
+
134
+ function severityRank(f: Finding): number {
135
+ const s = severityOf(f);
136
+ return s ? SEVERITY_RANK[s] : 5;
137
+ }
138
+
139
+ function isBlocking(f: Finding): boolean {
140
+ if (typeof f.blocking === "boolean") return f.blocking;
141
+ const s = severityOf(f);
142
+ return s === "P0" || s === "P1";
143
+ }
144
+
145
+ /** Strip a leading [Pn]/[nit] tag from a title (severity is shown in its own column). */
146
+ function titleText(f: Finding): string {
147
+ return (f.title ?? "(untitled)").replace(/^\s*\[?\s*(?:p[0-3]|nit)\s*\]?\s*[-–:·]?\s*/i, "").trim() || "(untitled)";
148
+ }
149
+
150
+ function location(f: Finding): string {
151
+ const p = f.code_location?.absolute_file_path;
152
+ if (!p) return "—";
153
+ const lr = f.code_location?.line_range;
154
+ const side = (f.code_location?.side ?? "").toString().toUpperCase();
155
+ const sideSuffix = side === "LEFT" ? " (LEFT)" : "";
156
+ if (lr && lr.start != null) {
157
+ const end = lr.end != null && lr.end !== lr.start ? `-${lr.end}` : "";
158
+ return `${p}:${lr.start}${end}${sideSuffix}`;
159
+ }
160
+ return `${p}${sideSuffix}`;
161
+ }
162
+
163
+ /** Whether a finding carries enough diff-anchored data to post as an inline comment. */
164
+ function isCommentable(f: Finding): boolean {
165
+ const cl = f.code_location;
166
+ if (!cl || !cl.absolute_file_path) return false;
167
+ if (cl.commentable === false) return false;
168
+ return cl.line_range?.start != null;
169
+ }
170
+
171
+ function conf(n: number | undefined): string {
172
+ return typeof n === "number" && Number.isFinite(n) ? n.toFixed(2) : "—";
173
+ }
174
+
175
+ /** Escape a value for use inside a Markdown table cell. */
176
+ function cell(s: string): string {
177
+ return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
178
+ }
179
+
180
+ function verdictLine(r: Review): string {
181
+ const v = (r.verdict ?? "").toLowerCase();
182
+ const incorrect = /incorrect/i.test(r.overall_correctness ?? "");
183
+ let icon: string;
184
+ let label: string;
185
+ if (v === "approve" || (!v && !incorrect)) {
186
+ icon = "✅";
187
+ label = "Approve";
188
+ } else if (v === "request_changes" || (!v && incorrect)) {
189
+ icon = "❌";
190
+ label = "Request changes";
191
+ } else {
192
+ icon = "💬";
193
+ label = "Comment";
194
+ }
195
+ const parts = [`${icon} **${label}**`];
196
+ if (r.overall_explanation) parts.push(`— ${r.overall_explanation.trim()}`);
197
+ if (r.overall_confidence_score != null) parts.push(`_(confidence ${conf(r.overall_confidence_score)})_`);
198
+ return parts.join(" ");
199
+ }
200
+
201
+ function renderReviewMarkdown(r: Review): string {
202
+ const out: string[] = [];
203
+
204
+ // Header
205
+ const num = r.pr?.number;
206
+ const title = (r.pr?.title ?? "").toString().replace(/\r?\n/g, " ").trim();
207
+ if (num != null) out.push(`## Code Review — PR #${num}${title ? `: ${title}` : ""}`, "");
208
+ else out.push("## Code Review", "");
209
+
210
+ if (r.verification?.trim()) out.push(`**Verification:** ${r.verification.trim()}`, "");
211
+
212
+ if (r.overview?.trim()) out.push("### Overview", "", r.overview.trim(), "");
213
+
214
+ if (Array.isArray(r.strengths) && r.strengths.length > 0) {
215
+ out.push("### Strengths", "");
216
+ for (const s of r.strengths) out.push(`- ${String(s).replace(/^\s*-\s*/, "").trim()}`);
217
+ out.push("");
218
+ }
219
+
220
+ // Findings
221
+ const findings = [...r.findings].sort((a, b) => severityRank(a) - severityRank(b));
222
+ const blocking = findings.filter(isBlocking).length;
223
+ const nonBlocking = findings.length - blocking;
224
+ out.push(`### Findings — ${findings.length} (${blocking} blocking, ${nonBlocking} non-blocking)`, "");
225
+
226
+ if (findings.length === 0) {
227
+ out.push("_No issues found — nit through P0._", "");
228
+ } else {
229
+ const inlineCount = findings.filter(isCommentable).length;
230
+ out.push("| # | Sev | Blk | Inline | Finding | Location | Conf |", "|---|:--:|:--:|:--:|---|---|:--:|");
231
+ findings.forEach((f, i) => {
232
+ out.push(
233
+ `| ${i + 1} | ${severityLabel(f)} | ${isBlocking(f) ? "yes" : "—"} | ${isCommentable(f) ? "✎" : "—"} | ${cell(titleText(f))} | \`${cell(location(f))}\` | ${conf(f.confidence_score)} |`,
234
+ );
235
+ });
236
+ out.push("", `_✎ = has diff-anchored location postable as an inline comment (${inlineCount}/${findings.length})._`, "");
237
+ findings.forEach((f, i) => {
238
+ out.push(`#### ${i + 1}. [${severityLabel(f)}] ${cell(titleText(f))}`);
239
+ const anchor = isCommentable(f) ? "inline-ready" : "summary-only";
240
+ out.push(`\`${location(f)}\` · confidence ${conf(f.confidence_score)} · ${isBlocking(f) ? "blocking" : "non-blocking"} · ${anchor}`, "");
241
+ if (f.body?.trim()) out.push(f.body.trim(), "");
242
+ });
243
+ }
244
+
245
+ // Correctness / Security / Performance
246
+ const notes = r.notes;
247
+ const noteRows: string[] = [];
248
+ if (notes?.correctness?.trim()) noteRows.push(`- **Correctness:** ${notes.correctness.trim()}`);
249
+ if (notes?.security?.trim()) noteRows.push(`- **Security:** ${notes.security.trim()}`);
250
+ if (notes?.performance?.trim()) noteRows.push(`- **Performance:** ${notes.performance.trim()}`);
251
+ if (noteRows.length > 0) out.push("### Correctness / Security / Performance", "", ...noteRows, "");
252
+
253
+ // Verdict
254
+ out.push("### Verdict", "", verdictLine(r));
255
+
256
+ return out.join("\n").trimEnd();
257
+ }
258
+
259
+ export default function (pi: ExtensionAPI) {
260
+ pi.on("message_end", async (event, ctx) => {
261
+ // Keep raw JSON for automation; only prettify for interactive terminals.
262
+ if (ctx.mode !== "tui") return;
263
+ if (event.message.role !== "assistant") return;
264
+ if (hasToolCall(event.message)) return; // not the final text-only answer
265
+
266
+ const text = assistantText(event.message);
267
+ if (!text.trim()) return;
268
+
269
+ const review = parseReview(text);
270
+ if (!review) return; // not a /pr-review JSON payload — leave untouched
271
+
272
+ const nonText = (event.message.content as MessagePart[]).filter((p) => p.type !== "text");
273
+ return {
274
+ message: {
275
+ ...event.message,
276
+ content: [...nonText, { type: "text", text: renderReviewMarkdown(review) }],
277
+ },
278
+ };
279
+ });
280
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "pi-pr-review",
3
+ "version": "1.1.2",
4
+ "description": "Model-agnostic GitHub PR code review prompt for pi. Pass a PR number; it derives the base and head branches from the PR in the current repo, reviews the diff, and returns structured JSON findings.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "code-review",
8
+ "github",
9
+ "pull-request",
10
+ "prompt-template"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/10ego/pi-pr-review.git"
16
+ },
17
+ "peerDependencies": {
18
+ "@earendil-works/pi-ai": "*",
19
+ "@earendil-works/pi-coding-agent": "*",
20
+ "@earendil-works/pi-tui": "*",
21
+ "typebox": "*"
22
+ },
23
+ "pi": {
24
+ "prompts": [
25
+ "./prompts"
26
+ ],
27
+ "extensions": [
28
+ "./extensions"
29
+ ]
30
+ }
31
+ }
@@ -0,0 +1,215 @@
1
+ ---
2
+ description: Review a GitHub Pull Request and return a structured JSON review (nit → P0)
3
+ argument-hint: "<PR-NUM> [--comment]"
4
+ ---
5
+ You are acting as a senior code reviewer for pull request **#$1** in the GitHub repository of the current working directory.
6
+
7
+ Your job: fetch the PR, review the diff between its base branch and its head (merging) branch, and return **only** the structured JSON review defined under "OUTPUT FORMAT" at the end of this prompt.
8
+
9
+ **Review philosophy — surface everything in scope, then rank it.** Report *every* issue the author would plausibly want to know about, from trivial nits up to blocking defects. Do **not** silently discard minor issues, style, readability, naming, missing edge cases, or "worth confirming" observations — capture them as low-severity findings instead. Then let the **verdict** depend only on *blocking* issues, so a clean PR is still approved while its nits are still recorded. The only things you leave out are non-issues: something that is actually correct, pure speculation you cannot substantiate, or a subjective preference with no concrete benefit.
10
+
11
+ **Stay strictly in scope — review the PR, not the repository.** Every finding must be *caused by* or *directly relevant to* this PR's diff: the added/removed/modified lines and the code they **provably** affect. Do **not** flag pre-existing issues in code the PR does not touch, and do not turn this into a whole-codebase audit — if the same problem existed before this PR, leave it out. You may (and should) read surrounding files, callers, tests, and convention files for context or to confirm a finding, but reading them is not license to report unrelated problems you happen to see there. A cross-file finding is valid only when you can point to the specific code the change **provably** breaks or requires updating (e.g. a caller that must change because of this diff) — never on speculation that the change "might" affect something.
12
+
13
+ > **OUTPUT CONTRACT — read this twice.** Your *entire* final message is the single JSON object defined under **OUTPUT FORMAT**, and nothing else: no prose, no Markdown, no headings, no code fences, and **not** the human-readable review. The overview, strengths, verification, notes, and verdict are **fields inside that JSON**, not text you write out. A separate renderer turns the JSON into the formatted table/report for humans — if you write the report yourself instead of the JSON, it will **not** render and the review will be considered failed. Do all your analysis with tools, then emit only the JSON object.
14
+
15
+ Do **not** assume, name, or switch to any specific model. Model selection is configured by the user, never hardcoded here.
16
+
17
+ ### Reviewer topology (parallel tiered subagents, with inline fallback)
18
+
19
+ You are the **orchestrator**. You own all GitHub access, skip decisions, convention-file discovery, verification worktrees, final validation/classification, JSON emission, and optional comment posting. Subagents are read-only reviewers: they receive PR context from you and return candidate evidence only.
20
+
21
+ If the `review_subagents` batch tool is available, prefer it over multiple single-pass calls. Fetch PR metadata and the unified diff once, gather any relevant convention-file excerpts, then call `review_subagents` with shared `context`, `max_parallel`, and ordered `passes`. This guarantees bounded parallel fan-out instead of depending on whether the tool interface runs separate calls concurrently. Use these pass assignments:
22
+
23
+ | Pass id | Tier label | Scope |
24
+ |---------|------------|-------|
25
+ | `overview` | `light` | Step 3 overview, strengths, and high-level risk areas |
26
+ | `conventions-maintainability` | `medium` | Step 5 convention compliance, readability, maintainability, test gaps, and nits |
27
+ | `correctness` | `heavy` | Step 5 compile/parse, logic, error handling, lifecycle, concurrency, and edge-case defects |
28
+ | `security-performance` | `heavy` | Step 5 security vulnerabilities and performance regressions |
29
+
30
+ Call `review_subagents` with `{ context, max_parallel: 4, passes: [...] }`. If any pass returns `status: failed`, treat the review evidence as incomplete: rerun the failed pass with `review_subagent` or perform that pass inline before finalizing. Always put the PR title/description, relevant metadata, convention-file excerpts, and unified diff in shared `context` so subagents do not refetch anything.
31
+
32
+ If `review_subagents` is unavailable but `review_subagent` is available, run the same pass assignments as individual `review_subagent` calls; emit independent calls in the same turn when the interface supports parallel tool calls. If neither subagent tool is available, perform every pass yourself inline on the current session model.
33
+
34
+ Tier→model mapping is set with `/pr-review-config`; if a tier is unset the subagent falls back to the nearest configured tier, then the pi default model.
35
+
36
+ Arguments for this run: `$@`
37
+ - `$1` is the PR number (required).
38
+ - If the token `--comment` appears anywhere in the arguments, "comment mode" is ON. Otherwise it is OFF (analysis only, no writes to GitHub).
39
+ - If `--include-closed` or `--review-closed` appears anywhere in the arguments, review closed/merged PRs without asking for confirmation.
40
+
41
+ ---
42
+
43
+ ## Operating assumptions
44
+
45
+ - All tools are functional. Do not test tools or make exploratory/throwaway calls. Every tool call must have a clear purpose.
46
+ - Use the `gh` CLI (already authenticated) for all GitHub access. Do **not** use web fetch. `gh` auto-detects the repo from the current directory's git remote, so run commands from the cwd.
47
+ - Read what you need to review thoroughly: the diff is the primary artifact, but open surrounding files, callers, and convention files whenever it improves the review or lets you confirm a finding.
48
+ - **Never disturb the user's working tree.** Do not `git checkout`/`gh pr checkout`, do not modify tracked files, do not commit or push. Any verification happens in an isolated worktree (Step 6).
49
+ - Create a short todo list before you start, then work the steps in order.
50
+
51
+ ---
52
+
53
+ ## Step 1 — Resolve the PR and decide whether to review
54
+
55
+ The orchestrator (you) always runs these — subagents never call `gh`:
56
+
57
+ ```
58
+ gh pr view $1 --json number,title,body,state,isDraft,author,baseRefName,headRefName,headRefOid,mergeable,url,files,comments
59
+ gh pr diff $1
60
+ ```
61
+
62
+ `baseRefName` is the base branch, `headRefName` is the merging (head) branch, and `headRefOid` is the head commit SHA (needed for duplicate-review reconciliation, verification, and permalinks/inline comments). `gh pr diff $1` is the base↔head diff and is the review artifact you pass to every subagent as `context`.
63
+
64
+ **Non-open PR confirmation.** If `state` is not `OPEN` and neither `--include-closed` nor `--review-closed` was supplied, do **not** hard-skip and do **not** emit the review JSON yet. Pause and ask exactly one confirmation question: `PR #$1 is <state> (head <headRefOid>). Review it anyway? Reply yes, or rerun with --include-closed to proceed non-interactively.` This pre-review confirmation prompt is the only allowed non-JSON response. If the user confirms, continue from Step 2; if needed, rerun Step 1 first to refresh metadata/diff.
65
+
66
+ **Skip conditions.** Stop immediately (emit the empty-findings JSON with `verdict: "approve"`, `overall_correctness: "patch is correct"`, and an explanation noting the skip) if any is true:
67
+ - The PR is a draft (`isDraft` == true).
68
+ - The change obviously does not need review (automated/bot PR, or a trivial change that is clearly correct).
69
+ - A prior `pi-pr-review` summary comment exists with a hidden marker whose `headRefOid` exactly matches the current `headRefOid` (same PR head already reviewed). Do **not** skip for older markers with a different SHA, unmarked prior comments/reviews, or because the PR was AI-authored — review those normally.
70
+
71
+ **Duplicate-review reconciliation.** Prior comments are only authoritative when they contain the exact marker form `<!-- pi-pr-review: {"schema":1,"headRefOid":"<full-head-SHA>"} -->`. If the marker SHA differs from the current `headRefOid`, the PR has new commits since the previous review, so continue and review the current diff. If comments contain older unmarked review text, treat it as unknown/stale and continue; unmarked comments cannot prove the current head was reviewed.
72
+
73
+ Otherwise continue. For closed/merged PRs that were explicitly confirmed or allowed with `--include-closed`/`--review-closed`, review the fetched base↔head diff normally; in `--comment` mode, still anchor inline comments to `headRefOid`, but if GitHub rejects inline comments on a non-open PR, fold those findings into the summary comment instead.
74
+
75
+ ## Step 2 — Gather project convention files
76
+
77
+ List (do not dump contents yet) the repository convention files that could govern the changed files:
78
+ - The root convention file (`CLAUDE.md`, and/or `AGENTS.md` if present).
79
+ - Any convention file living in a directory that contains a file modified by this PR.
80
+
81
+ When you evaluate compliance for a given changed file, only apply convention files that share that file's path or a parent path. Read a convention file's contents when a changed file falls under its scope, and include the relevant rule excerpts (with file paths) in the shared context for the medium pass.
82
+
83
+ ## Step 3 — Overview & strengths (`light` reviewer)
84
+
85
+ Write a short **overview** (1–3 short paragraphs) of what the PR does and how, grounded in the diff and PR title/description — enough to understand author intent. Also collect a list of genuine **strengths** (good tests, nice consolidation, correct reuse of helpers, etc.) and any high-level risk areas for the specialist passes. Strengths are part of the output, and understanding intent is what lets you tell an intentional change from a bug.
86
+
87
+ When `review_subagents` is available, include this as the `overview` pass in the same batch as the Step 5 review passes instead of waiting for a separate sequential call.
88
+
89
+ ## Step 4 — (reserved)
90
+
91
+ ## Step 5 — Review passes (dispatch by tier, then merge results)
92
+
93
+ Run these independent passes over the diff, each on its tier reviewer (or inline if subagent tools are unavailable). Give every pass the shared PR context from Step 1 plus the relevant convention-file excerpts from Step 2, and instruct each pass to report issues **at every severity, including nits**, within its own scope. Do **not** ask every pass to audit everything; the objective boundaries below reduce duplicate work while preserving coverage.
94
+
95
+ When `review_subagents` is available, dispatch the Step 3 `overview` pass and these Step 5 passes in one batch with `max_parallel: 4`, then combine the ordered outputs:
96
+
97
+ 1. **Convention, readability & maintainability pass — `medium` reviewer.** Audit changed lines against the in-scope convention files from Step 2. Flag violations (quote the rule), softer deviations from documented style as nits, naming/dead-code/comment/typo issues, minor duplication, test gaps, and "worth confirming" observations (e.g. "no current callers — confirm intended", "confirm this generated file came from codegen not a hand-edit"). Do not duplicate deep correctness or security analysis unless needed to explain a convention/maintainability issue.
98
+ 2. **Bug & correctness pass — `heavy` reviewer.** Hunt for defects in the introduced code: compile/parse failures, logic errors, wrong results, off-by-one, error handling, resource/lifecycle, concurrency, and edge cases. Include lower-severity correctness smells and missing edge cases as P2/P3. Do not duplicate pure style nits covered by the medium pass.
99
+ 3. **Security & performance pass — `heavy` reviewer.** Look for security issues (injection, authz, secrets, unsafe deserialization) and performance regressions in the changed code. Note minor ones too. Do not duplicate ordinary correctness/readability findings unless they are security/performance relevant.
100
+
101
+ ## Step 6 — Verification (best-effort, orchestrator-owned, non-destructive)
102
+
103
+ Try to verify the change without touching the user's checkout. This is best-effort — if the repo has no obvious build/test, or it would be slow or unsafe, skip it and record what you could not verify. Do not delegate worktree creation, `gh`, or final verification status to subagents.
104
+
105
+ Safe recipe (adapt to the project's toolchain):
106
+
107
+ ```
108
+ git fetch origin pull/$1/head # fetch the PR head without switching branches
109
+ wt=$(mktemp -d)
110
+ git worktree add --detach "$wt" FETCH_HEAD
111
+ # in "$wt": run the project's build and the tests for the affected packages/dirs
112
+ git worktree remove --force "$wt" # always clean up
113
+ ```
114
+
115
+ Record in `verification` exactly what you ran and the result (e.g. "`go build ./...` ✅, `go test ./pkg/...` ✅ 130 passed"), or why verification was limited. Never push, never leave a worktree behind, never modify the primary working tree.
116
+
117
+ ## Step 7 — Validate, classify, and finalize (orchestrator-owned)
118
+
119
+ Merge duplicate candidate findings across passes, then for every remaining candidate finding: confirm it is real (read surrounding code if needed), verify it is PR-scoped, then assign a **severity** and whether it is **blocking**:
120
+
121
+ - `P0` — blocking. Will not compile/parse, or is definitely wrong regardless of input, or a clear security hole. Independent of assumptions.
122
+ - `P1` — blocking. Serious bug/logic/security flaw that will bite under realistic inputs or conditions; should be fixed before merge.
123
+ - `P2` — non-blocking. A real issue worth fixing (correctness smell, missing edge case, notable maintainability problem).
124
+ - `P3` — non-blocking. Minor improvement or low-impact concern.
125
+ - `nit` — non-blocking. Trivial: style, naming, comments, typos, tiny cleanups, "confirm intended" observations. Purely optional.
126
+
127
+ `blocking` is `true` only for `P0`/`P1`. Set `confidence_score` (0.0–1.0) to your validated confidence. Keep only true findings — drop anything that is actually correct, that you cannot substantiate, or that is **pre-existing and not introduced or provably affected by this PR**. Report every qualifying finding at every severity; do not stop early and do not collapse several distinct nits into one.
128
+
129
+ ---
130
+
131
+ ## Writing each finding (`title` + `body`)
132
+
133
+ - **title**: ≤ 80 chars, imperative, prefixed with the severity tag `[P0]` `[P1]` `[P2]` `[P3]` `[nit]`. Example: `[P1] Guard against nil map before write`, `[nit] Rename tmp to buf for clarity`.
134
+ - **body**: concise valid Markdown explaining *why* it matters and citing the file/lines/function. State up front any scenario, environment, or input required for it to bite, and match tone to real severity (never overstate a nit).
135
+ - No code chunk longer than 3 lines; wrap code in inline backticks or a fenced block. Matter-of-fact tone; no flattery, no accusation.
136
+ - `severity` must be one of `P0|P1|P2|P3|nit` and match the title tag; `blocking` matches the rule above.
137
+ - `code_location` must carry everything needed to post the finding as a GitHub **inline review comment** whenever the code it references is part of the diff:
138
+ - `absolute_file_path`: the file's **repo-relative** path exactly as it appears in the PR diff (this is GitHub's `path`, e.g. `pkg/store/cache.go`). Not an on-disk absolute path.
139
+ - `line_range`: the line numbers **on `side`** as they appear in the diff — new-file line numbers for `RIGHT`, old-file line numbers for `LEFT`. Compute them from the diff's `@@ -old,+new @@` hunk headers. Keep it tight; use `start == end` for a single line, and for a multi-line range `start` must be `< end` and inside the *same* hunk.
140
+ - `side`: `RIGHT` for added or context lines, `LEFT` for removed lines.
141
+ - `commentable`: `true` only when the cited lines lie **inside a diff hunk** (GitHub only accepts inline comments on diff lines). Set it to `false` — or set `code_location` to `null` — for observations about **this PR's impact** on unchanged code or callers elsewhere (e.g. a caller this diff requires updating, or "no current callers — confirm intended"); those go in the summary rather than inline. This is not a channel for unrelated pre-existing issues — those are out of scope (see "Stay strictly in scope").
142
+
143
+ ## Verdict
144
+
145
+ - `verdict`: `"approve"` when there are **no blocking (P0/P1) findings** — even if nits/P2/P3 remain. `"request_changes"` when any blocking finding exists. `"comment"` when you are only leaving non-blocking notes and prefer not to explicitly approve.
146
+ - `overall_correctness`: `"patch is correct"` only if existing code/tests will not break and there are no blocking defects; otherwise `"patch is incorrect"`. Non-blocking nits do not make a patch "incorrect".
147
+ - `notes`: one-line status for `correctness`, `security`, and `performance` (use `""` if nothing to say).
148
+
149
+ ---
150
+
151
+ ## Comment mode (only when `--comment` was passed)
152
+
153
+ Analysis-only is the default. When comment mode is ON, after finalizing, also post to the PR via `gh`. Your terminal reply is still the JSON below — comment mode only controls GitHub writes.
154
+
155
+ - Post **one summary review comment** with `gh pr comment $1 --body "..."` containing the overview, verification line, strengths, a findings table, and the verdict. End the comment body with this exact hidden reconciliation marker using the current PR head SHA: `<!-- pi-pr-review: {"schema":1,"headRefOid":"<headRefOid>"} -->`. This marker is what future runs use to skip only an already-reviewed identical head.
156
+ - Post **inline comments** for each blocking, `P2`, and `P3` finding whose `code_location.commentable` is `true`, anchored to the head SHA. Fold `nit`s and any non-commentable findings into the summary comment rather than spamming inline (you may still leave an inline `nit` when it is location-specific and useful). Never post duplicate inline comments for the same finding on the same `headRefOid`; comments attached to older SHAs do not make the current head a duplicate. Use the finding's own anchor fields:
157
+
158
+ ```
159
+ # single line (line_range.start == line_range.end)
160
+ gh api repos/{owner}/{repo}/pulls/$1/comments \
161
+ -f body='<comment>' -f commit_id='<headRefOid>' \
162
+ -f path='<absolute_file_path>' -F line=<line_range.end> -f side='<side>'
163
+
164
+ # multi-line (line_range.start < line_range.end)
165
+ gh api repos/{owner}/{repo}/pulls/$1/comments \
166
+ -f body='<comment>' -f commit_id='<headRefOid>' -f path='<absolute_file_path>' \
167
+ -F start_line=<line_range.start> -f start_side='<side>' \
168
+ -F line=<line_range.end> -f side='<side>'
169
+ ```
170
+
171
+ - `path` = `absolute_file_path`, `side`/`start_side` = the finding's `side`, and the line numbers come straight from `line_range`. If an inline post is rejected (e.g. the line is not part of the diff), fold that finding into the summary comment instead.
172
+ - For small self-contained fixes, include a ` ```suggestion ` block, but only if committing it fixes the issue entirely; preserve exact leading whitespace and add/remove no indentation unless that is the fix. For larger fixes, describe them in prose.
173
+ - When linking to code in a comment body, use this exact permalink form or GitHub Markdown won't render it: `https://github.com/{owner}/{repo}/blob/<full-head-SHA>/path/to/file#Lstart-Lend` — full commit SHA, matching repo, `#` after the filename, `Lstart-Lend` range, with a line of context on each side.
174
+ - If there are no findings, post the summary comment with the overview, verification, strengths, an "Approve — no issues found" verdict, and the same hidden reconciliation marker.
175
+
176
+ ---
177
+
178
+ ## OUTPUT FORMAT — your entire response MUST be exactly this JSON
179
+
180
+ Your final message must be **exactly one JSON object** matching the shape below and nothing else — no leading sentence like "Here is the review", no Markdown, no headings, no ``` fences, no trailing commentary. The first character you emit is `{` and the last is `}`. Put the narrative into the `overview`, `strengths`, `verification`, `notes`, and `verdict` fields; do not also write it as prose. (In an interactive terminal the JSON is rendered as a formatted review table; in print/json/rpc modes it stays raw JSON for automation.)
181
+
182
+ ```json
183
+ {
184
+ "pr": { "number": 0, "title": "<PR title>" },
185
+ "verification": "<what you built/ran and the result, or why verification was limited>",
186
+ "overview": "<1-3 short Markdown paragraphs describing what the PR does>",
187
+ "strengths": ["<Markdown bullet>", "<Markdown bullet>"],
188
+ "findings": [
189
+ {
190
+ "title": "<= 80 chars, imperative, prefixed with [P0]|[P1]|[P2]|[P3]|[nit]",
191
+ "severity": "P0",
192
+ "blocking": true,
193
+ "body": "<valid Markdown: why it matters, with file/line citations and the conditions to trigger it>",
194
+ "confidence_score": 0.0,
195
+ "code_location": {
196
+ "absolute_file_path": "<repo-relative path exactly as in the diff, or null>",
197
+ "line_range": { "start": 0, "end": 0 },
198
+ "side": "RIGHT",
199
+ "commentable": true
200
+ }
201
+ }
202
+ ],
203
+ "notes": { "correctness": "", "security": "", "performance": "" },
204
+ "verdict": "approve",
205
+ "overall_correctness": "patch is correct",
206
+ "overall_explanation": "<1-3 sentence justification for the verdict>",
207
+ "overall_confidence_score": 0.0
208
+ }
209
+ ```
210
+
211
+ - `verdict` is exactly `"approve"`, `"request_changes"`, or `"comment"`.
212
+ - `overall_correctness` is exactly `"patch is correct"` or `"patch is incorrect"`.
213
+ - Each finding's `severity` is one of `P0|P1|P2|P3|nit` and must match its `[..]` title tag; `blocking` is `true` only for `P0`/`P1`.
214
+ - `code_location` is diff-anchored so commentable findings can be posted inline: repo-relative `absolute_file_path`, `line_range` on `side` (new-file lines for `RIGHT`, old-file for `LEFT`), `side` = `RIGHT|LEFT`, and `commentable` = whether the lines are inside a diff hunk. Use `null` (or `commentable: false`) for repo-wide/out-of-diff observations.
215
+ - Capture findings at **every** severity — nits included. Return an empty `findings` array only when there is genuinely nothing to note, and still fill in `overview`, `strengths`, `notes`, and the verdict.