pi-workflow-engine 0.3.0 → 0.5.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.
Files changed (39) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +369 -0
  2. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
  3. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
  4. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
  5. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
  6. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +83 -0
  7. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
  8. package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
  9. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
  10. package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
  11. package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
  12. package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
  13. package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
  14. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
  15. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
  16. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-result-renderer.ts +11 -8
  17. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
  18. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
  19. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
  20. package/{src → .pi/extensions/pi-workflow-engine/src}/workflows.ts +8 -2
  21. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +57 -125
  22. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/diagnose.ts +12 -73
  23. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/perf-review.ts +41 -148
  24. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/refactor-scout.ts +43 -151
  25. package/.pi/settings.json +3 -0
  26. package/README.md +101 -27
  27. package/USAGE.md +183 -0
  28. package/assets/preview-candidate-core.png +0 -0
  29. package/assets/preview-candidate-relic-emblem.png +0 -0
  30. package/assets/preview-candidate-robot-squad.png +0 -0
  31. package/assets/preview.png +0 -0
  32. package/package.json +33 -6
  33. package/src/agent-runner.ts +0 -122
  34. package/src/concurrency.ts +0 -55
  35. package/src/discovery.ts +0 -54
  36. package/src/index.ts +0 -141
  37. package/src/ui/workflow-widget.ts +0 -143
  38. package/workflows/ping.ts +0 -28
  39. /package/{src → .pi/extensions/pi-workflow-engine/src}/advisory-schema.ts +0 -0
