pi-workflow-engine 0.2.0 → 0.4.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 (37) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +256 -0
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +64 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
  4. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
  5. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
  6. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
  7. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +114 -0
  8. package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
  9. package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
  10. package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
  11. package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
  12. package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
  13. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
  14. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
  15. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +198 -0
  16. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
  17. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
  18. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +24 -0
  19. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +89 -145
  20. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +244 -0
  21. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +188 -0
  22. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
  23. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +181 -0
  24. package/.pi/settings.json +3 -0
  25. package/README.md +163 -63
  26. package/assets/preview-candidate-core.png +0 -0
  27. package/assets/preview-candidate-relic-emblem.png +0 -0
  28. package/assets/preview-candidate-robot-squad.png +0 -0
  29. package/assets/preview.png +0 -0
  30. package/package.json +32 -6
  31. package/src/agent-runner.ts +0 -122
  32. package/src/concurrency.ts +0 -55
  33. package/src/discovery.ts +0 -54
  34. package/src/index.ts +0 -141
  35. package/src/ui/workflow-result-renderer.ts +0 -149
  36. package/src/ui/workflow-widget.ts +0 -143
  37. package/src/workflows.ts +0 -14
@@ -1,5 +1,21 @@
1
- import { execSync } from "node:child_process";
2
1
  import { Type } from "typebox";
2
+ import {
3
+ AdvisoryCandidatesSchema,
4
+ AdvisoryReportSchema,
5
+ AdvisoryVerdictSchema,
6
+ type AdvisoryCandidate,
7
+ type AdvisoryVerdict,
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";
3
19
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
4
20
 
5
21
  export const meta: WorkflowMeta = {
@@ -13,54 +29,19 @@ const ScopeSchema = Type.Object({
13
29
  diffCommand: Type.String({ description: "Exact git command that produces the review diff" }),
14
30
  files: Type.Array(Type.String(), { description: "Changed file paths" }),
15
31
  summary: Type.String({ description: "One-paragraph summary of the change" }),
16
- conventions: Type.Optional(Type.String({ description: "Relevant CLAUDE.md / project conventions" })),
32
+ conventions: Type.Optional(Type.String({ description: "Relevant AGENTS.md / project conventions" })),
17
33
  });
18
34
 
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
- });
35
+ type Candidate = AdvisoryCandidate;
49
36
 
50
- interface Candidate {
51
- file: string;
52
- line?: number;
53
- summary: string;
54
- failure_scenario: string;
55
- }
56
37
  interface Angle {
57
38
  label: string;
58
39
  kind: "bug" | "cleanup";
59
40
  text: string;
60
41
  }
61
42
  interface Verified extends Candidate {
62
- verdict: "CONFIRMED" | "PLAUSIBLE" | "REFUTED";
63
- evidence: string;
43
+ verdict: AdvisoryVerdict["verdict"];
44
+ evidence: string[];
64
45
  kind: "bug" | "cleanup";
65
46
  }
66
47
 
@@ -77,11 +58,6 @@ const TOOLS = ["read", "bash"];
77
58
  const PER_ANGLE = 6;
78
59
  const DIFF_EMBED_CAP = 60_000;
79
60
 
80
- /** Strip leading "./", "a/", "b/" so diff paths and finder-reported paths compare equal. */
81
- export function normalizePath(path: string): string {
82
- return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
83
- }
84
-
85
61
  /** Parse a unified diff into the set of added/changed new-file line numbers per file. */
