pi-workflow-engine 0.2.0 → 0.3.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.
@@ -1,5 +1,14 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { Type } from "typebox";
3
+ import {
4
+ AdvisoryCandidatesSchema,
5
+ AdvisoryReportSchema,
6
+ AdvisoryVerdictSchema,
7
+ type AdvisoryCandidate,
8
+ type AdvisoryFinding,
9
+ type AdvisoryLocation,
10
+ type AdvisoryVerdict,
11
+ } from "../src/advisory-schema.ts";
3
12
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
4
13
 
5
14
  export const meta: WorkflowMeta = {
@@ -16,51 +25,16 @@ const ScopeSchema = Type.Object({
16
25
  conventions: Type.Optional(Type.String({ description: "Relevant CLAUDE.md / project conventions" })),
17
26
  });
18
27
 
19
- const CandidatesSchema = Type.Object({
20
- candidates: Type.Array(
21
- Type.Object({
22
- file: Type.String(),
23
- line: Type.Optional(Type.Number()),
24
- summary: Type.String({ description: "One-line description of the issue" }),
25
- failure_scenario: Type.String({ description: "Concrete scenario where this causes a problem" }),
26
- }),
27
- ),
28
- });
29
-
30
- const VerdictSchema = Type.Object({
31
- verdict: Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE"), Type.Literal("REFUTED")]),
32
- evidence: Type.String({ description: "Quote or cite the relevant line(s)" }),
33
- });
34
-
35
- const ReportSchema = Type.Object({
36
- summary: Type.String(),
37
- findings: Type.Array(
38
- Type.Object({
39
- file: Type.String(),
40
- line: Type.Optional(Type.Number()),
41
- severity: Type.Union([Type.Literal("bug"), Type.Literal("cleanup")]),
42
- verdict: Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE")]),
43
- summary: Type.String(),
44
- evidence: Type.Optional(Type.String()),
45
- failure_scenario: Type.Optional(Type.String()),
46
- }),
47
- ),
48
- });
28
+ type Candidate = AdvisoryCandidate;
49
29
 
50
- interface Candidate {
51
- file: string;
52
- line?: number;
53
- summary: string;
54
- failure_scenario: string;
55
- }
56
30
  interface Angle {
57
31
  label: string;
58
32
  kind: "bug" | "cleanup";
59
33
  text: string;
60
34
  }
61
35
  interface Verified extends Candidate {
62
- verdict: "CONFIRMED" | "PLAUSIBLE" | "REFUTED";
63
- evidence: string;
36
+ verdict: AdvisoryVerdict["verdict"];
37
+ evidence: string[];
64
38
  kind: "bug" | "cleanup";
65
39
  }
66
40
 
@@ -150,7 +124,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
150
124
  );
151
125
 
152
126
  if (!scope) {
153
- return { summary: "No changes found to review.", findings: [], stats: makeStats(0, 0) };
127
+ return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
154
128
  }
155
129
 
156
130
  fileCount = scope.files.length;
@@ -159,7 +133,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
159
133
  progress({ type: "counter", key: "files", label: "files", value: fileCount });
160
134
 
161
135
  if (scope.files.length === 0) {
162
- return { summary: "No changes found to review.", findings: [], stats: makeStats(0, 0) };
136
+ return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
163
137
  }
164
138
  log(`${scope.files.length} changed files`);
165
139
 
@@ -193,8 +167,11 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
193
167
 
194
168
  // Dedup state accumulates as finders complete (the pipeline has no barrier).
195
169
  const seen = new Set<string>();
196
- const dedupKey = (candidate: Candidate): string =>
197
- `${candidate.file}:${candidate.line != null ? Math.round(candidate.line / 5) * 5 : candidate.summary.slice(0, 40).toLowerCase()}`;
170
+ const dedupKey = (candidate: Candidate): string => {
171
+ const location = primaryLocation(candidate);
172
+ const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : candidate.summary.slice(0, 40).toLowerCase();
173
+ return `${normalizePath(location.file)}:${lineKey}`;
174
+ };
198
175
 
199
176
  // ─── Find → dedup → Verify (no barrier between stages) ───
200
177
  phase("Find");
@@ -208,15 +185,16 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
208
185
  `Review the change through ONLY this lens:\n${angle.text}\n` +
209
186
  "Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
210
187
  "You may read surrounding files for context, but never report issues in unchanged code. " +