@@ -0,0 +1,263 @@
1
+ import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
+ import type { AgentOptions, WorkflowApi, WorkflowProgressEvent } from "./types.ts";
3
+
4
+ export interface AdvisoryLens {
5
+ label: string;
6
+ category: string;
7
+ text: string;
8
+ }
9
+
10
+ export type AdvisoryVerified<Candidate extends AdvisoryCandidate = AdvisoryCandidate> = Candidate & {
11
+ verdict: AdvisoryVerdict["verdict"];
12
+ evidence: string[];
13
+ confidence?: AdvisoryVerdict["confidence"];
14
+ };
15
+
16
+ export interface LensVerificationPipelineResult<Verified> {
17
+ verified: Verified[];
18
+ rawCandidates: number;
19
+ dropped: number;
20
+ refuted: number;
21
+ }
22
+
23
+ const DEFAULT_ADVISORY_TOOLS = ["read", "bash"];
24
+
25
+ export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
26
+
27
+ export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Verified extends AdvisoryVerified> {
28
+ api: Pick<WorkflowApi, "agent" | "parallel" | "pipeline" | "progress" | "log">;
29
+ lenses: readonly Lens[];
30
+ perLens: number;
31
+ tools?: AgentOptions["tools"];
32
+ finderPhase?: string;
33
+ verifierPhase?: string;
34
+ schedulingMode?: AdvisorySchedulingMode;
35
+ finderPrompt(lens: Lens): string;
36
+ verifierPrompt(candidate: AdvisoryCandidate): string;
37
+ makeVerified(candidate: AdvisoryCandidate, lens: Lens, verdict: AdvisoryVerdict): Verified;
38
+ }
39
+
40
+ export interface AdvisoryBackfillDefaults {
41
+ impact: string;
42
+ recommendation?: string;
43
+ }
44
+
45
+ interface FoundForLens<Lens> {
46
+ lens: Lens;
47
+ candidates: AdvisoryCandidate[];
48
+ }
49
+
50
+ interface NovelCandidate<Lens> {
51
+ lens: Lens;
52
+ candidate: AdvisoryCandidate;
53
+ }
54
+
55
+ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Verified extends AdvisoryVerified>(
56
+ options: LensVerificationPipelineOptions<Lens, Verified>,
57
+ ): Promise<LensVerificationPipelineResult<Verified>> {
58
+ const {
59
+ api,
60
+ lenses,
61
+ perLens,
62
+ tools = DEFAULT_ADVISORY_TOOLS,
63
+ finderPhase = "Find",
64
+ verifierPhase = "Verify",
65
+ schedulingMode = "pipeline",
66
+ finderPrompt,
67
+ verifierPrompt,
68
+ makeVerified,
69
+ } = options;
70
+ const seen = new Set<string>();
71
+ let rawCandidates = 0;
72
+ let dropped = 0;
73
+ let refuted = 0;
74
+
75
+ const findForLens = async (lens: Lens): Promise<FoundForLens<Lens>> => {
76
+ const found = await api.agent(finderPrompt(lens), {
77
+ phase: finderPhase,
78
+ label: `find:${lens.label}`,
79
+ tools,
80
+ thinkingLevel: "low",
81
+ schema: AdvisoryCandidatesSchema,
82
+ });
83
+ const candidates = (found?.candidates ?? []).slice(0, perLens);
84
+ rawCandidates += candidates.length;
85
+ api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: candidates.length });
86
+ for (const candidate of candidates) {
87
+ api.progress({
88
+ type: "lane_item",
89
+ lane: "Candidates",
90
+ title: candidate.summary,
91
+ subtitle: formatLocation(candidate),
92
+ status: "pending",
93
+ details: candidate.impact,
94
+ });
95
+ }
96
+ return { lens, candidates };
97
+ };
98
+
99
+ const dedupeFound = (found: FoundForLens<Lens>): NovelCandidate<Lens>[] => {
100
+ const novel = candidatesNovelToRun(found.candidates, seen).map((candidate) => ({ lens: found.lens, candidate }));
101
+ const droppedForLens = found.candidates.length - novel.length;
102
+ if (droppedForLens > 0) {
103
+ dropped += droppedForLens;
104
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: droppedForLens });
105
+ api.log(`find:${found.lens.label}: dropped ${droppedForLens} duplicate candidate(s)`);
106
+ }
107
+ return novel;
108
+ };
109
+
110
+ const verifyCandidate = async ({ lens, candidate }: NovelCandidate<Lens>): Promise<Verified | null> => {
111
+ const location = primaryLocation(candidate);
112
+ const judged = await api.agent(verifierPrompt(candidate), {
113
+ phase: verifierPhase,
114
+ label: `verify:${location.file.split("/").pop() ?? location.file}`,
115
+ tools,
116
+ thinkingLevel: "low",
117
+ schema: AdvisoryVerdictSchema,
118
+ });
119
+ if (!judged) return null;
120
+ recordVerdictProgress(api.progress, candidate, judged, () => {
121
+ refuted += 1;
122
+ });
123
+ return makeVerified(candidate, lens, judged);
124
+ };
125
+
126
+ if (schedulingMode === "finder-barrier") {
127
+ const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
128
+ const novel = found.flatMap(dedupeFound);
129
+ const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
130
+ return { verified: verdicts.filter((value): value is Verified => value !== null), rawCandidates, dropped, refuted };
131
+ }
132
+
133
+ const perLensVerified = await api.pipeline(
134
+ lenses,
135
+ async (_prev, lens) => findForLens(lens),
136
+ async (found) => {
137
+ const novel = dedupeFound(found);
138
+ const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
139
+ return verdicts.filter((value): value is Verified => value !== null);
140
+ },
141
+ );
142
+
143
+ return { verified: perLensVerified.flat(), rawCandidates, dropped, refuted };
144
+ }
145
+
146
+ function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
147
+ return candidates.filter((candidate) => {
148
+ const key = advisoryDedupKey(candidate);
149
+ if (seen.has(key)) return false;
150
+ seen.add(key);
151
+ return true;
152
+ });
153
+ }
154
+
155
+ export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">): AdvisoryLocation {
156
+ return candidate.locations[0] ?? { file: "" };
157
+ }
158
+
159
+ export function formatLocation(candidate: Pick<AdvisoryCandidate, "locations">): string {
160
+ const location = primaryLocation(candidate);
161
+ const line = location.line != null ? `:${location.line}` : "";
162
+ const symbol = location.symbol ? ` (${location.symbol})` : "";
163
+ return `${location.file}${line}${symbol}`;
164
+ }
165
+
166
+ export function formatEvidence(evidence: readonly string[]): string {
167
+ return evidence.join("; ");
168
+ }
169
+
170
+ export function normalizePath(path: string): string {
171
+ return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
172
+ }
173
+
174
+ export function advisoryDedupKey(candidate: AdvisoryCandidate): string {
175
+ const location = primaryLocation(candidate);
176
+ const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : "file";
177
+ return `${candidate.category}:${normalizePath(location.file)}:${lineKey}:${candidate.summary.slice(0, 60).toLowerCase()}`;
178
+ }
179
+
180
+ export function verdictLane(verdict: AdvisoryVerdict["verdict"]): string {
181
+ switch (verdict) {
182
+ case "CONFIRMED":
183
+ return "Confirmed";
184
+ case "PLAUSIBLE":
185
+ return "Plausible";
186
+ case "REFUTED":
187
+ return "Refuted";
188
+ }
189
+ }
190
+
191
+ export function verdictStatus(verdict: AdvisoryVerdict["verdict"]): "success" | "warning" | "error" {
192
+ switch (verdict) {
193
+ case "CONFIRMED":
194
+ return "success";
195
+ case "PLAUSIBLE":
196
+ return "warning";
197
+ case "REFUTED":
198
+ return "error";
199
+ }
200
+ }
201
+
202
+ export function verdictConfidence(verdict: AdvisoryVerdict["verdict"]): "high" | "medium" | "low" {
203
+ switch (verdict) {
204
+ case "CONFIRMED":
205
+ return "high";
206
+ case "PLAUSIBLE":
207
+ return "medium";
208
+ case "REFUTED":
209
+ return "low";
210
+ }
211
+ }
212
+
213
+ export function sameFinding(candidate: Pick<AdvisoryCandidate, "locations">, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
214
+ return findingLocationKey(candidate) === findingLocationKey(finding);
215
+ }
216
+
217
+ export function findingLocationKey(value: Pick<AdvisoryCandidate, "locations"> | Pick<AdvisoryFinding, "locations">): string {
218
+ const location = primaryLocation(value);
219
+ return `${normalizePath(location.file)}:${location.line ?? "file"}`;
220
+ }
221
+
222
+ export function recordVerdictProgress(
223
+ progress: (event: WorkflowProgressEvent) => void,
224
+ candidate: Pick<AdvisoryCandidate, "locations" | "summary">,
225
+ verdict: Pick<AdvisoryVerdict, "verdict" | "evidence">,
226
+ onRefuted?: () => void,
227
+ ): void {
228
+ progress({ type: "counter_delta", key: `verdict.${verdict.verdict.toLowerCase()}`, label: verdict.verdict, delta: 1 });
229
+ if (verdict.verdict === "REFUTED") {
230
+ onRefuted?.();
231
+ progress({ type: "counter_delta", key: "refuted", label: "refuted", delta: 1 });
232
+ }
233
+ progress({
234
+ type: "lane_item",
235
+ lane: verdictLane(verdict.verdict),
236
+ title: candidate.summary,
237
+ subtitle: formatLocation(candidate),
238
+ status: verdictStatus(verdict.verdict),
239
+ details: formatEvidence(verdict.evidence),
240
+ });
241
+ }
242
+
243
+ export function backfillAdvisoryFindings<Source extends AdvisoryVerified>(
244
+ findings: AdvisoryReport["findings"],
245
+ ranked: readonly Source[],
246
+ defaults: AdvisoryBackfillDefaults,
247
+ ): AdvisoryReport["findings"] {
248
+ const rankedByLocation = new Map<string, Source>();
249
+ for (const candidate of ranked) {
250
+ const key = findingLocationKey(candidate);
251
+ if (!rankedByLocation.has(key)) rankedByLocation.set(key, candidate);
252
+ }
253
+
254
+ return findings.map((finding) => {
255
+ const source = rankedByLocation.get(findingLocationKey(finding));
256
+ return {
257
+ ...finding,
258
+ evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
259
+ impact: finding.impact || source?.impact || defaults.impact,
260
+ recommendation: finding.recommendation || source?.recommendation || defaults.recommendation || "",
261
+ };
262
+ });
263
+ }
@@ -0,0 +1,57 @@
1
+ import type { WorkflowMeta, WorkflowModule } from "./types.ts";
2
+
3
+ type WorkflowMetaCandidate = {
4
+ readonly name?: unknown;
5
+ readonly description?: unknown;
6
+ readonly phases?: unknown;
7
+ };
8
+
9
+ type WorkflowModuleCandidate = {
10
+ readonly meta?: unknown;
11
+ readonly default?: unknown;
12
+ };
13
+
14
+ /** Validate and normalize workflow metadata without executing workflow code. */
15
+ export function parseWorkflowMeta(value: unknown): { meta: WorkflowMeta } | { reason: string } {
16
+ if (!isRecord(value)) return { reason: "meta export must be an object" };
17
+ return parseWorkflowMetaObject(value);
18
+ }
19
+
20
+ export function parseWorkflowModule(value: unknown): { module: WorkflowModule } | { reason: string } {
21
+ if (!isRecord(value)) return { reason: "module export is not an object" };
22
+ const candidate = value as WorkflowModuleCandidate;
23
+ const meta = candidate.meta;
24
+ if (!isRecord(meta)) return { reason: "missing meta export" };
25
+
26
+ const parsedMeta = parseWorkflowMetaObject(meta);
27
+ if ("reason" in parsedMeta) return parsedMeta;
28
+ if (!isWorkflowRun(candidate.default)) return { reason: "default export must be a function" };
29
+
30
+ return { module: { meta: parsedMeta.meta, default: candidate.default } };
31
+ }
32
+
33
+ function parseWorkflowMetaObject(meta: WorkflowMetaCandidate): { meta: WorkflowMeta } | { reason: string } {
34
+ if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
35
+
36
+ const description = meta.description;
37
+ if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
38
+
39
+ const phases = meta.phases;
40
+ if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
41
+
42
+ const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
43
+ if (phases !== undefined) workflowMeta.phases = phases;
44
+ return { meta: workflowMeta };
45
+ }
46
+
47
+ function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
48
+ return typeof value === "function";
49
+ }
50
+
51
+ function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
52
+ return Array.isArray(value) && value.every((phase) => isRecord(phase) && typeof phase.title === "string");
53
+ }
54
+
55
+ function isRecord(value: unknown): value is Record<string, unknown> {
56
+ return typeof value === "object" && value !== null;
57
+ }
@@ -1,6 +1,5 @@
1
1
  import type { WorkflowModule } from "./types.ts";