86
62
  export function changedLines(diff: string): Map<string, Set<number>> {
87
63
  const byFile = new Map<string, Set<number>>();
@@ -115,7 +91,7 @@ export function inDiff(changed: Map<string, Set<number>>, file: string, line?: n
115
91
  }
116
92
 
117
93
  export default async function run(api: WorkflowApi): Promise<unknown> {
118
- const { agent, parallel, pipeline, phase, log, progress, args, cwd } = api;
94
+ const { agent, parallel, phase, log, progress, args, cwd, signal } = api;
119
95
  const target = args.trim();
120
96
  let fileCount = 0;
121
97
  let rawCandidateCount = 0;
@@ -144,13 +120,13 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
144
120
  "If the branch itself is main/master, use `git diff HEAD~1`.\n" +
145
121
  "4. Run the chosen command to confirm the diff is non-empty.\n\n" +
146
122
  "Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
147
- "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" +
148
124
  "Return diffCommand exactly as a reviewer should run it. Structured output only.",
149
125
  { phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
150
126
  );
151
127
 
152
128
  if (!scope) {
153
- return { summary: "No changes found to review.", findings: [], stats: makeStats(0, 0) };
129
+ return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
154
130
  }
155
131
 
156
132
  fileCount = scope.files.length;
@@ -159,22 +135,20 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
159
135
  progress({ type: "counter", key: "files", label: "files", value: fileCount });
160
136
 
161
137
  if (scope.files.length === 0) {
162
- return { summary: "No changes found to review.", findings: [], stats: makeStats(0, 0) };
138
+ return { summary: "No changes found to review.", findings: [], nextSteps: ["Provide a PR, ref range, or changed files to review."], stats: makeStats(0, 0) };
163
139
  }
164
140
  log(`${scope.files.length} changed files`);
165
141
 
166
142
  // Capture the diff once, deterministically, so findings can be bounded to changed lines in code.
167
143
  let changed: Map<string, Set<number>> | null = null;
168
144
  let diffText = "";
169
- if (/^(git diff|gh pr diff)\b/.test(scope.diffCommand)) {
170
- try {
171
- diffText = execSync(scope.diffCommand, { cwd, encoding: "utf8", maxBuffer: 16 << 20 });
172
- changed = changedLines(diffText);
173
- } catch (error) {
174
- log(`diff capture failed (${error instanceof Error ? error.message : String(error)}) — reviewing without the line gate`);
175
- }
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 });
176
150
  } else {
177
- 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`);
178
152
  }
179
153
 
180
154
  const diffBlock = diffText
@@ -191,32 +165,33 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
191
165
  diffBlock +
192
166
  (target ? `\n## User instructions (verbatim)\n${target}\n` : "");
193
167
 
194
- // 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.
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
- // ─── Find → dedup → Verify (no barrier between stages) ───
176
+ // ─── Find barrier → dedup → Verify ───
200
177
  phase("Find");
201
- const perAngle = await pipeline(
202
- ANGLES,
203
- // Stage 1: each angle finds candidates through its single lens.
204
- async (_prev, item) => {
205
- const angle = item as Angle;
178
+ const perAngle = await parallel(
179
+ ANGLES.map((angle) => async () => {
206
180
  const found = await agent(
207
181
  `## Code-review finder — ${angle.label}\n\n${scopeBlock}\n` +
208
182
  `Review the change through ONLY this lens:\n${angle.text}\n` +
209
183
  "Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
210
184
  "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 },
185
+ `Surface up to ${PER_ANGLE} candidates. Use category exactly "${angle.kind}". Each candidate must include a one-line summary, ` +
186
+ "locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
187
+ "Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
188
+ { phase: "Find", label: `find:${angle.label}`, tools: TOOLS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
214
189
  );
215
190
  const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
216
191
  rawCandidateCount += raw.length;
217
192
  progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
218
193
  const gate = changed;
219
- const bounded = gate ? raw.filter((candidate) => inDiff(gate, candidate.file, candidate.line)) : raw;
194
+ const bounded = gate ? raw.filter((candidate) => inDiff(gate, primaryLocation(candidate).file, primaryLocation(candidate).line)) : raw;
220
195
  const dropped = raw.length - bounded.length;
221
196
  if (dropped > 0) {
222
197
  droppedCandidateCount += dropped;
@@ -232,48 +207,49 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
232
207
  title: candidate.summary,
233
208
  subtitle: formatLocation(candidate),
234
209
  status: "pending",
235
- details: candidate.failure_scenario,
210
+ details: candidate.impact,
236
211
  });
237
212
  }
238
213
  return { angle, candidates: bounded };
239
- },
240
- // Stage 2: dedup against the shared set, then verify survivors concurrently.
241
- async (prev) => {
242
- const { angle, candidates } = prev as { angle: Angle; candidates: Candidate[] };
243
- const novel = candidates.filter((candidate) => {
214
+ }),
215
+ );
216
+
217
+ const novel = perAngle.flatMap(({ angle, candidates }) =>
218
+ candidates
219
+ .filter((candidate) => {
244
220
  const key = dedupKey(candidate);
245
221
  if (seen.has(key)) return false;
246
222
  seen.add(key);
247
223
  return true;
248
- });
249
- const verdicts = await parallel(
250
- novel.map((candidate) => async (): Promise<Verified | null> => {
251
- const judged = await agent(
252
- `## 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` +
255
- "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / REFUTED) " +
256
- "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 },
258
- );
259
- if (!judged) return null;
260
- progress({ type: "counter_delta", key: `verdict.${judged.verdict.toLowerCase()}`, label: judged.verdict, delta: 1 });
261
- progress({
262
- type: "lane_item",
263
- lane: verdictLane(judged.verdict),
264
- title: candidate.summary,
265
- subtitle: formatLocation(candidate),
266
- status: verdictStatus(judged.verdict),
267
- details: judged.evidence,
268
- });
269
- return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
270
- }),
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
+ },
271
245
  );
272
- return verdicts.filter((value): value is Verified => value !== null);
273
- },
246
+ if (!judged) return null;
247
+ recordVerdictProgress(progress, candidate, judged);
248
+ return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
249
+ }),
274
250
  );
