@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,481 @@
1
+ /**
2
+ * Build the review directive injected into the main agent (hidden, via
3
+ * `sendMessage` with `display:false` + `triggerTurn:true`).
4
+ *
5
+ * v0.7.0 contract (post-mortem from PR #18689 review):
6
+ * - chatProgress must be "auto" | "off" | "live-card" — anything else is
7
+ * rejected by pi-subagents schema validation.
8
+ * - Every reviewer child declares `cwd` (target workspace) and
9
+ * `outputSchema` so pi-subagents returns `result.structuredOutput`.
10
+ * - "inherit" reviewer models are NOT expanded into concrete model ids.
11
+ * The workflow script leaves `model:` off so the orchestrator keeps the
12
+ * inheritance link.
13
+ * - The gate consumes reviewer `structuredOutput` objects directly, never
14
+ * Markdown code fences.
15
+ * - Step 3 hands off to the `pi_review_report` tool, which is the only
16
+ * authoritative report renderer (deterministic code-side verdict).
17
+ */
18
+ import { writeFileSync } from "node:fs";
19
+
20
+ import {
21
+ FALSE_POSITIVE_GUIDANCE,
22
+ LEAN_BUDGETS,
23
+ LEAN_GATE_AGENT,
24
+ leanAgentName,
25
+ resolveLeanBudgets,
26
+ withThinkingSuffix,
27
+ type LeanBudgetSpec,
28
+ } from "./lean-agents.js";
29
+ import type { ReviewerSpec, ReviewTarget } from "./types.js";
30
+
31
+ export interface ReviewDirectiveInput {
32
+ target: ReviewTarget;
33
+ reviewers: ReviewerSpec[];
34
+ /** Resolved gate model id (from config.gate.model or --gate-model). */
35
+ gateModel: string;
36
+ /** Optional gate thinking from config (appended as model:thinking). */
37
+ gateThinking?: string;
38
+ threshold: number;
39
+ /** Verdict policy passed to the gate task (code-side authoritative). */
40
+ verdictPolicy?: "strict" | "legacy";
41
+ lite: boolean;
42
+ /** Set false to skip the gate while keeping the full reviewer roster. */
43
+ gateEnabled?: boolean;
44
+ cwd: string;
45
+ /** Absolute path to the plugin-prepared target workspace (reviewer cwd). */
46
+ workspacePath: string;
47
+ /** Absolute path to the run manifest.json. */
48
+ manifestPath: string;
49
+ /** Absolute path to the captured change.diff. */
50
+ diffPath: string;
51
+ /**
52
+ * Absolute path to write the raw workflowScript text. When set, the raw
53
+ * script is persisted here and the directive points the main agent at it
54
+ * (retry path) instead of asking it to re-derive the script from a
55
+ * double-escaped JSON string — see the 2026-08-25 PR 19395 incident where
56
+ * the main agent's copy/unescape of the script produced a syntax error
57
+ * three times and then drifted into hand-debugging.
58
+ */
59
+ workflowPath?: string;
60
+ /** Optional turnBudget override from config.budgets. */
61
+ budgets?: LeanBudgetSpec;
62
+ }
63
+
64
+ export function buildReviewDirective(input: ReviewDirectiveInput): string {
65
+ const { target, reviewers, gateModel, gateThinking, threshold, lite, cwd, workspacePath, manifestPath, diffPath, workflowPath } = input;
66
+ const policy = input.verdictPolicy ?? "strict";
67
+ const gateOn = !lite && input.gateEnabled !== false;
68
+ const budgets = input.budgets ?? resolveLeanBudgets();
69
+ const gateModelWithThinking = withThinkingSuffix(gateModel, gateThinking);
70
+ const blocks: string[] = [];
71
+
72
+ blocks.push("# Code review (token-lean)");
73
+ blocks.push("");
74
+ if (target.userContext?.trim()) {
75
+ blocks.push(`**User request:** ${target.userContext.trim()}`);
76
+ blocks.push("");
77
+ }
78
+ blocks.push(
79
+ `Review the change (${target.label}). The plugin has already prepared the target workspace, diff and run manifest. Run one workflowScript that fans out ${reviewers.length} reviewer${reviewers.length === 1 ? "" : "s"}${lite ? " (lite)" : ""}${gateOn ? " + inline gate" : ""}, then call the \`pi_review_report\` tool to finalize the report. Do not re-write or summarize findings in chat.`,
80
+ );
81
+ blocks.push("");
82
+ blocks.push("## Hard rules (do not violate)");
83
+ blocks.push("");
84
+ blocks.push("- Call `subagent` **exactly one** time in this whole review: the Step 2 workflowScript call.");
85
+ blocks.push(
86
+ lite
87
+ ? "- Step 2 must be a **single** `subagent({ workflowScript, async:false, ... })` that fans out the lite-reviewer via `runs.all([...])` — never more than one call."
88
+ : gateOn
89
+ ? "- Step 2 must be a **single** `subagent({ workflowScript, async:false, ... })` that fans out **all** reviewers via `runs.all([...])` and runs the inline gate via `runs.run(\"gate\", ...)` — never one call per reviewer, never serial waves."
90
+ : "- Step 2 must be a **single** `subagent({ workflowScript, async:false, ... })` that fans out **all** reviewers via `runs.all([...])` (gate disabled in config) — never one call per reviewer, never serial waves.",
91
+ );
92
+ blocks.push(
93
+ "- **Do not retry** or re-spawn if a reviewer times out, hits its turnBudget, returns partial output, or fails — `runs.all` collects failures as `{ ok:false }`; the script continues and you mark failures in the report.",
94
+ );
95
+ const retryScriptHint = workflowPath ? `Read-tool \`${workflowPath}\`` : "the Read tool on the workflow.js file";
96
+ blocks.push(
97
+ "- **Exception (script-level failure):** if the `subagent` call is rejected because the `workflowScript` **fails to parse** (no reviewer ever started — e.g. a syntax error in the script literal), retry **once**: use the " + retryScriptHint + " and repeat the call with exactly that file content as `workflowScript`. Do **not** hand-edit, re-quote, or fix the script text yourself — if the retry also fails, stop and notify the user. Do not retry any reviewer that already started and failed.",
98
+ );
99
+ blocks.push("- **Do not** call `subagent` for verification, re-review, or rewriting the report.");
100
+ blocks.push(
101
+ "- **Never read `.pi-subagents/` (artifacts, transcripts, run metadata) or reconstruct findings from disk.** Findings for `pi_review_report` come exclusively from the workflow return value of the Step 2 call — files left there by earlier runs describe OTHER reviews (a real incident had a failed workflow followed by stale-artifact findings presented as the current PR's).",
102
+ );
103
+ blocks.push(
104
+ "- Use the exact `pi-review.*` agents below — do not substitute builtin `reviewer`. Keep per-child `toolBudget` / `turnBudget` and the top-level `async:false` / `context:\"fresh\"` / `timeoutMs`.",
105
+ );
106
+ blocks.push("- Reviewer models **inherit** the parent session (omit per-child `model` unless the reviewer config sets an explicit model).");
107
+ blocks.push(
108
+ "- The `workflowScript` value below is a **template literal (backticks)** whose content is the exact text of the run's `workflow.js`. Copy its content verbatim — every character matters (paths, `outputSchema` JSON, budgets are already generated). Do not re-format, re-indent, unescape, or shorten it; the backtick form is unescaped by design so a straight copy is a valid script.",
109
+ );
110
+ blocks.push("");
111
+ blocks.push(`**Skip these false positives:** ${FALSE_POSITIVE_GUIDANCE}.`);
112
+ blocks.push("");
113
+
114
+ blocks.push(
115
+ "First, post the workflow as a markdown checklist into chat, then work through it — flip each `- [ ]` to `- [x]` as you finish.",
116
+ );
117
+ blocks.push("");
118
+ const todoSteps = [
119
+ `Confirm the plugin-prepared manifest is readable: ${manifestPath}`,
120
+ `Confirm the target workspace is readable: ${workspacePath}`,
121
+ lite
122
+ ? "Run one workflowScript: the lite-reviewer (one subagent call)"
123
+ : gateOn
124
+ ? `Run one workflowScript: ${reviewers.length} parallel reviewers + inline gate (one subagent call)`
125
+ : `Run one workflowScript: ${reviewers.length} parallel reviewers, no gate (one subagent call)`,
126
+ "Call `pi_review_report` once with the workflow return value (never re-parse findings)",
127
+ ];
128
+ for (const s of todoSteps) blocks.push(`- [ ] ${s}`);
129
+ blocks.push("");
130
+
131
+ // Step 1 — confirm the plugin-prepared manifest (no LLM-obtained diff).
132
+ blocks.push("## Step 1 — Confirm the plugin-prepared run (you, the main agent)");
133
+ blocks.push("");
134
+ blocks.push(
135
+ `The extension has **already** cloned/checked out the target repo, fetched an accurate diff, computed SHA-256 of the diff, and written \`${manifestPath}\` plus \`${diffPath}\`.`,
136
+ );
137
+ blocks.push("");
138
+ blocks.push("Verify with a single `bash` call with **no `&&` / `||` chains** and no network calls. Use one `test` per file (no compound operators):");
139
+ blocks.push("");
140
+ blocks.push("```bash");
141
+ blocks.push(`test -s ${JSON.stringify(diffPath)}`);
142
+ blocks.push(`test -f ${JSON.stringify(manifestPath)}`);
143
+ blocks.push(`test -d ${JSON.stringify(workspacePath)}`);
144
+ blocks.push("```");
145
+ blocks.push("");
146
+ blocks.push("If any check fails, stop and notify the user. Otherwise continue.");
147
+ blocks.push("");
148
+
149
+ // Step 2 — single workflowScript call.
150
+ const script = buildWorkflowScript({
151
+ reviewers,
152
+ gateModelWithThinking,
153
+ gateThinking,
154
+ gateModel,
155
+ budgets,
156
+ lite,
157
+ gateEnabled: gateOn,
158
+ threshold,
159
+ verdictPolicy: policy,
160
+ targetLabel: target.label,
161
+ userContext: target.userContext,
162
+ workspacePath,
163
+ manifestPath,
164
+ diffPath,
165
+ });
166
+
167
+ // Parse guard (P0 regression): make sure the generated script is valid JS
168
+ // BEFORE it reaches the main agent. If the template ever regresses — or,
169
+ // critically, if it ever grows a backtick or `${` (which would break the
170
+ // template-literal presentation the main agent copies) — fail here instead
171
+ // of at subagent() time.
172
+ if (/[`$]/.test(script)) {
173
+ throw new Error(
174
+ "pi-review: generated workflowScript contains a backtick or `$` (template-literal conflict) — the directive presents it inside backticks, so this would corrupt the main agent's copy. This is a plugin bug; please report it.",
175
+ );
176
+ }
177
+ try {
178
+ new Function(`return (async () => {\n${script}\n})`);
179
+ } catch (err) {
180
+ throw new Error(
181
+ `pi-review: generated workflowScript is not valid JavaScript — refusing to hand it to the main agent. This is a plugin bug; please report it. Underlying error: ${err instanceof Error ? err.message : String(err)}`,
182
+ );
183
+ }
184
+
185
+ // Persist the raw script text so the main agent has a zero-unescape
186
+ // retry source (see ReviewDirectiveInput.workflowPath).
187
+ if (workflowPath) {
188
+ try {
189
+ writeFileSync(workflowPath, script, "utf-8");
190
+ } catch {
191
+ // Directive still works from the template literal below.
192
+ }
193
+ }
194
+
195
+ blocks.push("## Step 2 — Run the review (exactly one subagent workflowScript call)");
196
+ blocks.push("");
197
+ blocks.push(
198
+ lite
199
+ ? "The script fans out the single lite-reviewer, which returns a Markdown report ending in a fenced JSON block."
200
+ : gateOn
201
+ ? "The script fans out the lean reviewers in parallel (Markdown reports), then feeds their reports to the gate, which returns a Markdown synthesis ending in a fenced JSON verdict block."
202
+ : "The script fans out the lean reviewers in parallel (gate disabled in config); each returns a Markdown report.",
203
+ );
204
+ blocks.push("");
205
+ blocks.push("```js");
206
+ blocks.push("subagent({");
207
+ blocks.push(" workflowScript: `");
208
+ // The raw script, verbatim (no escaping). The script contains no
209
+ // backticks and no ${, so the template literal is lossless.
210
+ blocks.push(script);
211
+ blocks.push("`,");
212
+ blocks.push(` async: false,`);
213
+ blocks.push(` context: "fresh",`);
214
+ blocks.push(` timeoutMs: ${budgets.timeoutMs},`);
215
+ blocks.push(` chatProgress: "auto",`);
216
+ blocks.push("})");
217
+ blocks.push("```");
218
+ blocks.push("");
219
+ blocks.push(
220
+ `Copy the SUBAGENT CALL above verbatim (the workflowScript template-literal content is the exact text of \`${workflowPath ?? "workflow.js"}\`). If the call is rejected with a script parse error, \`Read\` the workflow.js file and repeat the call with that content — one retry only, no hand-editing.`,
221
+ );
222
+ blocks.push("");
223
+
224
+ // Step 3 — tool call.
225
+ blocks.push("## Step 3 — Render the report (call `pi_review_report`)");
226
+ blocks.push("");
227
+ blocks.push(
228
+ "Call the `pi_review_report` tool **exactly once** with `{ runId, workflowReturn }`. The tool loads the manifest, extracts + validates the gate's fenced JSON verdict block, runs the deterministic verdict rules, and renders the final markdown + persists a session entry. Do not re-write findings yourself.",
229
+ );
230
+ blocks.push("");
231
+
232
+ // Parse guard (P0 regression): make sure the generated script is valid JS
233
+ // BEFORE it reaches the main agent. If the template ever regresses (e.g. an
234
+ // unquoted path), fail here with a clear error instead of at subagent() time.
235
+ try {
236
+ new Function(`return (async () => {\n${script}\n})`);
237
+ } catch (err) {
238
+ throw new Error(
239
+ `pi-review: generated workflowScript is not valid JavaScript — refusing to hand it to the main agent. This is a plugin bug; please report it. Underlying error: ${err instanceof Error ? err.message : String(err)}`,
240
+ );
241
+ }
242
+
243
+ return blocks.join("\n");
244
+ }
245
+
246
+ /**
247
+ * Build the inline workflowScript string. Single-wave: one `runs.all([...])`
248
+ * for reviewers, one `runs.run(\"gate\")`. Every child carries `cwd`,
249
+ * `outputSchema`, `toolBudget`/`turnBudget`; explicit model overrides flow
250
+ * through only when the reviewer config is not `inherit`.
251
+ */
252
+ export function buildWorkflowScript(input: {
253
+ reviewers: ReviewerSpec[];
254
+ gateModelWithThinking: string;
255
+ /** Raw gate thinking level (fallback branch passes it as a child param). */
256
+ gateThinking?: string;
257
+ gateModel: string;
258
+ budgets: LeanBudgetSpec;
259
+ lite: boolean;
260
+ /** Mirror of the directive-level gate switch (false when lite OR disabled). */
261
+ gateEnabled?: boolean;
262
+ threshold: number;
263
+ /** Verdict policy for the gate task text (strict is code-side default). */
264
+ verdictPolicy?: "strict" | "legacy";
265
+ targetLabel: string;
266
+ userContext?: string;
267
+ /** Absolute target workspace path (reviewer + gate cwd). */
268
+ workspacePath: string;
269
+ /** Absolute run manifest path. */
270
+ manifestPath: string;
271
+ /** Absolute change.diff path. */
272
+ diffPath: string;
273
+ }): string {
274
+ const {
275
+ reviewers,
276
+ gateModelWithThinking,
277
+ gateThinking,
278
+ gateModel,
279
+ budgets,
280
+ lite,
281
+ gateEnabled = true,
282
+ threshold,
283
+ verdictPolicy = "strict",
284
+ targetLabel,
285
+ userContext,
286
+ workspacePath,
287
+ manifestPath,
288
+ diffPath,
289
+ } = input;
290
+ const gateOn = !lite && gateEnabled;
291
+
292
+ // Blanket read-only declaration. pi-subagents classifies each task text for
293
+ // mutation intent: with a generic-object prohibition ("do not write any
294
+ // files") plus "review only"/"return findings only", the task is
295
+ // unambiguously read-only, so a read-only agent (gate: tools read) is never
296
+ // rejected by the implementation-tool contract, and acceptance stays at the
297
+ // lightweight attested level instead of "risky write-capable".
298
+ const READ_ONLY_PREFIX =
299
+ "READ-ONLY task — review only. Do not write any files. Do not edit files. Return findings only.";
300
+
301
+ const lines: string[] = [];
302
+ // v0.8: no outputSchema on any child — the structured-output tool
303
+ // contract was too fragile in the field ("Missing structured_output
304
+ // call" after budget wrap-ups). Reviewers return Markdown reports; the
305
+ // gate ends with a fenced JSON verdict block that the report tool
306
+ // extracts. See agents/*.md "Output format" sections.
307
+ lines.push("");
308
+ // Bind the reviewer array to a local FIRST: the gate IIFE and
309
+ // `reviewersShaped` below both reference `reviewers`, and a bare object
310
+ // property (`return { reviewers: ... }`) does NOT create a variable
311
+ // binding — that produced `ReferenceError: reviewers is not defined` at
312
+ // runtime (silently surfaced as a null workflow return).
313
+ lines.push("const reviewers = await runs.all([");
314
+ for (const r of reviewers) {
315
+ const tb = LEAN_BUDGETS.defaultToolBudget; // resolved below per-id
316
+ const tbForId = r.id === "history-context" ? LEAN_BUDGETS.historyToolBudget : tb;
317
+ const taskParts = [
318
+ READ_ONLY_PREFIX,
319
+ `Read ${JSON.stringify(diffPath)} as the change — the diff is the authoritative change record; workspace files are context only. When a workspace file disagrees with the diff, trust the diff and note the discrepancy in coverage.limitations.`,
320
+ `Also read ${JSON.stringify(manifestPath)} for change-profile (docsOnly, file list, rule file paths). Do not re-fetch via gh/git.`,
321
+ `Your cwd is the target workspace (${JSON.stringify(workspacePath)}). Run all read/grep/git from there.`,
322
+ "Stay within budgets; finish with your Markdown report (Summary / Findings / Coverage) as your final message and stop.",
323
+ "Do not read plan.md, progress.md, anything under .pi-subagents/ (artifacts and transcripts included), or node_modules.",
324
+ "Prefer Read/Grep. If you use bash, only simple allowlisted commands (no &&/||/; compounds).",
325
+ ];
326
+ if (r.id === "claude-md-compliance") {
327
+ taskParts.push(
328
+ `If change-profile.rulePaths is empty, return status: skipped with empty issues — do not invent rule violations.`,
329
+ );
330
+ }
331
+ if (r.id === "history-context") {
332
+ taskParts.push(
333
+ `If change-profile.history.available is false, return status: skipped with empty issues. Take ≤5 paths from the file list and run ONE bash: git log -n 5 --oneline -- <file1> <file2> ...`,
334
+ );
335
+ }
336
+ if (r.id === "code-comments") {
337
+ taskParts.push(
338
+ `If change-profile.docsOnly is true, return status: skipped with empty issues.`,
339
+ );
340
+ }
341
+ if (r.id === "bugbot" || r.id === "security-review") {
342
+ taskParts.push(
343
+ `If change-profile.docsOnly is true, return status: skipped with empty issues. Otherwise prefer diff-only; at most 3 extra file reads.`,
344
+ );
345
+ }
346
+ if (userContext?.trim()) {
347
+ taskParts.push(`User request: ${userContext.trim()}`);
348
+ }
349
+
350
+ const modelClause =
351
+ r.model && r.model !== "inherit"
352
+ ? `\n model: ${JSON.stringify(r.model)},`
353
+ : "";
354
+ lines.push(" {");
355
+ lines.push(` key: ${JSON.stringify(r.id)},`);
356
+ lines.push(` agent: ${JSON.stringify(leanAgentName(r.id))},`);
357
+ // Task as an array joined at runtime — one short quoted line per
358
+ // instruction. A single JSON.stringify of the whole task produced
359
+ // 900+ char lines, the other fragile copy point.
360
+ lines.push(` task: [`);
361
+ for (const part of taskParts) {
362
+ lines.push(` ${JSON.stringify(part)},`);
363
+ }
364
+ lines.push(` ].join(" "),`);
365
+ lines.push(` cwd: ${JSON.stringify(workspacePath)},`);
366
+ if (r.thinking) {
367
+ lines.push(` thinking: ${JSON.stringify(r.thinking)},`);
368
+ }
369
+ lines.push(` toolBudget: { soft: ${tbForId.soft}, hard: ${tbForId.hard} },`);
370
+ lines.push(
371
+ ` turnBudget: { maxTurns: ${budgets.turnBudget.maxTurns}, graceTurns: ${budgets.turnBudget.graceTurns} },${modelClause}`,
372
+ );
373
+ lines.push(" },");
374
+ }
375
+ lines.push("]);");
376
+ lines.push("");
377
+
378
+ // ---- gate ----------------------------------------------------------
379
+ // Top-level statements ONLY: pi-subagents' workflowScript AST walker
380
+ // rejects nested async functions ("Use top-level await, plain helper
381
+ // functions, or explicit Promise chains"). The pre-0.7.4 form
382
+ // `gate: await (async () => { ... })()` therefore never passed upstream
383
+ // validation — every prior failure that survived the copy stage died
384
+ // here (2026-08-26 session: "validation failed before child launch").
385
+ if (gateOn) {
386
+ const gateTaskParts = [
387
+ READ_ONLY_PREFIX,
388
+ `Synthesize reviewer findings for ${targetLabel}.`,
389
+ `The full diff is at ${JSON.stringify(diffPath)} and your cwd is the target workspace — you CAN and SHOULD verify candidates yourself.`,
390
+ `Threshold ${threshold}: drop candidates with finalConfidence < ${threshold}.`,
391
+ `Inputs are the reviewers' Markdown reports (## Summary / ## Findings / ## Coverage sections, one per reviewer).`,
392
+ `Re-score every candidate 1–10. For each blocker/major candidate, first try to verify it by reading the diff hunk and the touched file in the workspace; state what you checked in the disposition reason.`,
393
+ `Never raise a candidate above 8 without your own verification evidence from the diff or workspace files.`,
394
+ `If you cannot verify a blocker/major candidate (missing context, truncated diff), do NOT silently drop it: keep it at the reviewer's original confidence, prefix the reason with "unverified:", and let the human decide — the parent's report tool floors unverified blocker/major candidates at the threshold so they stay visible.`,
395
+ `Every candidate must appear in dispositions with decision (kept | dropped | merged), originalConfidence, finalConfidence, sourceReviewers, reason.`,
396
+ verdictPolicy === "legacy"
397
+ ? `Verdict (legacy): request_changes if any blocker OR >=3 majors; approve if no blocker/major; else comment.`
398
+ : `Verdict (strict): request_changes if any surviving blocker or major; comment if only minor/nit; approve if no surviving issues.`,
399
+ `The parent re-applies verdict in code; this is a recommendation.`,
400
+ `Skip false positives: ${FALSE_POSITIVE_GUIDANCE}.`,
401
+ `End your report with exactly one fenced json block containing { status, verdict, issues[], dispositions[], reason } — the parent machine-reads that block.`,
402
+ ];
403
+
404
+ // Inline the reviewers' Markdown reports for the gate to arbitrate.
405
+ // (Sync arrow — allowed; only async functions are rejected upstream.)
406
+ lines.push("const reviewerSections = reviewers.map((r) => {");
407
+ lines.push(" const head = '## Reviewer: ' + r.key + (r.ok ? '' : ' (FAILED: ' + String(r.error || 'run failed').slice(0, 120) + ')');");
408
+ lines.push(" return head + '\\n\\n' + String(r.output || '(no output)').slice(0, 6000);");
409
+ lines.push("});");
410
+ // Gate task as an array join (short lines) — same copy-safety rule as
411
+ // the reviewer tasks above.
412
+ lines.push("const gateTask = [");
413
+ for (const part of gateTaskParts) {
414
+ lines.push(` ${JSON.stringify(part)},`);
415
+ }
416
+ lines.push(`].join(" ") + '\\n\\n# Reviewer reports (Markdown)\\n\\n' + reviewerSections.join('\\n\\n---\\n\\n');`);
417
+ // Proxy providers often report bare model ids from the child ("MiniMax-M2.7")
418
+ // that fail the launcher's strict model verification against the launch
419
+ // candidate ("CPA/Minimax/MiniMax-M2.7:high") — observed 2026-08-27. The
420
+ // reviewers never hit this (they inherit). So: try the configured model
421
+ // first; on launch failure retry once with an inherited model under a
422
+ // DIFFERENT key (the runtime rejects same-key launches with different
423
+ // params). A second failure rejects as before.
424
+ lines.push("let gateRun;");
425
+ lines.push("try {");
426
+ lines.push(" gateRun = await runs.run('gate', {");
427
+ lines.push(` agent: ${JSON.stringify(LEAN_GATE_AGENT)},`);
428
+ lines.push(" task: gateTask,");
429
+ lines.push(` cwd: ${JSON.stringify(workspacePath)},`);
430
+ lines.push(` model: ${JSON.stringify(gateModelWithThinking)},`);
431
+ lines.push(` toolBudget: { soft: ${budgets.gateToolBudget.soft}, hard: ${budgets.gateToolBudget.hard} },`);
432
+ lines.push(` turnBudget: { maxTurns: ${budgets.gateTurnBudget.maxTurns}, graceTurns: ${budgets.gateTurnBudget.graceTurns} },`);
433
+ lines.push(" });");
434
+ lines.push("} catch (gateLaunchError) {");
435
+ lines.push(" gateRun = await runs.run('gate-fallback', {");
436
+ lines.push(` agent: ${JSON.stringify(LEAN_GATE_AGENT)},`);
437
+ lines.push(" task: gateTask,");
438
+ lines.push(` cwd: ${JSON.stringify(workspacePath)},`);
439
+ if (gateThinking && gateThinking !== "off" && gateThinking !== "false") {
440
+ lines.push(` thinking: ${JSON.stringify(gateThinking)},`);
441
+ }
442
+ lines.push(` toolBudget: { soft: ${budgets.gateToolBudget.soft}, hard: ${budgets.gateToolBudget.hard} },`);
443
+ lines.push(` turnBudget: { maxTurns: ${budgets.gateTurnBudget.maxTurns}, graceTurns: ${budgets.gateTurnBudget.graceTurns} },`);
444
+ lines.push(" });");
445
+ lines.push("}");
446
+ lines.push("const gate = {");
447
+ lines.push(" ok: gateRun.ok,");
448
+ lines.push(" error: gateRun.error,");
449
+ lines.push(" output: gateRun.output,");
450
+
451
+ lines.push("};");
452
+ lines.push("");
453
+ }
454
+
455
+ lines.push("return {");
456
+ lines.push(" reviewers,");
457
+ if (gateOn) {
458
+ lines.push(" gate,");
459
+ } else {
460
+ lines.push(" gate: null,");
461
+ }
462
+
463
+ // ---- reviewer summary shape ----------------------------------------
464
+ lines.push(" reviewersShaped: reviewers.map((r) => ({");
465
+ lines.push(" key: r.key,");
466
+ lines.push(" ok: r.ok,");
467
+ lines.push(" error: r.error,");
468
+ lines.push(" output: r.output,");
469
+ lines.push(" })),");
470
+ lines.push("};");
471
+ return lines.join("\n");
472
+ }
473
+
474
+ /** Map the workflow return value into a normalized `ReviewWorkflowReturn` for the tool. */
475
+ export function buildWorkflowReturnShape() {
476
+ return "{ reviewers, reviewersShaped, gate }";
477
+ }
478
+
479
+ // `gateModel` reserved for config validation parity with previous surface.
480
+ export const _LEGACY_PARITY = { gateModel: "" };
481
+ void _LEGACY_PARITY;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Deterministic gate post-process (Claude Phase 5 equivalent).
3
+ *
4
+ * Re-scoring + dedupe + threshold + verdict, all in code. The LLM gate is
5
+ * advisory; the parent pipeline always re-enforces.
6
+ */
7
+ import type { GateDisposition, GateVerdict, Issue, IssueSeverity, Verdict } from "./types.js";
8
+
9
+ const SEVERITY_RANK: Record<IssueSeverity, number> = {
10
+ blocker: 4,
11
+ major: 3,
12
+ minor: 2,
13
+ nit: 1,
14
+ };
15
+
16
+ export function severityRank(s: IssueSeverity): number {
17
+ return SEVERITY_RANK[s] ?? 0;
18
+ }
19
+
20
+ function dedupeKey(issue: Issue): string {
21
+ const line = issue.line === undefined ? "-" : String(issue.line);
22
+ return `${issue.file}\0${line}\0${issue.category}`;
23
+ }
24
+
25
+ function hasFingerprint(issue: Issue): boolean {
26
+ return typeof issue.fingerprint === "string" && issue.fingerprint.length > 0;
27
+ }
28
+
29
+ /** Dedupe by (file, line, category), or by stable `fingerprint` when present. */
30
+ export function dedupeIssues(issues: Issue[]): Issue[] {
31
+ const best = new Map<string, Issue>();
32
+ for (const issue of issues) {
33
+ const key = hasFingerprint(issue) ? `fp:${issue.fingerprint}` : dedupeKey(issue);
34
+ const prev = best.get(key);
35
+ if (!prev) {
36
+ best.set(key, issue);
37
+ continue;
38
+ }
39
+ if (confidenceOf(issue) > confidenceOf(prev)) {
40
+ best.set(key, issue);
41
+ continue;
42
+ }
43
+ if (confidenceOf(issue) < confidenceOf(prev)) continue;
44
+ if (severityRank(issue.severity) > severityRank(prev.severity)) {
45
+ best.set(key, issue);
46
+ continue;
47
+ }
48
+ if (
49
+ severityRank(issue.severity) === severityRank(prev.severity) &&
50
+ issue.evidence.length > prev.evidence.length
51
+ ) {
52
+ best.set(key, issue);
53
+ }
54
+ }
55
+ return [...best.values()];
56
+ }
57
+
58
+ export function filterByThreshold(issues: Issue[], threshold: number): Issue[] {
59
+ const floor = Math.max(0, Math.min(10, Math.floor(threshold)));
60
+ // Issues without a usable confidence (legacy/shape-adapted output) default
61
+ // to a neutral 5 instead of being silently dropped (`undefined >= floor`
62
+ // is false, which used to kill every such issue — a systematic
63
+ // false-negative source observed in the field).
64
+ return issues.filter((i) => confidenceOf(i) >= floor);
65
+ }
66
+
67
+ /** Neutral-midpoint fallback for issues that arrived without a score. */
68
+ export function confidenceOf(issue: Issue): number {
69
+ if (typeof issue.confidence === "number" && Number.isFinite(issue.confidence)) {
70
+ return Math.max(1, Math.min(10, issue.confidence));
71
+ }
72
+ return 5;
73
+ }
74
+
75
+ /**
76
+ * Default verdict policy (strict):
77
+ * - any surviving blocker or major → `request_changes`
78
+ * - only minor / nit → `comment`
79
+ * - no surviving issues → `approve` (caller must still check coverage)
80
+ *
81
+ * Legacy policy (kept for `verdictPolicy:"legacy"`):
82
+ * - request_changes on any blocker OR ≥3 major
83
+ * - approve on no blocker and no major
84
+ * - otherwise comment
85
+ */
86
+ export type VerdictPolicy = "strict" | "legacy";
87
+
88
+ export function computeVerdict(issues: Issue[], policy: VerdictPolicy = "strict"): Verdict {
89
+ if (issues.length === 0) return "approve";
90
+ const blockers = issues.filter((i) => i.severity === "blocker").length;
91
+ const majors = issues.filter((i) => i.severity === "major").length;
92
+ if (policy === "legacy") {
93
+ if (blockers > 0 || majors >= 3) return "request_changes";
94
+ if (majors === 0) return "approve";
95
+ return "comment";
96
+ }
97
+ if (blockers > 0 || majors > 0) return "request_changes";
98
+ return "comment";
99
+ }
100
+
101
+ export function defaultApproveReason(issues: Issue[]): string {
102
+ if (issues.length === 0) {
103
+ return "No high-confidence findings after dedupe + threshold.";
104
+ }
105
+ return "No blockers or major issues remain after filtering.";
106
+ }
107
+
108
+ /**
109
+ * Apply code-side gate enforcement on raw LLM (or pre-scored) output.
110
+ * Always recomputes verdict from filtered issues; LLM verdict is ignored.
111
+ */
112
+ export function enforceGateOutput(
113
+ raw: { issues: Issue[]; reason?: string },
114
+ threshold: number,
115
+ policy: VerdictPolicy = "strict",
116
+ ): GateVerdict {
117
+ const deduped = dedupeIssues(raw.issues ?? []);
118
+ const issues = filterByThreshold(deduped, threshold);
119
+ const verdict = computeVerdict(issues, policy);
120
+ let reason = (raw.reason ?? "").trim();
121
+ if (!reason) {
122
+ reason =
123
+ verdict === "approve"
124
+ ? defaultApproveReason(issues)
125
+ : `Enforced verdict from ${issues.length} issue(s) after threshold ${threshold}.`;
126
+ }
127
+ if (reason.length > 500) reason = reason.slice(0, 500);
128
+ return { verdict, issues, reason, dispositions: [], status: "ok" };
129
+ }
130
+
131
+ /**
132
+ * Build an empty dispositions array when the gate did not return one. We
133
+ * keep every candidate visible so the report can audit dropped/merged ones.
134
+ */
135
+ export function buildDispositions(
136
+ candidates: Issue[],
137
+ surviving: Issue[],
138
+ ): GateDisposition[] {
139
+ const survivingKeys = new Set(surviving.map((i) => i.fingerprint ?? dedupeKey(i)));
140
+ return candidates.map((c) => ({
141
+ fingerprint: c.fingerprint ?? dedupeKey(c),
142
+ decision: survivingKeys.has(c.fingerprint ?? dedupeKey(c)) ? "kept" : "dropped",
143
+ originalConfidence: c.confidence,
144
+ finalConfidence: c.confidence,
145
+ sourceReviewers: [],
146
+ reason:
147
+ survivingKeys.has(c.fingerprint ?? dedupeKey(c))
148
+ ? "Survived threshold + dedupe."
149
+ : "Below threshold or merged into another candidate.",
150
+ }));
151
+ }