pi-workflow-engine 0.1.1 → 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,6 +1,15 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { Type } from "typebox";
3
- import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
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";
12
+ import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
4
13
 
5
14
  export const meta: WorkflowMeta = {
6
15
  name: "code-review",
@@ -16,49 +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
- }),
45
- ),
46
- });
28
+ type Candidate = AdvisoryCandidate;
47
29
 
48
- interface Candidate {
49
- file: string;
50
- line?: number;
51
- summary: string;
52
- failure_scenario: string;
53
- }
54
30
  interface Angle {
55
31
  label: string;
56
32
  kind: "bug" | "cleanup";
57
33
  text: string;
58
34
  }
59
35
  interface Verified extends Candidate {
60
- verdict: "CONFIRMED" | "PLAUSIBLE" | "REFUTED";
61
- evidence: string;
36
+ verdict: AdvisoryVerdict["verdict"];
37
+ evidence: string[];
62
38
  kind: "bug" | "cleanup";
63
39
  }
64
40
 
@@ -113,8 +89,18 @@ export function inDiff(changed: Map<string, Set<number>>, file: string, line?: n
113
89
  }
114
90
 
115
91
  export default async function run(api: WorkflowApi): Promise<unknown> {
116
- const { agent, parallel, pipeline, phase, log, args, cwd } = api;
92
+ const { agent, parallel, pipeline, phase, log, progress, args, cwd } = api;
117
93
  const target = args.trim();
94
+ let fileCount = 0;
95
+ let rawCandidateCount = 0;
96
+ let droppedCandidateCount = 0;
97
+ const makeStats = (verified: number, kept: number): WorkflowRunStats => ({
98
+ files: fileCount,
99
+ candidates: rawCandidateCount,
100
+ verified,
101
+ kept,
102
+ dropped: droppedCandidateCount,
103
+ });
118
104
 
119
105
  // ─── Phase 0: Scope ───
120
106
  phase("Scope");
@@ -137,8 +123,17 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
137
123
  { phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
138
124
  );
139
125
 
140
- if (!scope || scope.files.length === 0) {
141
- return { summary: "No changes found to review.", findings: [], stats: { candidates: 0, verified: 0 } };
126
+ if (!scope) {
127
+ return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
128
+ }
129
+
130
+ fileCount = scope.files.length;
131
+ progress({ type: "summary", key: "files", value: scope.files.join(", ") || "(none)" });
132
+ progress({ type: "summary", key: "diffCommand", value: scope.diffCommand });
133
+ progress({ type: "counter", key: "files", label: "files", value: fileCount });
134
+
135
+ if (scope.files.length === 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) };
142
137
  }
143
138
  log(`${scope.files.length} changed files`);
144
139
 
@@ -172,8 +167,11 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
172
167
 
173
168
  // Dedup state accumulates as finders complete (the pipeline has no barrier).
174
169
  const seen = new Set<string>();
175
- const dedupKey = (candidate: Candidate): string =>
176
- `${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
+ };
177
175
 
178
176
  // ─── Find → dedup → Verify (no barrier between stages) ───
179
177
  phase("Find");
@@ -187,15 +185,33 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
187
185
  `Review the change through ONLY this lens:\n${angle.text}\n` +
188
186
  "Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
189
187
  "You may read surrounding files for context, but never report issues in unchanged code. " +
190
- `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. ` +
191
- "Pass through anything with a nameable failure scenario — a separate verifier judges them next. Structured output only.",
192
- { 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 },
193
192
  );
194
193
  const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
194
+ rawCandidateCount += raw.length;
195
+ progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
195
196
  const gate = changed;
196
- const bounded = gate ? raw.filter((candidate) => inDiff(gate, candidate.file, candidate.line)) : raw;
197
- if (gate && raw.length - bounded.length > 0) {
198
- log(`find:${angle.label}: dropped ${raw.length - bounded.length} out-of-diff candidate(s)`);
197
+ const bounded = gate ? raw.filter((candidate) => inDiff(gate, primaryLocation(candidate).file, primaryLocation(candidate).line)) : raw;
198
+ const dropped = raw.length - bounded.length;
199
+ if (dropped > 0) {
200
+ droppedCandidateCount += dropped;
201
+ progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
202
+ }
203
+ if (gate && dropped > 0) {
204
+ log(`find:${angle.label}: dropped ${dropped} out-of-diff candidate(s)`);
205
+ }
206
+ for (const candidate of bounded) {
207
+ progress({
208
+ type: "lane_item",
209
+ lane: "Candidates",
210
+ title: candidate.summary,
211
+ subtitle: formatLocation(candidate),
212
+ status: "pending",
213
+ details: candidate.impact,
214
+ });
199
215
  }
200
216
  return { angle, candidates: bounded };
201
217
  },
@@ -210,15 +226,32 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
210
226
  });
211
227
  const verdicts = await parallel(
212
228
  novel.map((candidate) => async (): Promise<Verified | null> => {
229
+ const location = primaryLocation(candidate);
213
230
  const judged = await agent(
214
231
  `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
215
- `File: ${candidate.file}${candidate.line != null ? `:${candidate.line}` : ""}\n` +
216
- `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` +
217
234
  "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / REFUTED) " +
218
235
  "with evidence quoting the line(s). Default toward REFUTED if you cannot substantiate it. Structured output only.",
219
- { 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
+ },
220
243
  );
221
- return judged ? { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind } : null;
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 };
222
255
  }),
223
256
  );
224
257
  return verdicts.filter((value): value is Verified => value !== null);
@@ -227,10 +260,15 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
227
260
 
228
261
  const verified = (perAngle as Verified[][]).flat();
229
262
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
263
+ const stats = makeStats(verified.length, surviving.length);
264
+ progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
265
+ progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
266
+ progress({ type: "summary", key: "verified", value: verified.length });
267
+ progress({ type: "summary", key: "kept", value: surviving.length });
230
268
  log(`${verified.length} verified → ${surviving.length} kept`);
231
269
 
232
270
  if (surviving.length === 0) {
233
- return { summary: "No findings survived verification.", findings: [], stats: { candidates: seen.size, verified: verified.length } };
271
+ return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats };
234
272
  }
235
273
 
236
274
  // ─── Synthesize: rank, merge, report ───
@@ -240,16 +278,83 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
240
278
  const block = ranked
241
279
  .map(
242
280
  (finding, index) =>
243
- `### [${index}] ${finding.file}${finding.line != null ? `:${finding.line}` : ""} (${finding.verdict}${finding.kind === "cleanup" ? ", cleanup" : ""})\n` +
244
- `${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)}`,
245
284
  )
246
285
  .join("\n\n");
247
286
 
248
287
  const report = await agent(
249
288
  `## Synthesis: final code-review report\n\n${ranked.length} findings survived independent verification.\n\n${block}\n\n` +
250
- "Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final report. Structured output only.",
251
- { 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 },
252
293
  );
253
294
 
254
- return report ?? { summary: "Synthesis produced no output.", findings: [] };
295
+ if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats };
296
+
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
+ };
304
+ });
305
+ return { ...report, findings, stats };
306
+ }
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;
255
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
+ }