275
251
 
276
- const verified = (perAngle as Verified[][]).flat();
252
+ const verified = verdicts.filter((value): value is Verified => value !== null);
277
253
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
278
254
  const stats = makeStats(verified.length, surviving.length);
279
255
  progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
@@ -283,7 +259,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
283
259
  log(`${verified.length} verified → ${surviving.length} kept`);
284
260
 
285
261
  if (surviving.length === 0) {
286
- return { summary: "No findings survived verification.", findings: [], stats };
262
+ return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats };
287
263
  }
288
264
 
289
265
  // ─── Synthesize: rank, merge, report ───
@@ -293,56 +269,24 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
293
269
  const block = ranked
294
270
  .map(
295
271
  (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}`,
272
+ `### [${index}] ${formatLocation(finding)} (${finding.verdict}${finding.kind === "cleanup" ? ", cleanup" : ""})\n` +
273
+ `Category: ${finding.kind}\nConfidence: ${verdictConfidence(finding.verdict)}\n` +
274
+ `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}`,
298
275
  )
299
276
  .join("\n\n");
300
277
 
301
278
  const report = await agent(
302
279
  `## 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 },
280
+ "Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final advisory report. " +
281
+ "Return summary, findings, and nextSteps. For each finding: category must be bug or cleanup; severity is impact level (low/medium/high), not category; " +
282
+ "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.",
283
+ { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
305
284
  );
306
285
 
307
- if (!report) return { summary: "Synthesis produced no output.", findings: [], stats };
286
+ if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats };
308
287
 
309
- const findings = report.findings.map((finding) => {
310
- const source = ranked.find((candidate) => sameFinding(candidate, finding));
311
- return {
312
- ...finding,
313
- evidence: finding.evidence ?? source?.evidence,
314
- failure_scenario: finding.failure_scenario ?? source?.failure_scenario,
315
- };
288
+ const findings = backfillAdvisoryFindings(report.findings, ranked, {
289
+ impact: "Impact not restated by synthesis.",
316
290
  });
317
291
  return { ...report, findings, stats };
318
292
  }
319
-
320
- function formatLocation(candidate: Pick<Candidate, "file" | "line">): string {
321
- return `${candidate.file}${candidate.line != null ? `:${candidate.line}` : ""}`;
322
- }
323
-
324
- function verdictLane(verdict: Verified["verdict"]): string {
325
- switch (verdict) {
326
- case "CONFIRMED":
327
- return "Confirmed";
328
- case "PLAUSIBLE":
329
- return "Plausible";
330
- case "REFUTED":
331
- return "Refuted";
332
- }
333
- }
334
-
335
- function verdictStatus(verdict: Verified["verdict"]): "success" | "warning" | "error" {
336
- switch (verdict) {
337
- case "CONFIRMED":
338
- return "success";
339
- case "PLAUSIBLE":
340
- return "warning";
341
- case "REFUTED":
342
- return "error";
343
- }
344
- }
345
-
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;
348
- }
@@ -0,0 +1,244 @@
1
+ import { Type } from "typebox";
2
+ import {
3
+ AdvisoryCandidatesSchema,
4
+ AdvisoryReportSchema,
5
+ AdvisoryVerdictSchema,
6
+ type AdvisoryCandidate,
7
+ type AdvisoryReport,
8
+ type AdvisoryVerdict,
9
+ } from "../src/advisory-schema.ts";
10
+ import {
11
+ advisoryDedupKey as dedupKey,
12
+ backfillAdvisoryFindings,
13
+ formatEvidence,
14
+ formatLocation,
15
+ primaryLocation,
16
+ recordVerdictProgress,
17
+ } from "../src/workflow-advisory-utils.ts";
18
+ import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
19
+
20
+ export const meta: WorkflowMeta = {
21
+ name: "diagnose",
22
+ description: "Advisory-only bug diagnosis: scope symptoms → competing hypotheses → independent verify → synthesize likely root causes.",
23
+ phases: [{ title: "Scope" }, { title: "Hypothesize" }, { title: "Verify" }, { title: "Synthesize" }],
24
+ };
25
+
26
+ const ScopeSchema = Type.Object({
27
+ symptom: Type.String({ description: "Observed bug, failing command, regression, or unclear behavior." }),
28
+ commands: Type.Array(Type.String(), { description: "Safe read-only or diagnostic commands relevant to the symptom." }),
29
+ files: Type.Array(Type.String(), { description: "Repository-relative files likely involved." }),
30
+ observations: Type.Array(Type.String(), { description: "Concrete observations from files, tests, config, or command output." }),
31
+ constraints: Type.Optional(Type.String({ description: "Safety constraints, missing evidence, or commands intentionally not run." })),
32
+ });
33
+
34
+ interface HypothesisLens {
35
+ label: string;
36
+ category: string;
37
+ text: string;
38
+ }
39
+
40
+ type Candidate = AdvisoryCandidate;
41
+
42
+ interface Hypothesis extends Candidate {
43
+ lens: HypothesisLens;
44
+ }
45
+
46
+ interface Verified extends Hypothesis {
47
+ verdict: AdvisoryVerdict["verdict"];
48
+ evidence: string[];
49
+ confidence?: AdvisoryVerdict["confidence"];
50
+ }
51
+
52
+ const HYPOTHESIS_LENSES: HypothesisLens[] = [
53
+ { label: "recent-change", category: "regression", text: "A recent code change broke a previously working path or changed an implicit contract." },
54
+ { label: "control-flow", category: "root-cause", text: "Incorrect branching, ordering, async flow, data flow, or state transition causes the symptom." },
55
+ { label: "configuration", category: "configuration", text: "Configuration, environment, package scripts, or runtime assumptions differ from what the code expects." },
56
+ { label: "dependency-api", category: "dependency", text: "A dependency API, version, import mode, or bundled peer behavior does not match the implementation." },
57
+ { 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." },
58
+ ];
59
+
60
+ const TOOLS = ["read", "bash"];
61
+ const PER_LENS = 4;
62
+
63
+ export default async function run(api: WorkflowApi): Promise<unknown> {
64
+ const { agent, parallel, phase, log, progress, args } = api;
65
+ const symptom = args.trim();
66
+ let fileCount = 0;
67
+ let rawCandidateCount = 0;
68
+ let droppedCandidateCount = 0;
69
+ let refutedCandidateCount = 0;
70
+ const makeStats = (verified: number, kept: number): WorkflowRunStats => ({
71
+ files: fileCount,
72
+ candidates: rawCandidateCount,
73
+ verified,
74
+ kept,
75
+ dropped: droppedCandidateCount,
76
+ refuted: refutedCandidateCount,
77
+ });
78
+
79
+ phase("Scope");
80
+ const scope = await agent(
81
+ "Establish the scope for an advisory-only diagnosis workflow. Do not edit files.\n" +
82
+ (symptom
83
+ ? `Bug / failure description (verbatim): ${symptom}\n\n`
84
+ : "No explicit symptom was provided. Infer likely failing commands from repository manifests and scripts without running destructive commands.\n\n") +
85
+ "Inspect relevant files, package/test configuration, and safe diagnostic commands. " +
86
+ "Safe commands are read-only commands such as status, grep, listing files, typecheck/test commands, or commands explicitly requested by the user. " +
87
+ "Do not run mutation, install, commit, network, or destructive commands. Return scoped files, observations, and constraints. Structured output only.",
88
+ { phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
89
+ );
90
+
91
+ if (!scope) {
92
+ return emptyReport(
93
+ "Diagnosis could not establish a scope.",
94
+ ["Provide the failing command, error message, or regression description and rerun diagnose."],
95
+ makeStats(0, 0),
96
+ );
97
+ }
98
+
99
+ fileCount = scope.files.length;
100
+ progress({ type: "counter", key: "files", label: "files", value: fileCount });
101
+ progress({ type: "summary", key: "symptom", value: scope.symptom });
102
+ progress({ type: "summary", key: "files", value: scope.files.join(", ") || "(none)" });
103
+ log(`${scope.files.length} files scoped for diagnosis`);
104
+
105
+ const scopeBlock =
106
+ `## Symptom\n${scope.symptom}\n\n## Relevant commands\n${scope.commands.map((command) => `- ${command}`).join("\n") || "(none)"}\n\n` +
107
+ `## Files\n${scope.files.map((file) => `- ${file}`).join("\n") || "(none)"}\n\n` +
108
+ `## Observations\n${scope.observations.map((observation) => `- ${observation}`).join("\n") || "(none)"}\n\n` +
109
+ `## Constraints\n${scope.constraints ?? "(none noted)"}\n`;
110
+
111
+ phase("Hypothesize");
112
+ const perLens = await parallel(
113
+ HYPOTHESIS_LENSES.map((lens) => async (): Promise<Hypothesis[]> => {
114
+ const found = await agent(
115
+ `## Diagnose hypothesis generator — ${lens.label}\n\n${scopeBlock}\n` +
116
+ "This workflow is advisory-only: diagnose and recommend validation/fix plans, but do not edit files.\n" +
117
+ `Consider ONLY this hypothesis lens:\n${lens.text}\n\n` +
118
+ `Surface up to ${PER_LENS} root-cause hypotheses. Use category exactly "${lens.category}". ` +
119
+ "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.",
120
+ { phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
121
+ );
122
+ const candidates = (found?.candidates ?? []).slice(0, PER_LENS).map((candidate) => ({ ...candidate, lens }));
123
+ rawCandidateCount += candidates.length;
124
+ progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: candidates.length });
125
+ for (const candidate of candidates) {
126
+ progress({
127
+ type: "lane_item",
128
+ lane: "Hypotheses",
129
+ title: candidate.summary,
130
+ subtitle: formatLocation(candidate),
131
+ status: "pending",
132
+ details: candidate.impact,
133
+ });
134
+ }
135
+ return candidates;
136
+ }),
137
+ );
138
+
139
+ const hypotheses = dedupe(perLens.flat(), (dropped) => {
140
+ droppedCandidateCount += dropped;
141
+ progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
142
+ });
143
+
144
+ phase("Verify");
145
+ const verified = (
146
+ await parallel(
147
+ hypotheses.map((hypothesis) => async (): Promise<Verified | null> => {
148
+ const location = primaryLocation(hypothesis);
149
+ const judged = await agent(
150
+ `## Diagnose verifier\n\n${scopeBlock}\n## Hypothesis\n` +
151
+ `Location: ${formatLocation(hypothesis)}\nCategory: ${hypothesis.category}\nSummary: ${hypothesis.summary}\nImpact: ${hypothesis.impact}\n` +
152
+ `Recommended validation: ${hypothesis.recommendation ?? "(none supplied)"}\n\n` +
153
+ "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. " +
154
+ "Do not run mutation, install, commit, network, or destructive commands. Return CONFIRMED, PLAUSIBLE, or REFUTED with evidence. " +
155
+ "Default toward REFUTED if evidence does not connect the hypothesis to the symptom. Structured output only.",
156
+ {
157
+ phase: "Verify",
158
+ label: `verify:${location.file.split("/").pop() ?? location.file}`,
159
+ tools: TOOLS,
160
+ thinkingLevel: "low",
161
+ schema: AdvisoryVerdictSchema,
162
+ },
163
+ );
164
+ if (!judged) return null;
165
+ recordVerdictProgress(progress, hypothesis, judged, () => {
166
+ refutedCandidateCount += 1;
167
+ });
168
+ return { ...hypothesis, verdict: judged.verdict, evidence: judged.evidence, confidence: judged.confidence };
169
+ }),
170
+ )
171
+ ).filter((value): value is Verified => value !== null);
172
+
173
+ const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
174
+ const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
175
+ const stats = makeStats(verified.length, surviving.length);
176
+ progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
177
+ progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
178
+ progress({ type: "summary", key: "verified", value: verified.length });
179
+ progress({ type: "summary", key: "kept", value: surviving.length });
180
+ log(`${verified.length} verified → ${surviving.length} kept`);
181
+
182
+ if (surviving.length === 0) {
183
+ return emptyReport(
184
+ "No root-cause hypothesis survived verification.",
185
+ ["Capture the exact failing command and error output.", "Rerun diagnose with a narrower symptom or more evidence."],
186
+ stats,
187
+ );
188
+ }
189
+
190
+ phase("Synthesize");
191
+ const ranked = [...surviving].sort((a, b) => rank(a) - rank(b));
192
+ const block = ranked
193
+ .map(
194
+ (finding, index) =>
195
+ `### [${index}] ${formatLocation(finding)} (${finding.verdict}, ${finding.category})\n` +
196
+ `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}\nValidation/fix plan: ${finding.recommendation ?? "(none supplied)"}`,
197
+ )
198
+ .join("\n\n");
199
+ const refutedBlock = refuted
200
+ .slice(0, 8)
201
+ .map((finding) => `- ${finding.summary} — REFUTED because ${formatEvidence(finding.evidence)}`)
202
+ .join("\n");
203
+
204
+ const report = await agent(
205
+ `## Synthesis: final diagnosis report\n\n${ranked.length} hypotheses survived independent verification.\n\n${block}\n\n` +
206
+ `## Refuted hypotheses for context\n${refutedBlock || "(none recorded)"}\n\n` +
207
+ "Produce the shared advisory report shape. Only include confirmed or plausible root causes in findings. " +
208
+ "Use categories such as root-cause, regression, configuration, dependency, or test-fixture. " +
209
+ "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.",
210
+ { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: AdvisoryReportSchema },
211
+ );
212
+
213
+ if (!report) return emptyReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun diagnose with a narrower symptom."], stats);
214
+
215
+ const findings = backfillAdvisoryFindings(report.findings, ranked, {
216
+ impact: "Impact not restated by synthesis.",
217
+ recommendation: "Validate this diagnosis with the smallest safe reproduction command.",
218
+ });
219
+ return { ...report, findings, stats };
220
+ }
221
+
222
+ function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
223
+ return { summary, findings: [], nextSteps, stats };
224
+ }
225
+
226
+ function dedupe(candidates: Hypothesis[], onDropped: (dropped: number) => void): Hypothesis[] {
227
+ const seen = new Set<string>();
228
+ const novel: Hypothesis[] = [];
229
+ for (const candidate of candidates) {
230
+ const key = dedupKey(candidate);
231
+ if (seen.has(key)) continue;
232
+ seen.add(key);
233
+ novel.push(candidate);
234
+ }
235
+ const dropped = candidates.length - novel.length;
236
+ if (dropped > 0) onDropped(dropped);
237
+ return novel;
238
+ }
239
+
240
+ function rank(finding: Verified): number {
241
+ if (finding.verdict === "CONFIRMED") return 0;
242
+ return 1;
243
+ }
244
+