211
- `Surface up to ${PER_ANGLE} candidates, each with file, a line that appears in the diff, a one-line summary, and a concrete failure_scenario. ` +
212
- "Pass through anything with a nameable failure scenario — a separate verifier judges them next. Structured output only.",
213
- { phase: "Find", label: `find:${angle.label}`, tools: TOOLS, thinkingLevel: "low", schema: CandidatesSchema },
188
+ `Surface up to ${PER_ANGLE} candidates. Use category exactly "${angle.kind}". Each candidate must include a one-line summary, ` +
189
+ "locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
190
+ "Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
191
+ { phase: "Find", label: `find:${angle.label}`, tools: TOOLS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
214
192
  );
215
193
  const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
216
194
  rawCandidateCount += raw.length;
217
195
  progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
218
196
  const gate = changed;
219
- const bounded = gate ? raw.filter((candidate) => inDiff(gate, candidate.file, candidate.line)) : raw;
197
+ const bounded = gate ? raw.filter((candidate) => inDiff(gate, primaryLocation(candidate).file, primaryLocation(candidate).line)) : raw;
220
198
  const dropped = raw.length - bounded.length;
221
199
  if (dropped > 0) {
222
200
  droppedCandidateCount += dropped;
@@ -232,7 +210,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
232
210
  title: candidate.summary,
233
211
  subtitle: formatLocation(candidate),
234
212
  status: "pending",
235
- details: candidate.failure_scenario,
213
+ details: candidate.impact,
236
214
  });
237
215
  }
238
216
  return { angle, candidates: bounded };
@@ -248,13 +226,20 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
248
226
  });
