@unifan/pi-review-zh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Mapping from pi-review reviewer ids → pi-subagents runtime agent names,
3
+ * plus per-child budgets for the token-lean workflowScript directive path.
4
+ *
5
+ * Agents live in `agents/*.md` and are registered via package.json
6
+ * `pi.subagents.agents` so pi-subagents discovers them as package agents
7
+ * (`pi-review.<id>`).
8
+ *
9
+ * Budget model (pi-subagents ≥0.41 workflowScript API): the top-level
10
+ * `subagent({ workflowScript })` call carries `context`/`timeoutMs` only;
11
+ * `turnBudget` and per-reviewer `toolBudget` are injected onto each
12
+ * `runs.all` / `runs.run` child item (child params override workflow
13
+ * defaults). `runs.run` rejects `tasks`/`chain`/`concurrency` but accepts
14
+ * `toolBudget`/`turnBudget`/`model`/`output`.
15
+ */
16
+
17
+ export const LEAN_AGENT_PACKAGE = "pi-review";
18
+
19
+ /** Runtime agent name for a reviewer id (e.g. bugbot → pi-review.bugbot). */
20
+ export function leanAgentName(reviewerId: string): string {
21
+ return `${LEAN_AGENT_PACKAGE}.${reviewerId}`;
22
+ }
23
+
24
+ /** Gate agent runtime name. */
25
+ export const LEAN_GATE_AGENT = leanAgentName("gate");
26
+
27
+ export interface ToolBudgetSpec {
28
+ soft: number;
29
+ hard: number;
30
+ }
31
+
32
+ export interface LeanBudgetSpec {
33
+ /** Per-child turn budget, injected onto each runs.all / runs.run item. */
34
+ turnBudget: { maxTurns: number; graceTurns: number };
35
+ /** Per-child tool budget for the default reviewer (injected per runs.all item). */
36
+ defaultToolBudget: ToolBudgetSpec;
37
+ /** Stricter per-child tool budget for history-context (injected per runs.all item). */
38
+ historyToolBudget: ToolBudgetSpec;
39
+ /** Gate child budgets (injected onto the runs.run("gate", ...) item). */
40
+ gateTurnBudget: { maxTurns: number; graceTurns: number };
41
+ gateToolBudget: ToolBudgetSpec;
42
+ /** Wall-clock timeout for the top-level workflowScript call (ms). */
43
+ timeoutMs: number;
44
+ }
45
+
46
+ /**
47
+ * Defaults (v0.7.1): reviewers 20→26 turns (field runs kept wrapping up
48
+ * partial at 20); the gate 6→16 turns / 5→14 soft tools — it now carries a
49
+ * verification duty on high-severity candidates (read the diff hunk + the
50
+ * touched file) and physically could not verify anything under the old
51
+ * budget. Wall clock 10→17 min to match.
52
+ */
53
+ export const LEAN_BUDGETS: LeanBudgetSpec = {
54
+ turnBudget: { maxTurns: 26, graceTurns: 2 },
55
+ defaultToolBudget: { soft: 20, hard: 32 },
56
+ historyToolBudget: { soft: 14, hard: 24 },
57
+ gateTurnBudget: { maxTurns: 16, graceTurns: 2 },
58
+ gateToolBudget: { soft: 14, hard: 20 },
59
+ timeoutMs: 1_000_000,
60
+ };
61
+
62
+ export function toolBudgetForReviewer(id: string): ToolBudgetSpec {
63
+ if (id === "history-context") return LEAN_BUDGETS.historyToolBudget;
64
+ return LEAN_BUDGETS.defaultToolBudget;
65
+ }
66
+
67
+ /** Merge optional config.budgets.turnBudget over defaults. */
68
+ export function resolveLeanBudgets(override?: {
69
+ turnBudget?: { maxTurns?: number; graceTurns?: number };
70
+ }): LeanBudgetSpec {
71
+ const base = { ...LEAN_BUDGETS, turnBudget: { ...LEAN_BUDGETS.turnBudget } };
72
+ if (override?.turnBudget?.maxTurns != null && override.turnBudget.maxTurns >= 1) {
73
+ base.turnBudget.maxTurns = Math.min(48, Math.floor(override.turnBudget.maxTurns));
74
+ }
75
+ if (override?.turnBudget?.graceTurns != null && override.turnBudget.graceTurns >= 0) {
76
+ base.turnBudget.graceTurns = Math.floor(override.turnBudget.graceTurns);
77
+ }
78
+ return base;
79
+ }
80
+
81
+ /** Append :thinking to a model id when thinking is set (gate path). */
82
+ export function withThinkingSuffix(model: string, thinking?: string): string {
83
+ if (!thinking || thinking === "off" || thinking === "false") return model;
84
+ const colon = model.lastIndexOf(":");
85
+ const known = ["minimal", "low", "medium", "high", "xhigh", "max", "min"];
86
+ if (colon !== -1 && known.includes(model.slice(colon + 1))) {
87
+ return `${model.slice(0, colon)}:${thinking}`;
88
+ }
89
+ return `${model}:${thinking}`;
90
+ }
91
+
92
+ /**
93
+ * Shared false-positive list (injected once into the directive).
94
+ * Wording constraint: this text is embedded verbatim in the gate task, which
95
+ * pi-subagents classifies for read-only vs implementation intent — keep it
96
+ * free of bare write verbs (modify/edit/implement/…) outside explicit
97
+ * prohibitions, or the read-only gate gets rejected at launch.
98
+ */
99
+ export const FALSE_POSITIVE_GUIDANCE = [
100
+ "Pre-existing issues on lines untouched by this change",
101
+ "Pedantic nitpicks a senior engineer would not call out",
102
+ "Issues a linter, typechecker, or CI would catch",
103
+ "Generic quality (missing tests/docs) unless a project rule explicitly requires it",
104
+ "Something that looks like a bug but is intentional given the change",
105
+ ].join("; ");
package/src/pr-ref.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Extract a GitHub PR reference from freeform user input (CC-style).
3
+ */
4
+
5
+ /** Normalize punctuation that often appears when typing PR URLs in CJK IME. */
6
+ export function normalizeUserInput(text: string): string {
7
+ return text.replace(/[,,;]+/g, " ").replace(/\s+/g, " ").trim();
8
+ }
9
+
10
+ /** Regex matching `github.com/<owner>/<repo>/pull/<number>` (used for full parsing). */
11
+ export const PR_REF_REGEX = /github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/i;
12
+
13
+ /**
14
+ * Return a `gh pr diff` ref: full PR URL, or PR number string.
15
+ * Returns null when no PR reference is detected.
16
+ */
17
+ export function extractPrRef(text: string): string | null {
18
+ const normalized = normalizeUserInput(text);
19
+ if (!normalized) return null;
20
+
21
+ const fullUrl = normalized.match(/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/i);
22
+ if (fullUrl) return fullUrl[0];
23
+
24
+ const shortUrl = normalized.match(/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/i);
25
+ if (shortUrl) return `https://${shortUrl[0]}`;
26
+
27
+ const pullPath = normalized.match(/\bpull\/(\d{1,10})\b/i);
28
+ if (pullPath) return pullPath[1];
29
+
30
+ const prTag = normalized.match(/\bPR\s*#?(\d{1,10})\b/i);
31
+ if (prTag) return prTag[1];
32
+
33
+ const first = normalized.split(/\s+/)[0] ?? "";
34
+ if (/^#?\d{1,10}$/.test(first)) {
35
+ return first.replace(/^#/, "");
36
+ }
37
+
38
+ return null;
39
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Implementation of the `pi_review_report` tool. Loaded by index.ts and
3
+ * registered as a real Pi tool — the main agent calls it once after the
4
+ * workflowScript returns.
5
+ *
6
+ * Pure logic — does not touch pi. The wrapper in `src/tool-wrapper.ts`
7
+ * adapts it to `pi.registerTool`.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+
12
+ import { enforceGateOutput, type VerdictPolicy } from "./gate-enforce.js";
13
+ import { buildReportFromWorkflow, renderReport } from "./report.js";
14
+ import { readManifest, RunManifest } from "./review-report.js";
15
+ import { removeWorkspaceRoot } from "./target-workspace.js";
16
+ import type {
17
+ GateDisposition,
18
+ Issue,
19
+ ReviewerOutput,
20
+ Verdict,
21
+ } from "./types.js";
22
+
23
+ export interface ReportToolInput {
24
+ runId: string;
25
+ workflowReturn: unknown;
26
+ threshold?: number;
27
+ verdictPolicy?: VerdictPolicy;
28
+ /** Root of the prepared run; defaults to process.cwd(). Callers with a
29
+ * tool/command context should pass ctx.cwd so the manifest is found
30
+ * regardless of the hosting process's cwd. */
31
+ cwd?: string;
32
+ }
33
+
34
+ export interface ReportToolSuccess {
35
+ ok: true;
36
+ runId: string;
37
+ verdict: Verdict | "partial" | "no-gate" | "error";
38
+ markdown: string;
39
+ report: ReturnType<typeof buildReportFromWorkflow>;
40
+ persistedEntry?: unknown;
41
+ }
42
+
43
+ export interface ReportToolFailure {
44
+ ok: false;
45
+ error: string;
46
+ }
47
+
48
+ export type ReportToolResult = ReportToolSuccess | ReportToolFailure;
49
+
50
+ /** Map legacy `ReviewerOutput` shape to v0.7 shape (status, coverage). */
51
+ function adaptReviewer(so: unknown): ReviewerOutput {
52
+ if (!so || typeof so !== "object") {
53
+ return { status: "limited", issues: [], summary: "", coverage: emptyCoverage() };
54
+ }
55
+ const obj = so as Partial<ReviewerOutput> & {
56
+ issues?: unknown;
57
+ summary?: unknown;
58
+ status?: unknown;
59
+ coverage?: unknown;
60
+ };
61
+ const issues = Array.isArray(obj.issues)
62
+ ? (obj.issues as Issue[]).filter(isIssueLike).map(normalizeIssue)
63
+ : [];
64
+ return {
65
+ status: obj.status === "ok" || obj.status === "limited" || obj.status === "skipped"
66
+ ? obj.status
67
+ : "limited",
68
+ issues,
69
+ summary: typeof obj.summary === "string" ? obj.summary : "",
70
+ coverage: isCoverage(obj.coverage)
71
+ ? obj.coverage
72
+ : emptyCoverage(),
73
+ };
74
+ }
75
+
76
+ function isIssueLike(v: unknown): v is Issue {
77
+ return !!v && typeof v === "object" && typeof (v as { file?: unknown }).file === "string";
78
+ }
79
+
80
+ /** Clamp / default an issue's confidence so downstream filters never see NaN. */
81
+ function normalizeIssue(issue: Issue): Issue {
82
+ if (typeof issue.confidence === "number" && Number.isFinite(issue.confidence)) {
83
+ return { ...issue, confidence: Math.max(1, Math.min(10, Math.round(issue.confidence))) };
84
+ }
85
+ return { ...issue, confidence: 5 };
86
+ }
87
+
88
+ /** Clamp an externally supplied score (gate re-scores bypass schema paths). */
89
+ function clampConfidence(value: unknown): number {
90
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 5;
91
+ return Math.max(1, Math.min(10, Math.round(n)));
92
+ }
93
+
94
+ function isCoverage(v: unknown): v is ReviewerOutput["coverage"] {
95
+ return (
96
+ !!v &&
97
+ typeof v === "object" &&
98
+ Array.isArray((v as { filesChecked?: unknown }).filesChecked) &&
99
+ Array.isArray((v as { commandsRun?: unknown }).commandsRun) &&
100
+ Array.isArray((v as { limitations?: unknown }).limitations)
101
+ );
102
+ }
103
+
104
+ function emptyCoverage(): ReviewerOutput["coverage"] {
105
+ return { filesChecked: [], commandsRun: [], limitations: [] };
106
+ }
107
+
108
+ /** Build a deterministic verdict + report from a workflow return value. */
109
+ export function runReportTool(input: ReportToolInput): ReportToolResult {
110
+ if (!input.workflowReturn || typeof input.workflowReturn !== "object") {
111
+ return { ok: false, error: "workflowReturn must be an object" };
112
+ }
113
+ const ret = input.workflowReturn as {
114
+ reviewers?: unknown;
115
+ gate?: unknown;
116
+ reviewersShaped?: unknown;
117
+ };
118
+ if (!Array.isArray(ret.reviewers)) {
119
+ return { ok: false, error: "workflowReturn.reviewers must be an array" };
120
+ }
121
+
122
+ // Locate the manifest so the report has authoritative metadata.
123
+ const manifest = loadManifestSafe(input.runId, input.cwd);
124
+ const threshold = input.threshold ?? 8;
125
+ const policy = input.verdictPolicy ?? "strict";
126
+
127
+ const reviewersRaw = ret.reviewers as Array<{
128
+ key: string;
129
+ ok: boolean;
130
+ error?: string;
131
+ structuredOutput?: unknown;
132
+ output?: string;
133
+ }>;
134
+ // Stale-artifact guard: findings may only come from reviewers THIS run
135
+ // fanned out (manifest.reviewerIds). Anything else — e.g. a main agent
136
+ // that reconstructed a workflowReturn from old .pi-subagents artifacts
137
+ // after a failed workflow — is dropped and surfaced, never reported.
138
+ const roster = new Set(manifest?.reviewerIds ?? []);
139
+ const knownReviewers = roster.size === 0
140
+ ? reviewersRaw
141
+ : reviewersRaw.filter((r) => roster.has(r.key));
142
+ const unknownKeys = reviewersRaw
143
+ .filter((r) => roster.size > 0 && !roster.has(r.key))
144
+ .map((r) => r.key);
145
+ if (unknownKeys.length > 0 && knownReviewers.length === 0) {
146
+ // Every finding came from outside this run's roster — almost certainly
147
+ // a workflowReturn reconstructed from stale artifacts after a failed
148
+ // workflow. Rendering a report here would fabricate a clean APPROVE
149
+ // over zero real reviewers; refuse instead.
150
+ return {
151
+ ok: false,
152
+ error: `workflowReturn contains no reviewer from this run's roster (got: ${unknownKeys.join(", ")}; roster: ${[...roster].join(", ")}). Findings appear to come from stale artifacts — re-run the review instead of reconstructing its return value.`,
153
+ };
154
+ }
155
+ const reviewerOutputs = knownReviewers.map((r) => ({
156
+ key: r.key,
157
+ ok: r.ok,
158
+ error: r.error,
159
+ output: r.output,
160
+ }));
161
+
162
+ const gateRaw = (ret.gate ?? null) as {
163
+ ok: boolean;
164
+ error?: string;
165
+ output?: string;
166
+ } | null;
167
+
168
+ // v0.8 data source: the gate (or, in lite mode, the lite-reviewer) ends
169
+ // its Markdown report with one fenced ```json verdict block. That block
170
+ // is the ONLY structured data we machine-read; reviewer Markdown is
171
+ // rendered verbatim for humans and was already arbitrated by the gate.
172
+ const gateMd = gateRaw?.ok ? gateRaw.output : undefined;
173
+ const gateSo = extractVerdictBlock(gateMd);
174
+ const liteSo = gateRaw
175
+ ? undefined
176
+ : extractVerdictBlock(reviewerOutputs.find((r) => r.ok)?.output);
177
+
178
+ const verdictSource = gateSo ?? liteSo;
179
+ const sourceLabel = gateSo ? "gate" : liteSo ? "lite-reviewer" : null;
180
+
181
+ // 1) Candidates come from the verdict block's issues (the gate already
182
+ // re-scored + verified them). "unverified:" blocker/major dispositions
183
+ // are exempt from the threshold floor — the verification duty says
184
+ // those must stay visible instead of dying in a numeric filter.
185
+ const dispoByFp = new Map<string, GateDisposition>();
186
+ for (const d of verdictSource?.dispositions ?? []) dispoByFp.set(d.fingerprint, d);
187
+
188
+ const finalCandidates: Array<{ issue: Issue }> = [];
189
+ for (const rawIssue of verdictSource?.issues ?? []) {
190
+ if (!isIssueLike(rawIssue)) continue;
191
+ const issue = normalizeIssue(rawIssue);
192
+ if (!issue.fingerprint) issue.fingerprint = issueFingerprint(issue);
193
+ const d = dispoByFp.get(issue.fingerprint);
194
+ let confidence = issue.confidence;
195
+ let evidence = issue.evidence;
196
+ if (d) {
197
+ confidence = clampConfidence(d.finalConfidence);
198
+ const unverified =
199
+ /^unverified:/i.test(d.reason.trim()) &&
200
+ (issue.severity === "blocker" || issue.severity === "major");
201
+ if (unverified) {
202
+ confidence = Math.max(confidence, threshold);
203
+ evidence = `${evidence} (unverified)`;
204
+ }
205
+ }
206
+ finalCandidates.push({ issue: { ...issue, confidence, evidence } });
207
+ }
208
+
209
+ const dedupMap = new Map<string, { issue: Issue }>();
210
+ for (const c of finalCandidates) {
211
+ const key = c.issue.fingerprint ?? issueFingerprint(c.issue);
212
+ const prev = dedupMap.get(key);
213
+ if (!prev || c.issue.confidence > prev.issue.confidence) dedupMap.set(key, c);
214
+ }
215
+
216
+ const deduped: Issue[] = [...dedupMap.values()].map((d) => d.issue);
217
+ const enforced = enforceGateOutput({ issues: deduped }, threshold, policy);
218
+
219
+ const dispositions: GateDisposition[] = [...dedupMap.values()].map((d) => {
220
+ const fp = d.issue.fingerprint ?? issueFingerprint(d.issue);
221
+ const gateDispo = dispoByFp.get(fp);
222
+ const survived = enforced.issues.some(
223
+ (e) => (e.fingerprint ?? issueFingerprint(e)) === fp,
224
+ );
225
+ return {
226
+ fingerprint: fp,
227
+ decision: survived ? "kept" : "dropped",
228
+ originalConfidence: gateDispo?.originalConfidence ?? d.issue.confidence,
229
+ finalConfidence: d.issue.confidence,
230
+ sourceReviewers: gateDispo?.sourceReviewers ?? [sourceLabel ?? "gate"],
231
+ reason:
232
+ gateDispo?.reason ??
233
+ (survived ? "Survived threshold + dedupe." : "Below threshold or merged."),
234
+ };
235
+ });
236
+
237
+ if (!manifest) {
238
+ return {
239
+ ok: false,
240
+ error: `manifest not found for runId ${input.runId}. Has /review prepared the run?`,
241
+ };
242
+ }
243
+
244
+ // Effective gate for the report layer: a gate that ran but produced no
245
+ // parseable verdict block counts as no-gate; in lite mode the
246
+ // lite-reviewer's verdict block stands in for the gate.
247
+ const effectiveGate = gateRaw
248
+ ? gateSo
249
+ ? { ok: true, output: gateMd, structuredOutput: { status: gateSo.status ?? "ok" } }
250
+ : { ok: false, error: gateRaw.error ?? "gate produced no parseable verdict JSON block", output: gateMd }
251
+ : liteSo
252
+ ? { ok: true, output: undefined, structuredOutput: { status: liteSo.status ?? "ok" } }
253
+ : null;
254
+
255
+ const built = buildReportFromWorkflow({
256
+ startedAt: Date.now(),
257
+ manifest: {
258
+ runId: manifest.runId,
259
+ targetLabel: manifest.targetLabel,
260
+ targetKind: manifest.targetKind,
261
+ prRef: manifest.prRef,
262
+ diffSha256: manifest.diffSha256,
263
+ workspacePath: manifest.workspacePath,
264
+ workspaceHeadSha: manifest.workspaceHeadSha,
265
+ workspaceWarning: manifest.workspaceWarning,
266
+ diffWarning: manifest.diffWarning,
267
+ mode: manifest.mode,
268
+ docsOnly: manifest.docsOnly,
269
+ rulePaths: manifest.rulePaths,
270
+ historyAvailable: manifest.historyAvailable,
271
+ changedFiles: manifest.changedFiles,
272
+ baseSha: manifest.baseSha,
273
+ headSha: manifest.headSha,
274
+ skippedReviewers: manifest.skippedReviewers,
275
+ },
276
+ workflowReturn: { reviewers: reviewerOutputs, gate: effectiveGate },
277
+ threshold,
278
+ policy,
279
+ enforcedVerdict: enforced.verdict,
280
+ enforcedIssues: enforced.issues,
281
+ enforcedDispositions: dispositions,
282
+ enforcedReason: (unknownKeys.length > 0
283
+ ? `${enforced.reason} (dropped findings from non-roster reviewer keys: ${unknownKeys.join(", ")})`
284
+ : enforced.reason).slice(0, 500),
285
+ });
286
+
287
+ const markdown = renderReport(built);
288
+ // End-of-run reclamation: the report is rendered and persisted, so the
289
+ // plugin-owned scratch clone has no further reader. Only cloned
290
+ // workspaces are touched (never the user's cwd), and each run owns a
291
+ // unique tmpdir root, so concurrent reviews never collide. Runs that
292
+ // never reach this tool (failed workflow) are still caught by the 24h
293
+ // TTL pruner on the next prepareRun.
294
+ if (manifest.workspaceCloned) {
295
+ removeWorkspaceRoot(dirname(manifest.workspacePath));
296
+ }
297
+ return {
298
+ ok: true,
299
+ runId: input.runId,
300
+ verdict: built.verdict,
301
+ markdown,
302
+ report: built,
303
+ };
304
+ }
305
+
306
+ function safeJsonParse(text?: string): unknown {
307
+ if (!text) return undefined;
308
+ try {
309
+ return JSON.parse(text);
310
+ } catch {
311
+ // The model often wraps the JSON in prose or a fence — try to lift the
312
+ // outermost {...} block out before giving up.
313
+ const start = text.indexOf("{");
314
+ const end = text.lastIndexOf("}");
315
+ if (start >= 0 && end > start) {
316
+ try {
317
+ const lifted = JSON.parse(text.slice(start, end + 1));
318
+ if (lifted && typeof lifted === "object") return lifted;
319
+ } catch {
320
+ /* not JSON after all */
321
+ }
322
+ }
323
+ return undefined;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Extract the verdict JSON block from a gate / lite-reviewer Markdown
329
+ * report (v0.8). The report ends with exactly one fenced ```json block;
330
+ * extraction prefers fenced blocks and falls back to brace-lifting.
331
+ * Returns undefined when nothing shaped like { verdict?, issues[] } is
332
+ * found — callers treat that as "no verdict data" (no-gate path).
333
+ */
334
+ export function extractVerdictBlock(md?: string):
335
+ | {
336
+ status?: string;
337
+ verdict?: string;
338
+ reason?: string;
339
+ issues?: unknown[];
340
+ dispositions?: GateDisposition[];
341
+ summary?: string;
342
+ coverage?: unknown;
343
+ }
344
+ | undefined {
345
+ if (!md) return undefined;
346
+ const candidates: unknown[] = [];
347
+ const fence = /```(?:json|JSON)?\s*\n([\s\S]*?)```/g;
348
+ let m: RegExpExecArray | null;
349
+ while ((m = fence.exec(md)) !== null) {
350
+ const parsed = safeJsonParse(m[1]!);
351
+ if (parsed && typeof parsed === "object") candidates.push(parsed);
352
+ }
353
+ if (candidates.length === 0) {
354
+ const lifted = safeJsonParse(md);
355
+ if (lifted && typeof lifted === "object") candidates.push(lifted);
356
+ }
357
+ // Prefer the LAST well-shaped block (the verdict block comes at the end;
358
+ // earlier fences may be acceptance reports or examples).
359
+ for (let i = candidates.length - 1; i >= 0; i--) {
360
+ const c = candidates[i] as { issues?: unknown; verdict?: unknown };
361
+ if (Array.isArray(c.issues) || typeof c.verdict === "string") {
362
+ return c as ReturnType<typeof extractVerdictBlock>;
363
+ }
364
+ }
365
+ return undefined;
366
+ }
367
+
368
+ function loadManifestSafe(runId: string, cwd?: string): RunManifest | null {
369
+ // Prefer the explicit run root (tool ctx.cwd); the manifest lives under
370
+ // the same .pi/pi-review/runs/<runId> that prepareRun created.
371
+ const root = cwd?.trim() ? cwd : process.cwd();
372
+ const path = join(root, ".pi", "pi-review", "runs", runId, "manifest.json");
373
+ try {
374
+ const raw = readFileSync(path, "utf-8");
375
+ return JSON.parse(raw) as RunManifest;
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ /** Deterministic fingerprint for an issue. */
382
+ export function issueFingerprint(issue: Issue): string {
383
+ const line = issue.line === undefined ? "-" : String(issue.line);
384
+ const evHash = simpleHash(issue.evidence);
385
+ return `${issue.file}:${line}:${issue.category}:${evHash}`;
386
+ }
387
+
388
+ function simpleHash(text: string): string {
389
+ let h = 5381;
390
+ for (let i = 0; i < text.length; i++) {
391
+ h = ((h << 5) + h + text.charCodeAt(i)) | 0;
392
+ }
393
+ return (h >>> 0).toString(36).slice(0, 6);
394
+ }