@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.
- package/CHANGELOG.md +540 -0
- package/LICENSE +15 -0
- package/README.md +55 -0
- package/agents/bugbot.md +46 -0
- package/agents/claude-md-compliance.md +46 -0
- package/agents/code-comments.md +43 -0
- package/agents/conventions.md +41 -0
- package/agents/gate.md +70 -0
- package/agents/history-context.md +45 -0
- package/agents/lite-review.md +51 -0
- package/agents/security-review.md +45 -0
- package/index.ts +205 -0
- package/package.json +38 -0
- package/reference/README.md +20 -0
- package/reference/claude-code-review.md +133 -0
- package/reference/cursor-review-skills.md +72 -0
- package/reference/pi-review-roadmap.md +183 -0
- package/reference/structured-output.md +26 -0
- package/reference/v0.2-plan.md +268 -0
- package/src/cli-args.ts +105 -0
- package/src/config.ts +340 -0
- package/src/directive.ts +481 -0
- package/src/gate-enforce.ts +151 -0
- package/src/lean-agents.ts +105 -0
- package/src/pr-ref.ts +39 -0
- package/src/report-tool.ts +394 -0
- package/src/report.ts +399 -0
- package/src/review-report.ts +279 -0
- package/src/review-run.ts +568 -0
- package/src/target-workspace.ts +239 -0
- package/src/tool-wrapper.ts +75 -0
- package/src/tui-renderer.ts +92 -0
- package/src/types.ts +207 -0
- package/src/workflow-schemas.ts +172 -0
|
@@ -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
|
+
}
|