2
2
  import * as codeReview from "../workflows/code-review.ts";
3
- import * as ping from "../workflows/ping.ts";
4
3
  import * as refactorScout from "../workflows/refactor-scout.ts";
5
4
  import * as diagnose from "../workflows/diagnose.ts";
6
5
  import * as perfReview from "../workflows/perf-review.ts";
@@ -13,5 +12,12 @@ import * as perfReview from "../workflows/perf-review.ts";
13
12
  * To add a guaranteed workflow: drop a `.ts` in `workflows/`, import it here, and add
14
13
  * it to this array. (Drop-in workflows discovered at runtime are also supported — see
15
14
  * discovery.ts — but the static registry is the always-correct path.)
15
+ *
16
+ * Do not replace these built-in imports with dynamic imports unless a dedicated test
17
+ * proves pi's jiti virtual modules preserve the same bundled `typebox` identity for
18
+ * dynamically imported built-ins. Startup optimizations should happen at the extension
19
+ * entrypoint/discovery boundary first.
16
20
  */
17
- export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, ping, refactorScout, diagnose, perfReview];
21
+ export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, refactorScout, diagnose, perfReview];
22
+ export const BUILTIN_WORKFLOW_FILES = new Set(["code-review.ts", "refactor-scout.ts", "diagnose.ts", "perf-review.ts"]);
23
+ export const BUILTIN_WORKFLOW_NAMES = BUILTIN_WORKFLOWS.map((mod) => mod.meta.name);
@@ -1,14 +1,21 @@
1
- import { execSync } from "node:child_process";
2
1
  import { Type } from "typebox";