249
227
  const verdicts = await parallel(
250
228
  novel.map((candidate) => async (): Promise<Verified | null> => {
229
+ const location = primaryLocation(candidate);
251
230
  const judged = await agent(
252
231
  `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
253
- `File: ${candidate.file}${candidate.line != null ? `:${candidate.line}` : ""}\n` +
254
- `Summary: ${candidate.summary}\nFailure scenario: ${candidate.failure_scenario}\n\n` +
232
+ `Location: ${formatLocation(candidate)}\n` +
233
+ `Category: ${candidate.category}\nSummary: ${candidate.summary}\nImpact: ${candidate.impact}\n\n` +
255
234
  "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / REFUTED) " +
256
235
  "with evidence quoting the line(s). Default toward REFUTED if you cannot substantiate it. Structured output only.",
257
- { phase: "Verify", label: `verify:${candidate.file.split("/").pop() ?? candidate.file}`, tools: TOOLS, thinkingLevel: "low", schema: VerdictSchema },
236
+ {
237
+ phase: "Verify",
238
+ label: `verify:${location.file.split("/").pop() ?? location.file}`,
239
+ tools: TOOLS,
240
+ thinkingLevel: "low",
241
+ schema: AdvisoryVerdictSchema,
242
+ },
258
243
  );
259
244
  if (!judged) return null;
260
245
  progress({ type: "counter_delta", key: `verdict.${judged.verdict.toLowerCase()}`, label: judged.verdict, delta: 1 });
@@ -264,7 +249,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
264
249
  title: candidate.summary,
265
250
  subtitle: formatLocation(candidate),
266
251
  status: verdictStatus(judged.verdict),
267
- details: judged.evidence,
252
+ details: formatEvidence(judged.evidence),
268
253
  });
269
254
  return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
270
255
  }),
@@ -283,7 +268,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
283
268
  log(`${verified.length} verified → ${surviving.length} kept`);
284
269
 
285
270
  if (surviving.length === 0) {
286
- return { summary: "No findings survived verification.", findings: [], stats };
271
+ return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats };
287
272
  }
288
273
 
289
274
  // ─── Synthesize: rank, merge, report ───
@@ -293,32 +278,46 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
293
278
  const block = ranked
294
279
  .map(
295
280
  (finding, index) =>
296
- `### [${index}] ${finding.file}${finding.line != null ? `:${finding.line}` : ""} (${finding.verdict}${finding.kind === "cleanup" ? ", cleanup" : ""})\n` +
297
- `${finding.summary}\nFailure: ${finding.failure_scenario}\nEvidence: ${finding.evidence}`,
281
+ `### [${index}] ${formatLocation(finding)} (${finding.verdict}${finding.kind === "cleanup" ? ", cleanup" : ""})\n` +
282
+ `Category: ${finding.kind}\nConfidence: ${verdictConfidence(finding.verdict)}\n` +
283
+ `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}`,
298
284
  )
299
285
  .join("\n\n");
300
286
 
301
287
  const report = await agent(
302
288
  `## Synthesis: final code-review report\n\n${ranked.length} findings survived independent verification.\n\n${block}\n\n` +
303
- "Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final report. Include evidence and failure_scenario for each finding when available. Structured output only.",
304
- { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: ReportSchema },
289
+ "Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final advisory report. " +
290
+ "Return summary, findings, and nextSteps. For each finding: category must be bug or cleanup; severity is impact level (low/medium/high), not category; " +
291
+ "confidence must be high for CONFIRMED and medium for PLAUSIBLE; copy locations and evidence arrays from the verified finding; impact is the concrete failure or maintenance scenario; recommendation is an advisory fix direction, not an edit. Structured output only.",
292
+ { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
305
293
  );
306
294
 
307
- if (!report) return { summary: "Synthesis produced no output.", findings: [], stats };
295
+ if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats };
308
296
 
309
297
  const findings = report.findings.map((finding) => {
310
298
  const source = ranked.find((candidate) => sameFinding(candidate, finding));
311
299
  return {
312
300
  ...finding,
313
- evidence: finding.evidence ?? source?.evidence,
314
- failure_scenario: finding.failure_scenario ?? source?.failure_scenario,
301
+ evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
302
+ impact: finding.impact || source?.impact || "Impact not restated by synthesis.",
315
303
  };
316
304
  });
317
305
  return { ...report, findings, stats };
318
306
  }
319
307
 
320
- function formatLocation(candidate: Pick<Candidate, "file" | "line">): string {
321
- return `${candidate.file}${candidate.line != null ? `:${candidate.line}` : ""}`;
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("; ");
322
321
  }
323
322
 
324
323
  function verdictLane(verdict: Verified["verdict"]): string {
@@ -343,6 +342,19 @@ function verdictStatus(verdict: Verified["verdict"]): "success" | "warning" | "e
343
342
  }
344
343
  }
345
344
 
346
- function sameFinding(candidate: Verified, finding: { file: string; line?: number; summary: string }): boolean {
347
- return normalizePath(candidate.file) === normalizePath(finding.file) && candidate.line === finding.line;
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;
348
360
  }
@@ -0,0 +1,305 @@
1
+ import { Type } from "typebox";
2
+ import {
3
+ AdvisoryCandidatesSchema,
4
+ AdvisoryReportSchema,
5
+ AdvisoryVerdictSchema,
6
+ type AdvisoryCandidate,
7
+ type AdvisoryFinding,
8
+ type AdvisoryLocation,
9
+ type AdvisoryReport,
10
+ type AdvisoryVerdict,
11
+ } from "../src/advisory-schema.ts";
12
+ import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
13
+
14
+ export const meta: WorkflowMeta = {
15
+ name: "diagnose",
16
+ description: "Advisory-only bug diagnosis: scope symptoms → competing hypotheses → independent verify → synthesize likely root causes.",
17
+ phases: [{ title: "Scope" }, { title: "Hypothesize" }, { title: "Verify" }, { title: "Synthesize" }],
18
+ };
19
+
20
+ const ScopeSchema = Type.Object({
21
+ symptom: Type.String({ description: "Observed bug, failing command, regression, or unclear behavior." }),
22
+ commands: Type.Array(Type.String(), { description: "Safe read-only or diagnostic commands relevant to the symptom." }),
23
+ files: Type.Array(Type.String(), { description: "Repository-relative files likely involved." }),
24
+ observations: Type.Array(Type.String(), { description: "Concrete observations from files, tests, config, or command output." }),
25
+ constraints: Type.Optional(Type.String({ description: "Safety constraints, missing evidence, or commands intentionally not run." })),
26
+ });
27
+
28
+ interface HypothesisLens {
29
+ label: string;
30
+ category: string;
31
+ text: string;
32
+ }
33
+
34
+ type Candidate = AdvisoryCandidate;
35
+
36
+ interface Hypothesis extends Candidate {
37
+ lens: HypothesisLens;
38
+ }
39
+
40
+ interface Verified extends Hypothesis {
41
+ verdict: AdvisoryVerdict["verdict"];
42
+ evidence: string[];
43
+ confidence?: AdvisoryVerdict["confidence"];
44
+ }
45
+
46
+ const HYPOTHESIS_LENSES: HypothesisLens[] = [
47
+ { label: "recent-change", category: "regression", text: "A recent code change broke a previously working path or changed an implicit contract." },
48
+ { label: "control-flow", category: "root-cause", text: "Incorrect branching, ordering, async flow, data flow, or state transition causes the symptom." },
49
+ { label: "configuration", category: "configuration", text: "Configuration, environment, package scripts, or runtime assumptions differ from what the code expects." },
50
+ { label: "dependency-api", category: "dependency", text: "A dependency API, version, import mode, or bundled peer behavior does not match the implementation." },
51
+ { label: "test-fixture", category: "test-fixture", text: "The failure is caused by test setup, fixtures, mocks, generated files, or stale local state rather than product code." },
52
+ ];
53
+
54
+ const TOOLS = ["read", "bash"];
55
+ const PER_LENS = 4;
56
+
57
+ export default async function run(api: WorkflowApi): Promise<unknown> {
58
+ const { agent, parallel, phase, log, progress, args } = api;
59
+ const symptom = args.trim();
60
+ let fileCount = 0;
61
+ let rawCandidateCount = 0;
62
+ let droppedCandidateCount = 0;
63
+ let refutedCandidateCount = 0;
64
+ const makeStats = (verified: number, kept: number): WorkflowRunStats => ({
65
+ files: fileCount,
66
+ candidates: rawCandidateCount,
67
+ verified,
68
+ kept,
69
+ dropped: droppedCandidateCount,
70
+ refuted: refutedCandidateCount,
71
+ });
72
+
73
+ phase("Scope");
74
+ const scope = await agent(
75
+ "Establish the scope for an advisory-only diagnosis workflow. Do not edit files.\n" +
76
+ (symptom
77
+ ? `Bug / failure description (verbatim): ${symptom}\n\n`
78
+ : "No explicit symptom was provided. Infer likely failing commands from repository manifests and scripts without running destructive commands.\n\n") +
79
+ "Inspect relevant files, package/test configuration, and safe diagnostic commands. " +
80
+ "Safe commands are read-only commands such as status, grep, listing files, typecheck/test commands, or commands explicitly requested by the user. " +
81
+ "Do not run mutation, install, commit, network, or destructive commands. Return scoped files, observations, and constraints. Structured output only.",
82
+ { phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
83
+ );
84
+
85
+ if (!scope) {
86
+ return emptyReport(
87
+ "Diagnosis could not establish a scope.",
88
+ ["Provide the failing command, error message, or regression description and rerun diagnose."],
89
+ makeStats(0, 0),
90
+ );
91
+ }
92
+
93
+ fileCount = scope.files.length;
94
+ progress({ type: "counter", key: "files", label: "files", value: fileCount });
95
+ progress({ type: "summary", key: "symptom", value: scope.symptom });
96
+ progress({ type: "summary", key: "files", value: scope.files.join(", ") || "(none)" });
97
+ log(`${scope.files.length} files scoped for diagnosis`);
98
+
99
+ const scopeBlock =
100
+ `## Symptom\n${scope.symptom}\n\n## Relevant commands\n${scope.commands.map((command) => `- ${command}`).join("\n") || "(none)"}\n\n` +
101
+ `## Files\n${scope.files.map((file) => `- ${file}`).join("\n") || "(none)"}\n\n` +
102
+ `## Observations\n${scope.observations.map((observation) => `- ${observation}`).join("\n") || "(none)"}\n\n` +
103
+ `## Constraints\n${scope.constraints ?? "(none noted)"}\n`;
104
+
105
+ phase("Hypothesize");
106
+ const perLens = await parallel(
107
+ HYPOTHESIS_LENSES.map((lens) => async (): Promise<Hypothesis[]> => {
108
+ const found = await agent(
109
+ `## Diagnose hypothesis generator — ${lens.label}\n\n${scopeBlock}\n` +
110
+ "This workflow is advisory-only: diagnose and recommend validation/fix plans, but do not edit files.\n" +
111
+ `Consider ONLY this hypothesis lens:\n${lens.text}\n\n` +
112
+ `Surface up to ${PER_LENS} root-cause hypotheses. Use category exactly "${lens.category}". ` +
113
+ "Each hypothesis must include a one-line summary, locations, impact explaining how it produces the symptom, and an optional recommendation for the next validation step. Structured output only.",
114
+ { phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
115
+ );
116
+ const candidates = (found?.candidates ?? []).slice(0, PER_LENS).map((candidate) => ({ ...candidate, lens }));
117
+ rawCandidateCount += candidates.length;
118
+ progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: candidates.length });
119
+ for (const candidate of candidates) {
120
+ progress({
121
+ type: "lane_item",
122
+ lane: "Hypotheses",
123
+ title: candidate.summary,
124
+ subtitle: formatLocation(candidate),
125
+ status: "pending",
126
+ details: candidate.impact,
127
+ });
128
+ }
129
+ return candidates;
130
+ }),
131
+ );
132
+
133
+ const hypotheses = dedupe(perLens.flat(), (dropped) => {
134
+ droppedCandidateCount += dropped;
135
+ progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
136
+ });
137
+
138
+ phase("Verify");
139
+ const verified = (
140
+ await parallel(
141
+ hypotheses.map((hypothesis) => async (): Promise<Verified | null> => {
142
+ const location = primaryLocation(hypothesis);
143
+ const judged = await agent(
144
+ `## Diagnose verifier\n\n${scopeBlock}\n## Hypothesis\n` +
145
+ `Location: ${formatLocation(hypothesis)}\nCategory: ${hypothesis.category}\nSummary: ${hypothesis.summary}\nImpact: ${hypothesis.impact}\n` +
146
+ `Recommended validation: ${hypothesis.recommendation ?? "(none supplied)"}\n\n` +
147
+ "Read relevant files and, when useful, run only safe read-only diagnostic commands from the scoped command list or commands explicitly requested by the user. " +
148
+ "Do not run mutation, install, commit, network, or destructive commands. Return CONFIRMED, PLAUSIBLE, or REFUTED with evidence. " +
149
+ "Default toward REFUTED if evidence does not connect the hypothesis to the symptom. Structured output only.",
150
+ {
151
+ phase: "Verify",
152
+ label: `verify:${location.file.split("/").pop() ?? location.file}`,
153
+ tools: TOOLS,
154
+ thinkingLevel: "low",
155
+ schema: AdvisoryVerdictSchema,
156
+ },
157
+ );
158
+ if (!judged) return null;
159
+ progress({ type: "counter_delta", key: `verdict.${judged.verdict.toLowerCase()}`, label: judged.verdict, delta: 1 });
160
+ if (judged.verdict === "REFUTED") {
161
+ refutedCandidateCount += 1;
162
+ progress({ type: "counter_delta", key: "refuted", label: "refuted", delta: 1 });
163
+ }
164
+ progress({
165
+ type: "lane_item",
166
+ lane: verdictLane(judged.verdict),
167
+ title: hypothesis.summary,
168
+ subtitle: formatLocation(hypothesis),
169
+ status: verdictStatus(judged.verdict),
170
+ details: formatEvidence(judged.evidence),
171
+ });
172
+ return { ...hypothesis, verdict: judged.verdict, evidence: judged.evidence, confidence: judged.confidence };
173
+ }),
174
+ )
175
+ ).filter((value): value is Verified => value !== null);
176
+
177
+ const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
178
+ const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
179
+ const stats = makeStats(verified.length, surviving.length);
180
+ progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
181
+ progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
182
+ progress({ type: "summary", key: "verified", value: verified.length });
183
+ progress({ type: "summary", key: "kept", value: surviving.length });
184
+ log(`${verified.length} verified → ${surviving.length} kept`);
185
+
186
+ if (surviving.length === 0) {
187
+ return emptyReport(
188
+ "No root-cause hypothesis survived verification.",
189
+ ["Capture the exact failing command and error output.", "Rerun diagnose with a narrower symptom or more evidence."],
190
+ stats,
191
+ );
192
+ }
193
+
194
+ phase("Synthesize");
195
+ const ranked = [...surviving].sort((a, b) => rank(a) - rank(b));
196
+ const block = ranked
197
+ .map(
198
+ (finding, index) =>
199
+ `### [${index}] ${formatLocation(finding)} (${finding.verdict}, ${finding.category})\n` +
200
+ `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}\nValidation/fix plan: ${finding.recommendation ?? "(none supplied)"}`,
201
+ )
202
+ .join("\n\n");
203
+ const refutedBlock = refuted
204
+ .slice(0, 8)
205
+ .map((finding) => `- ${finding.summary} — REFUTED because ${formatEvidence(finding.evidence)}`)
206
+ .join("\n");
207
+
208
+ const report = await agent(
209
+ `## Synthesis: final diagnosis report\n\n${ranked.length} hypotheses survived independent verification.\n\n${block}\n\n` +
210
+ `## Refuted hypotheses for context\n${refutedBlock || "(none recorded)"}\n\n` +
211
+ "Produce the shared advisory report shape. Only include confirmed or plausible root causes in findings. " +
212
+ "Use categories such as root-cause, regression, configuration, dependency, or test-fixture. " +
213
+ "Recommendation must be a validation/fix plan, not a patch. nextSteps must be the minimum commands or code inspections needed to confirm the top diagnosis. Structured output only.",
214
+ { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
215
+ );
216
+
217
+ if (!report) return emptyReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun diagnose with a narrower symptom."], stats);
218
+
219
+ const findings = report.findings.map((finding) => {
220
+ const source = ranked.find((candidate) => sameFinding(candidate, finding));
221
+ return {
222
+ ...finding,
223
+ evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
224
+ impact: finding.impact || source?.impact || "Impact not restated by synthesis.",
225
+ recommendation: finding.recommendation || source?.recommendation || "Validate this diagnosis with the smallest safe reproduction command.",
226
+ };
227
+ });
228
+ return { ...report, findings, stats };
229
+ }
230
+
231
+ function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
232
+ return { summary, findings: [], nextSteps, stats };
233
+ }
234
+
235
+ function dedupe(candidates: Hypothesis[], onDropped: (dropped: number) => void): Hypothesis[] {
236
+ const seen = new Set<string>();
237
+ const novel: Hypothesis[] = [];
238
+ for (const candidate of candidates) {
239
+ const key = dedupKey(candidate);
240
+ if (seen.has(key)) continue;
241
+ seen.add(key);
242
+ novel.push(candidate);
243
+ }
244
+ const dropped = candidates.length - novel.length;
245
+ if (dropped > 0) onDropped(dropped);
246
+ return novel;
247
+ }
248
+
249
+ function dedupKey(candidate: Candidate): string {
250
+ const location = primaryLocation(candidate);
251
+ const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : "file";
252
+ return `${candidate.category}:${normalizePath(location.file)}:${lineKey}:${candidate.summary.slice(0, 60).toLowerCase()}`;
253
+ }
254
+
255
+ function primaryLocation(candidate: Pick<Candidate, "locations">): AdvisoryLocation {
256
+ return candidate.locations[0] ?? { file: "" };
257
+ }
258
+
259
+ function formatLocation(candidate: Pick<Candidate, "locations">): string {
260
+ const location = primaryLocation(candidate);
261
+ const line = location.line != null ? `:${location.line}` : "";
262
+ const symbol = location.symbol ? ` (${location.symbol})` : "";
263
+ return `${location.file}${line}${symbol}`;
264
+ }
265
+
266
+ function formatEvidence(evidence: string[]): string {
267
+ return evidence.join("; ");
268
+ }
269
+
270
+ function normalizePath(path: string): string {
271
+ return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
272
+ }
273
+
274
+ function rank(finding: Verified): number {
275
+ if (finding.verdict === "CONFIRMED") return 0;
276
+ return 1;
277
+ }
278
+
279
+ function verdictLane(verdict: Verified["verdict"]): string {
280
+ switch (verdict) {
281
+ case "CONFIRMED":
282
+ return "Confirmed";
283
+ case "PLAUSIBLE":
284
+ return "Plausible";
285
+ case "REFUTED":
286
+ return "Refuted";
287
+ }
288
+ }
289
+
290
+ function verdictStatus(verdict: Verified["verdict"]): "success" | "warning" | "error" {
291
+ switch (verdict) {
292
+ case "CONFIRMED":
293
+ return "success";
294
+ case "PLAUSIBLE":
295
+ return "warning";
296
+ case "REFUTED":
297
+ return "error";
298
+ }
299
+ }
300
+
301
+ function sameFinding(candidate: Verified, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
302
+ const candidateLocation = primaryLocation(candidate);
303
+ const findingLocation = primaryLocation(finding);
304
+ return normalizePath(candidateLocation.file) === normalizePath(findingLocation.file) && candidateLocation.line === findingLocation.line;
305
+ }