3
2
  import {
4
3
  AdvisoryCandidatesSchema,
5
4
  AdvisoryReportSchema,
6
5
  AdvisoryVerdictSchema,
7
6
  type AdvisoryCandidate,
8
- type AdvisoryFinding,
9
- type AdvisoryLocation,
10
7
  type AdvisoryVerdict,
11
8
  } from "../src/advisory-schema.ts";
9
+ import {
10
+ backfillAdvisoryFindings,
11
+ formatEvidence,
12
+ formatLocation,
13
+ normalizePath,
14
+ primaryLocation,
15
+ recordVerdictProgress,
16
+ verdictConfidence,
17
+ } from "../src/workflow-advisory-utils.ts";
18
+ import { captureDiff } from "../src/diff-capture.ts";
12
19
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
13
20
 
14
21
  export const meta: WorkflowMeta = {
@@ -22,7 +29,7 @@ const ScopeSchema = Type.Object({
22
29
  diffCommand: Type.String({ description: "Exact git command that produces the review diff" }),
23
30
  files: Type.Array(Type.String(), { description: "Changed file paths" }),
24
31
  summary: Type.String({ description: "One-paragraph summary of the change" }),
25
- conventions: Type.Optional(Type.String({ description: "Relevant CLAUDE.md / project conventions" })),
32
+ conventions: Type.Optional(Type.String({ description: "Relevant AGENTS.md / project conventions" })),
26
33
  });
27
34
 
28
35
  type Candidate = AdvisoryCandidate;
@@ -51,11 +58,6 @@ const TOOLS = ["read", "bash"];
51
58
  const PER_ANGLE = 6;
52
59
  const DIFF_EMBED_CAP = 60_000;
53
60
 
54
- /** Strip leading "./", "a/", "b/" so diff paths and finder-reported paths compare equal. */
55
- export function normalizePath(path: string): string {
56
- return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
57
- }
58
-
59
61
  /** Parse a unified diff into the set of added/changed new-file line numbers per file. */
60
62
  export function changedLines(diff: string): Map<string, Set<number>> {
61
63
  const byFile = new Map<string, Set<number>>();
@@ -89,7 +91,7 @@ export function inDiff(changed: Map<string, Set<number>>, file: string, line?: n
89
91
  }
90
92
 
91
93
  export default async function run(api: WorkflowApi): Promise<unknown> {
92
- const { agent, parallel, pipeline, phase, log, progress, args, cwd } = api;
94
+ const { agent, parallel, phase, log, progress, args, cwd, signal } = api;
93
95
  const target = args.trim();
94
96
  let fileCount = 0;
95
97
  let rawCandidateCount = 0;
@@ -118,7 +120,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
118
120
  "If the branch itself is main/master, use `git diff HEAD~1`.\n" +
119
121
  "4. Run the chosen command to confirm the diff is non-empty.\n\n" +
120
122
  "Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
121
- "and read any relevant CLAUDE.md noting conventions a reviewer should know.\n" +
123
+ "and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
122
124
  "Return diffCommand exactly as a reviewer should run it. Structured output only.",
123
125
  { phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
124
126
  );
@@ -140,15 +142,13 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
140
142
  // Capture the diff once, deterministically, so findings can be bounded to changed lines in code.
141
143
  let changed: Map<string, Set<number>> | null = null;
142
144
  let diffText = "";
143
- if (/^(git diff|gh pr diff)\b/.test(scope.diffCommand)) {
144
- try {
145
- diffText = execSync(scope.diffCommand, { cwd, encoding: "utf8", maxBuffer: 16 << 20 });
146
- changed = changedLines(diffText);
147
- } catch (error) {
148
- log(`diff capture failed (${error instanceof Error ? error.message : String(error)}) — reviewing without the line gate`);
149
- }
145
+ const capturedDiff = await captureDiff(scope.diffCommand, { cwd, signal, timeoutMs: 30_000, maxBufferBytes: 16 << 20 });
146
+ if (capturedDiff.ok) {
147
+ diffText = capturedDiff.stdout;
148
+ changed = changedLines(diffText);
149
+ progress({ type: "summary", key: "diffBytes", value: capturedDiff.bytes });
150
150
  } else {
151
- log("diffCommand not in the git/gh allowlist — reviewing without the line gate");
151
+ log(`diff capture failed (${capturedDiff.error ?? "unknown error"}) — reviewing without the line gate`);
152
152
  }
153
153
 
154
154
  const diffBlock = diffText
@@ -165,7 +165,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
165
165
  diffBlock +
166
166
  (target ? `\n## User instructions (verbatim)\n${target}\n` : "");
167
167
 
168
- // Dedup state accumulates as finders complete (the pipeline has no barrier).
168
+ // Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
169
169
  const seen = new Set<string>();
170
170
  const dedupKey = (candidate: Candidate): string => {
171
171
  const location = primaryLocation(candidate);
@@ -173,13 +173,10 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
173
173
  return `${normalizePath(location.file)}:${lineKey}`;
174
174
  };
175
175
 
176
- // ─── Find → dedup → Verify (no barrier between stages) ───
176
+ // ─── Find barrier → dedup → Verify ───
177
177
  phase("Find");
178
- const perAngle = await pipeline(
179
- ANGLES,
180
- // Stage 1: each angle finds candidates through its single lens.
181
- async (_prev, item) => {
182
- const angle = item as Angle;
178
+ const perAngle = await parallel(
179
+ ANGLES.map((angle) => async () => {
183
180
  const found = await agent(
184
181
  `## Code-review finder — ${angle.label}\n\n${scopeBlock}\n` +
185
182
  `Review the change through ONLY this lens:\n${angle.text}\n` +
@@ -214,51 +211,45 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
214
211
  });
215
212
  }
216
213
  return { angle, candidates: bounded };
217
- },
218
- // Stage 2: dedup against the shared set, then verify survivors concurrently.
219
- async (prev) => {
220
- const { angle, candidates } = prev as { angle: Angle; candidates: Candidate[] };
221
- const novel = candidates.filter((candidate) => {
214
+ }),
215
+ );
216
+
217
+ const novel = perAngle.flatMap(({ angle, candidates }) =>
218
+ candidates
219
+ .filter((candidate) => {
222
220
  const key = dedupKey(candidate);
223
221
  if (seen.has(key)) return false;
224
222
  seen.add(key);
225
223
  return true;
226
- });
227
- const verdicts = await parallel(
228
- novel.map((candidate) => async (): Promise<Verified | null> => {
229
- const location = primaryLocation(candidate);
230
- const judged = await agent(
231
- `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
232
- `Location: ${formatLocation(candidate)}\n` +
233
- `Category: ${candidate.category}\nSummary: ${candidate.summary}\nImpact: ${candidate.impact}\n\n` +
234
- "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / REFUTED) " +
235
- "with evidence quoting the line(s). Default toward REFUTED if you cannot substantiate it. Structured output only.",
236
- {
237
- phase: "Verify",
238
- label: `verify:${location.file.split("/").pop() ?? location.file}`,
239
- tools: TOOLS,
240
- thinkingLevel: "low",
241
- schema: AdvisoryVerdictSchema,
242
- },
243
- );
244
- if (!judged) return null;
245
- progress({ type: "counter_delta", key: `verdict.${judged.verdict.toLowerCase()}`, label: judged.verdict, delta: 1 });
246
- progress({
247
- type: "lane_item",
248
- lane: verdictLane(judged.verdict),
249
- title: candidate.summary,
250
- subtitle: formatLocation(candidate),
251
- status: verdictStatus(judged.verdict),
252
- details: formatEvidence(judged.evidence),
253
- });
254
- return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
255
- }),
224
+ })
225
+ .map((candidate) => ({ angle, candidate })),
226
+ );
227
+
228
+ phase("Verify");
229
+ const verdicts = await parallel(
230
+ novel.map(({ angle, candidate }) => async (): Promise<Verified | null> => {
231
+ const location = primaryLocation(candidate);
232
+ const judged = await agent(
233
+ `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
234
+ `Location: ${formatLocation(candidate)}\n` +
235
+ `Category: ${candidate.category}\nSummary: ${candidate.summary}\nImpact: ${candidate.impact}\n\n` +
236
+ "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / REFUTED) " +
237
+ "with evidence quoting the line(s). Default toward REFUTED if you cannot substantiate it. Structured output only.",
238
+ {
239
+ phase: "Verify",
240
+ label: `verify:${location.file.split("/").pop() ?? location.file}`,
241
+ tools: TOOLS,
242
+ thinkingLevel: "low",
243
+ schema: AdvisoryVerdictSchema,
244
+ },
256
245
  );
257
- return verdicts.filter((value): value is Verified => value !== null);
258
- },
246
+ if (!judged) return null;
247
+ recordVerdictProgress(progress, candidate, judged);
248
+ return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
249
+ }),
259
250
  );
260
251
 
261
- const verified = (perAngle as Verified[][]).flat();
252
+ const verified = verdicts.filter((value): value is Verified => value !== null);
262
253
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
263
254
  const stats = makeStats(verified.length, surviving.length);
264
255
  progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
@@ -294,67 +285,8 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
294
285
 
295
286
  if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats };
296
287
 
297
- const findings = report.findings.map((finding) => {
298
- const source = ranked.find((candidate) => sameFinding(candidate, finding));
299
- return {
300
- ...finding,
301
- evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
302
- impact: finding.impact || source?.impact || "Impact not restated by synthesis.",
303
- };
288
+ const findings = backfillAdvisoryFindings(report.findings, ranked, {
289
+ impact: "Impact not restated by synthesis.",
304
290
  });
305
291
  return { ...report, findings, stats };
306
292
  }
307
-
308
- function primaryLocation(candidate: Pick<Candidate, "locations">): AdvisoryLocation {
309
- return candidate.locations[0] ?? { file: "" };
310
- }
311
-
312
- function formatLocation(candidate: Pick<Candidate, "locations">): string {
313
- const location = primaryLocation(candidate);
314
- const line = location.line != null ? `:${location.line}` : "";
315
- const symbol = location.symbol ? ` (${location.symbol})` : "";
316
- return `${location.file}${line}${symbol}`;
317
- }
318
-
319
- function formatEvidence(evidence: string[]): string {
320
- return evidence.join("; ");
321
- }
322
-
323
- function verdictLane(verdict: Verified["verdict"]): string {
324
- switch (verdict) {
325
- case "CONFIRMED":
326
- return "Confirmed";
327
- case "PLAUSIBLE":
328
- return "Plausible";
329
- case "REFUTED":
330
- return "Refuted";
331
- }
332
- }
333
-
334
- function verdictStatus(verdict: Verified["verdict"]): "success" | "warning" | "error" {
335
- switch (verdict) {
336
- case "CONFIRMED":
337
- return "success";
338
- case "PLAUSIBLE":
339
- return "warning";
340
- case "REFUTED":
341
- return "error";
342
- }
343
- }
344
-
345
- function verdictConfidence(verdict: Verified["verdict"]): "high" | "medium" | "low" {
346
- switch (verdict) {
347
- case "CONFIRMED":
348
- return "high";
349
- case "PLAUSIBLE":
350
- return "medium";
351
- case "REFUTED":
352
- return "low";
353
- }
354
- }
355
-
356
- function sameFinding(candidate: Verified, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
357
- const candidateLocation = primaryLocation(candidate);
358
- const findingLocation = primaryLocation(finding);
359
- return normalizePath(candidateLocation.file) === normalizePath(findingLocation.file) && candidateLocation.line === findingLocation.line;
360
- }