pi-workflow-engine 0.12.0 → 0.13.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 (22) hide show
  1. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -1
  4. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +16 -2
  5. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +12 -6
  6. package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -0
  7. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +53 -3
  8. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -25
  9. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
  10. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
  11. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
  12. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
  13. package/.pi/extensions/pi-workflow-engine/src/types.ts +3 -0
  14. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -2
  15. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +131 -178
  16. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +7 -0
  17. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +55 -128
  18. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +45 -139
  19. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +25 -67
  20. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +25 -67
  21. package/USAGE.md +59 -0
  22. package/package.json +1 -1
@@ -1,26 +1,21 @@
1
+ import { challengeFindings, parseChallengeArgs } from "../src/advisory-challenge.ts";
1
2
  import { Type } from "typebox";
2
3
  import {
3
- AdvisoryCandidatesSchema,
4
- AdvisoryReportSchema,
5
- AdvisoryVerdictSchema,
6
- type AdvisoryCandidate,
7
- type AdvisoryVerdict,
8
- } from "../src/advisory-schema.ts";
9
- import {
10
- backfillAdvisoryFindings,
4
+ type AdvisoryVerified,
5
+ type AdvisoryLens,
6
+ synthesizeAdvisoryReport,
7
+ finishAdvisoryReport,
11
8
  formatEvidence,
12
9
  formatLocation,
13
10
  normalizePath,
14
- primaryLocation,
15
11
  publishVerifiedKeptProgress,
16
- recordVerdictProgress,
12
+ runLensVerificationPipeline,
17
13
  verdictConfidence,
18
14
  DEFAULT_ADVISORY_TOOL_HINTS,
19
15
  DEFAULT_ADVISORY_TOOLS,
20
16
  } from "../src/workflow-advisory-utils.ts";
21
- import { compactResults } from "../src/concurrency.ts";
22
17
  import { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/review-diff-target.ts";
23
- import { buildCodeReviewScopeBlock, dedupeCodeReviewCandidates } from "../src/review/code-review-orchestration.ts";
18
+ import { buildCodeReviewScopeBlock } from "../src/review/code-review-orchestration.ts";
24
19
  import type { ReviewContext } from "../src/review/review-report.ts";
25
20
  import { captureReviewMaterial, type ReviewMaterialCaptureResult } from "../src/review/review-snapshot.ts";
26
21
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
@@ -28,7 +23,7 @@ import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.t
28
23
  export const meta: WorkflowMeta = {
29
24
  name: "code-review",
30
25
  description: "Fan-out review of the branch's open PR (or branch vs main): scope → per-angle find → independent verify → synthesize.",
31
- phases: [{ title: "Scope" }, { title: "Find" }, { title: "Verify" }, { title: "Synthesize" }],
26
+ phases: [{ title: "Scope" }, { title: "Find" }, { title: "Verify" }, { title: "Challenge" }, { title: "Synthesize" }],
32
27
  };
33
28
 
34
29
  // ─── Schemas (the contracts that make orchestration plain code) ───
@@ -39,30 +34,15 @@ const ScopeSchema = Type.Object({
39
34
  conventions: Type.Optional(Type.String({ description: "Relevant AGENTS.md / project conventions" })),
40
35
  });
41
36
 
42
- type Candidate = AdvisoryCandidate;
43
-
44
- interface Angle {
45
- label: string;
46
- kind: "bug" | "cleanup";
47
- text: string;
48
- }
49
- interface Verified extends Candidate {
50
- verdict: AdvisoryVerdict["verdict"];
51
- evidence: string[];
52
- kind: "bug" | "cleanup";
53
- }
54
-
55
37
  // The review lenses — this is the part you customise to your codebase's real failure modes.
56
- const ANGLES: Angle[] = [
57
- { label: "logic-bugs", kind: "bug", text: "Off-by-one errors, wrong conditionals, incorrect return values, broken control flow." },
58
- { label: "error-paths", kind: "bug", text: "Unhandled errors, swallowed exceptions, missing awaits, partial failure leaving inconsistent state." },
59
- { label: "edge-cases", kind: "bug", text: "Empty/null inputs, boundary values, concurrency races, resource leaks." },
60
- { label: "simplification", kind: "cleanup", text: "Dead code, needless complexity, duplicated logic, clearer equivalents." },
61
- { label: "conventions", kind: "cleanup", text: "Violations of the project conventions noted in scope (naming, idioms, banned patterns)." },
38
+ const ANGLES: AdvisoryLens[] = [
39
+ { label: "logic-bugs", category: "bug", text: "Off-by-one errors, wrong conditionals, incorrect return values, broken control flow." },
40
+ { label: "error-paths", category: "bug", text: "Unhandled errors, swallowed exceptions, missing awaits, partial failure leaving inconsistent state." },
41
+ { label: "edge-cases", category: "bug", text: "Empty/null inputs, boundary values, concurrency races, resource leaks." },
42
+ { label: "simplification", category: "cleanup", text: "Dead code, needless complexity, duplicated logic, clearer equivalents." },
43
+ { label: "conventions", category: "cleanup", text: "Violations of the project conventions noted in scope (naming, idioms, banned patterns)." },
62
44
  ];
63
45
 
64
- const TOOLS = DEFAULT_ADVISORY_TOOLS;
65
- const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
66
46
  const PER_ANGLE = 6;
67
47
 
68
48
  /** Parse a unified diff into the set of added/changed new-file line numbers per file. */
@@ -106,8 +86,9 @@ export interface CodeReviewDependencies {
106
86
  }
107
87
 
108
88
  export default async function run(api: WorkflowApi, dependencies: CodeReviewDependencies = {}): Promise<unknown> {
109
- const { agent, parallel, phase, log, progress, args, cwd, signal } = api;
110
- const target = args.trim();
89
+ const { agent, phase, log, progress, args, cwd, signal } = api;
90
+ const challengeConfig = parseChallengeArgs(args);
91
+ const target = challengeConfig.args.trim();
111
92
  let fileCount = 0;
112
93
  let rawCandidateCount = 0;
113
94
  let droppedCandidateCount = 0;
@@ -138,7 +119,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
138
119
  "Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
139
120
  "and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
140
121
  "Return diffCommand exactly as a reviewer should run it. Structured output only.",
141
- { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
122
+ { phase: "Scope", label: "scope", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, profile: "medium", schema: ScopeSchema },
142
123
  );
143
124
 
144
125
  if (!scope) {
@@ -184,7 +165,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
184
165
  : {}),
185
166
  };
186
167
 
187
- const scopeBlock = buildCodeReviewScopeBlock({
168
+ const scopeBlock = `Reviewed snapshot identity: ${JSON.stringify(reviewContext.snapshot ?? "unavailable")}\n` + buildCodeReviewScopeBlock({
188
169
  diffCommand,
189
170
  files: scope.files,
190
171
  summary: scope.summary,
@@ -193,113 +174,59 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
193
174
  target,
194
175
  });
195
176
 
196
- // ─── Find barrier → dedup → Verify ───
197
- phase("Find");
198
- const perAngle = await parallel(
199
- ANGLES.map((angle) => async () => {
200
- const found = await agent(
201
- `## Code-review finder — ${angle.label}\n\n${scopeBlock}\n` +
202
- `Review the change through ONLY this lens:\n${angle.text}\n` +
203
- "Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
204
- "You may read surrounding files for context, but never report issues in unchanged code. " +
205
- `Surface up to ${PER_ANGLE} candidates. Use category exactly "${angle.kind}". Each candidate must include a one-line summary, ` +
206
- "locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
207
- "Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
208
- { phase: "Find", label: `find:${angle.label}`, tools: TOOLS, toolHints: TOOL_HINTS, profile: "small", schema: AdvisoryCandidatesSchema },
209
- );
210
- const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
211
- rawCandidateCount += raw.length;
212
- progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
213
- const bounded = raw.filter((candidate) => inDiff(changed, primaryLocation(candidate).file, primaryLocation(candidate).line));
214
- const dropped = raw.length - bounded.length;
215
- if (dropped > 0) {
216
- droppedCandidateCount += dropped;
217
- progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
218
- log(`find:${angle.label}: dropped ${dropped} out-of-diff candidate(s)`);
219
- }
220
- for (const candidate of bounded) {
221
- progress({
222
- type: "lane_item",
223
- lane: "Candidates",
224
- title: candidate.summary,
225
- subtitle: formatLocation(candidate),
226
- status: "pending",
227
- details: candidate.impact,
228
- });
229
- }
230
- return { angle, candidates: bounded };
231
- }),
232
- );
233
-
234
- // Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
235
- const novel = dedupeCodeReviewCandidates(compactResults(perAngle));
236
-
237
- phase("Verify");
238
- const verdicts = await parallel(
239
- novel.map(({ angle, candidate }) => async (): Promise<Verified | null> => {
240
- const location = primaryLocation(candidate);
241
- const judged = await agent(
242
- `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
243
- `Location: ${formatLocation(candidate)}\n` +
244
- `Category: ${candidate.category}\nSummary: ${candidate.summary}\nImpact: ${candidate.impact}\n\n` +
245
- "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / REFUTED) " +
246
- "with evidence quoting the line(s). Default toward REFUTED if you cannot substantiate it. Structured output only.",
247
- {
248
- phase: "Verify",
249
- label: `verify:${location.file.split("/").pop() ?? location.file}`,
250
- tools: TOOLS,
251
- toolHints: TOOL_HINTS,
252
- profile: "small",
253
- schema: AdvisoryVerdictSchema,
254
- },
255
- );
256
- if (!judged) return null;
257
- recordVerdictProgress(progress, candidate, judged);
258
- return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
259
- }),
260
- );
261
-
262
- const verified = compactResults(verdicts);
177
+ const pipelineResult = await runLensVerificationPipeline({
178
+ api,
179
+ lenses: ANGLES,
180
+ perLens: PER_ANGLE,
181
+ boundCandidate: (candidate, lens) => {
182
+ const anchor = candidate.reviewAnchor ?? candidate.locations.find((location) => inDiff(changed, location.file, location.line));
183
+ return anchor && inDiff(changed, anchor.file, anchor.line)
184
+ ? { ...candidate, category: lens.category, reviewAnchor: anchor } : undefined;
185
+ },
186
+ finderPrompt: (lens) =>
187
+ `## Code-review finder — ${lens.label}\n\n${scopeBlock}\n` +
188
+ `Review the change through ONLY this lens:\n${lens.text}\n` +
189
+ "Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
190
+ "Set reviewAnchor to the changed line causing the issue; locations and discoveryEvidence may include unchanged callers and other files. " +
191
+ `Surface up to ${PER_ANGLE} candidates. Use category exactly "${lens.category}". Each candidate must include a one-line summary, ` +
192
+ "locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
193
+ "Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
194
+ verifierPrompt: (candidate) =>
195
+ `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
196
+ `Location: ${formatLocation(candidate)}\n` +
197
+ `Category: ${candidate.category}\nSummary: ${candidate.summary}\nImpact: ${candidate.impact}\n\n` +
198
+ "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / NOT_SUBSTANTIATED / REFUTED) " +
199
+ "with evidence quoting the line(s). Use NOT_SUBSTANTIATED when evidence is insufficient; use REFUTED only for concrete disproof. Structured output only.",
200
+ });
201
+ rawCandidateCount += pipelineResult.rawCandidates;
202
+ droppedCandidateCount += pipelineResult.dropped;
203
+ const { coverage } = pipelineResult;
204
+ const verified = await challengeFindings(api, pipelineResult.verified, scopeBlock, challengeConfig.options, coverage);
263
205
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
264
206
  const stats = makeStats(verified.length, surviving.length);
265
207
  publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
266
208
 
267
209
  if (surviving.length === 0) {
268
- return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext };
210
+ return finishAdvisoryReport({ summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext }, coverage, verified);
269
211
  }
270
212
 
271
213
  // ─── Synthesize: rank, merge, report ───
272
- phase("Synthesize");
273
- const rank = (finding: Verified): number => (finding.kind === "cleanup" ? 2 : 0) + (finding.verdict === "PLAUSIBLE" ? 1 : 0);
214
+ const rank = (finding: AdvisoryVerified): number => (finding.category === "cleanup" ? 2 : 0) + (finding.verdict !== "CONFIRMED" ? 1 : 0);
274
215
  const ranked = [...surviving].sort((a, b) => rank(a) - rank(b));
275
216
  const block = ranked
276
217
  .map(
277
218
  (finding, index) =>
278
- `### [${index}] ${formatLocation(finding)} (${finding.verdict}${finding.kind === "cleanup" ? ", cleanup" : ""})\n` +
279
- `Category: ${finding.kind}\nConfidence: ${verdictConfidence(finding.verdict)}\n` +
219
+ `### [${index}] IDs: ${finding.sourceCandidateIds.join(", ")} ${formatLocation(finding)} (${finding.verdict}${finding.category === "cleanup" ? ", cleanup" : ""})\n` +
220
+ `Category: ${finding.category}\nConfidence: ${verdictConfidence(finding.verdict)}\n` +
280
221
  `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}`,
281
222
  )
282
223
  .join("\n\n");
283
224
 
284
- const report = await agent(
225
+ const resolved = await synthesizeAdvisoryReport(api,
285
226
  `## Synthesis: final code-review report\n\n${ranked.length} findings survived independent verification.\n\n${block}\n\n` +
286
227
  "Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final advisory report. " +
287
- "Return summary, findings, and nextSteps. For each finding: category must be bug or cleanup; severity is impact level (low/medium/high), not category; " +
288
- "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.",
289
- {
290
- phase: "Synthesize",
291
- label: "synthesize",
292
- tools: [],
293
- profile: "medium",
294
- resume: "read-only",
295
- schema: AdvisoryReportSchema,
296
- },
228
+ "Return summary, ID selections with severity (low/medium/high) and advisory recommendation, and nextSteps. Evidence and confidence are reconstructed from verified records. Structured output only.",
229
+ ranked, coverage,
297
230
  );
298
-
299
- if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats, reviewContext };
300
-
301
- const findings = backfillAdvisoryFindings(report.findings, ranked, {
302
- impact: "Impact not restated by synthesis.",
303
- });
304
- return { ...report, findings, stats, reviewContext };
231
+ return finishAdvisoryReport({ ...resolved, stats: { ...stats, kept: resolved.findings.length }, reviewContext }, coverage, verified);
305
232
  }
@@ -1,30 +1,24 @@
1
+ import { challengeFindings, parseChallengeArgs } from "../src/advisory-challenge.ts";
1
2
  import { Type } from "typebox";
2
3
  import {
3
- AdvisoryCandidatesSchema,
4
- AdvisoryReportSchema,
5
- AdvisoryVerdictSchema,
6
- type AdvisoryCandidate,
7
- type AdvisoryVerdict,
8
- } from "../src/advisory-schema.ts";
9
- import {
10
- advisoryDedupKey as dedupKey,
11
- backfillAdvisoryFindings,
4
+ type AdvisoryVerified,
5
+ type AdvisoryLens,
6
+ synthesizeAdvisoryReport,
7
+ finishAdvisoryReport,
12
8
  emptyAdvisoryReport,
13
9
  formatEvidence,
14
10
  formatLocation,
15
- primaryLocation,
16
11
  publishVerifiedKeptProgress,
17
- recordVerdictProgress,
12
+ runLensVerificationPipeline,
18
13
  DEFAULT_ADVISORY_TOOL_HINTS,
19
14
  DEFAULT_ADVISORY_TOOLS,
20
15
  } from "../src/workflow-advisory-utils.ts";
21
- import { compactResults } from "../src/concurrency.ts";
22
16
  import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
23
17
 
24
18
  export const meta: WorkflowMeta = {
25
19
  name: "diagnose",
26
20
  description: "Advisory-only bug diagnosis: scope symptoms → competing hypotheses → independent verify → synthesize likely root causes.",
27
- phases: [{ title: "Scope" }, { title: "Hypothesize" }, { title: "Verify" }, { title: "Synthesize" }],
21
+ phases: [{ title: "Scope" }, { title: "Hypothesize" }, { title: "Verify" }, { title: "Challenge" }, { title: "Synthesize" }],
28
22
  };
29
23
 
30
24
  const ScopeSchema = Type.Object({
@@ -35,25 +29,7 @@ const ScopeSchema = Type.Object({
35
29
  constraints: Type.Optional(Type.String({ description: "Safety constraints, missing evidence, or commands intentionally not run." })),
36
30
  });
37
31
 
38
- interface HypothesisLens {
39
- label: string;
40
- category: string;
41
- text: string;
42
- }
43
-
44
- type Candidate = AdvisoryCandidate;
45
-
46
- interface Hypothesis extends Candidate {
47
- lens: HypothesisLens;
48
- }
49
-
50
- interface Verified extends Hypothesis {
51
- verdict: AdvisoryVerdict["verdict"];
52
- evidence: string[];
53
- confidence?: AdvisoryVerdict["confidence"];
54
- }
55
-
56
- const HYPOTHESIS_LENSES: HypothesisLens[] = [
32
+ const HYPOTHESIS_LENSES: AdvisoryLens[] = [
57
33
  { label: "recent-change", category: "regression", text: "A recent code change broke a previously working path or changed an implicit contract." },
58
34
  { label: "control-flow", category: "root-cause", text: "Incorrect branching, ordering, async flow, data flow, or state transition causes the symptom." },
59
35
  { label: "configuration", category: "configuration", text: "Configuration, environment, package scripts, or runtime assumptions differ from what the code expects." },
@@ -61,13 +37,12 @@ const HYPOTHESIS_LENSES: HypothesisLens[] = [
61
37
  { 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." },
62
38
  ];
63
39
 
64
- const TOOLS = DEFAULT_ADVISORY_TOOLS;
65
- const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
66
40
  const PER_LENS = 4;
67
41
 
68
42
  export default async function run(api: WorkflowApi): Promise<unknown> {
69
- const { agent, parallel, phase, log, progress, args } = api;
70
- const symptom = args.trim();
43
+ const { agent, phase, log, progress, args } = api;
44
+ const challengeConfig = parseChallengeArgs(args);
45
+ const symptom = challengeConfig.args.trim();
71
46
  let fileCount = 0;
72
47
  let rawCandidateCount = 0;
73
48
  let droppedCandidateCount = 0;
@@ -90,15 +65,15 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
90
65
  "Inspect relevant files, package/test configuration, and safe diagnostic commands. " +
91
66
  "Safe commands are read-only commands such as status, grep, listing files, typecheck/test commands, or commands explicitly requested by the user. " +
92
67
  "Do not run mutation, install, commit, network, or destructive commands. Return scoped files, observations, and constraints. Structured output only.",
93
- { phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
68
+ { phase: "Scope", label: "scope", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, profile: "medium", schema: ScopeSchema },
94
69
  );
95
70
 
96
71
  if (!scope) {
97
- return emptyAdvisoryReport(
72
+ return finishAdvisoryReport(emptyAdvisoryReport(
98
73
  "Diagnosis could not establish a scope.",
99
74
  ["Provide the failing command, error message, or regression description and rerun diagnose."],
100
75
  makeStats(0, 0),
101
- );
76
+ ), []);
102
77
  }
103
78
 
104
79
  fileCount = scope.files.length;
@@ -113,88 +88,48 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
113
88
  `## Observations\n${scope.observations.map((observation) => `- ${observation}`).join("\n") || "(none)"}\n\n` +
114
89
  `## Constraints\n${scope.constraints ?? "(none noted)"}\n`;
115
90
 
116
- phase("Hypothesize");
117
- const perLens = await parallel(
118
- HYPOTHESIS_LENSES.map((lens) => async (): Promise<Hypothesis[]> => {
119
- const found = await agent(
120
- `## Diagnose hypothesis generator — ${lens.label}\n\n${scopeBlock}\n` +
121
- "This workflow is advisory-only: diagnose and recommend validation/fix plans, but do not edit files.\n" +
122
- `Consider ONLY this hypothesis lens:\n${lens.text}\n\n` +
123
- `Surface up to ${PER_LENS} root-cause hypotheses. Use category exactly "${lens.category}". ` +
124
- "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.",
125
- { phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, toolHints: TOOL_HINTS, profile: "small", schema: AdvisoryCandidatesSchema },
126
- );
127
- const candidates = (found?.candidates ?? []).slice(0, PER_LENS).map((candidate) => ({ ...candidate, lens }));
128
- rawCandidateCount += candidates.length;
129
- progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: candidates.length });
130
- for (const candidate of candidates) {
131
- progress({
132
- type: "lane_item",
133
- lane: "Hypotheses",
134
- title: candidate.summary,
135
- subtitle: formatLocation(candidate),
136
- status: "pending",
137
- details: candidate.impact,
138
- });
139
- }
140
- return candidates;
141
- }),
142
- );
143
-
144
- const hypotheses = dedupe(compactResults(perLens).flat(), (dropped) => {
145
- droppedCandidateCount += dropped;
146
- progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
91
+ const pipelineResult = await runLensVerificationPipeline({
92
+ api,
93
+ lenses: HYPOTHESIS_LENSES,
94
+ perLens: PER_LENS,
95
+ finderPhase: "Hypothesize",
96
+ finderPrompt: (lens) =>
97
+ `## Diagnose hypothesis generator — ${lens.label}\n\n${scopeBlock}\n` +
98
+ "This workflow is advisory-only: diagnose and recommend validation/fix plans, but do not edit files.\n" +
99
+ `Consider ONLY this hypothesis lens:\n${lens.text}\n\n` +
100
+ `Surface up to ${PER_LENS} root-cause hypotheses. Use category exactly "${lens.category}". ` +
101
+ "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.",
102
+ verifierPrompt: (candidate) =>
103
+ `## Diagnose verifier\n\n${scopeBlock}\n## Hypothesis\n` +
104
+ `Location: ${formatLocation(candidate)}\nCategory: ${candidate.category}\nSummary: ${candidate.summary}\nImpact: ${candidate.impact}\n` +
105
+ `Recommended validation: ${candidate.recommendation ?? "(none supplied)"}\n\n` +
106
+ "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. " +
107
+ "Do not run mutation, install, commit, network, or destructive commands. Return CONFIRMED, PLAUSIBLE, NOT_SUBSTANTIATED, or REFUTED with evidence. " +
108
+ "Use NOT_SUBSTANTIATED when evidence is missing; REFUTED requires disproof. Structured output only.",
147
109
  });
148
-
149
- phase("Verify");
150
- const verified = compactResults(
151
- await parallel(
152
- hypotheses.map((hypothesis) => async (): Promise<Verified | null> => {
153
- const location = primaryLocation(hypothesis);
154
- const judged = await agent(
155
- `## Diagnose verifier\n\n${scopeBlock}\n## Hypothesis\n` +
156
- `Location: ${formatLocation(hypothesis)}\nCategory: ${hypothesis.category}\nSummary: ${hypothesis.summary}\nImpact: ${hypothesis.impact}\n` +
157
- `Recommended validation: ${hypothesis.recommendation ?? "(none supplied)"}\n\n` +
158
- "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. " +
159
- "Do not run mutation, install, commit, network, or destructive commands. Return CONFIRMED, PLAUSIBLE, or REFUTED with evidence. " +
160
- "Default toward REFUTED if evidence does not connect the hypothesis to the symptom. Structured output only.",
161
- {
162
- phase: "Verify",
163
- label: `verify:${location.file.split("/").pop() ?? location.file}`,
164
- tools: TOOLS,
165
- toolHints: TOOL_HINTS,
166
- profile: "small",
167
- schema: AdvisoryVerdictSchema,
168
- },
169
- );
170
- if (!judged) return null;
171
- recordVerdictProgress(progress, hypothesis, judged, () => {
172
- refutedCandidateCount += 1;
173
- });
174
- return { ...hypothesis, verdict: judged.verdict, evidence: judged.evidence, confidence: judged.confidence };
175
- }),
176
- ),
177
- );
178
-
110
+ rawCandidateCount += pipelineResult.rawCandidates;
111
+ droppedCandidateCount += pipelineResult.dropped;
112
+ refutedCandidateCount += pipelineResult.refuted;
113
+ const { coverage } = pipelineResult;
114
+ const verified = await challengeFindings(api, pipelineResult.verified, scopeBlock, challengeConfig.options, coverage);
179
115
  const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
180
116
  const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
181
117
  const stats = makeStats(verified.length, surviving.length);
182
118
  publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
183
119
 
184
120
  if (surviving.length === 0) {
185
- return emptyAdvisoryReport(
121
+ return finishAdvisoryReport(emptyAdvisoryReport(
186
122
  "No root-cause hypothesis survived verification.",
187
123
  ["Capture the exact failing command and error output.", "Rerun diagnose with a narrower symptom or more evidence."],
188
124
  stats,
189
- );
125
+ ), coverage, verified);
190
126
  }
191
127
 
192
- phase("Synthesize");
193
128
  const ranked = [...surviving].sort((a, b) => rank(a) - rank(b));
194
129
  const block = ranked
195
130
  .map(
196
131
  (finding, index) =>
197
- `### [${index}] ${formatLocation(finding)} (${finding.verdict}, ${finding.category})\n` +
132
+ `### [${index}] IDs: ${finding.sourceCandidateIds.join(", ")} ${formatLocation(finding)} (${finding.verdict}, ${finding.category})\n` +
198
133
  `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}\nValidation/fix plan: ${finding.recommendation ?? "(none supplied)"}`,
199
134
  )
200
135
  .join("\n\n");
@@ -203,46 +138,17 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
203
138
  .map((finding) => `- ${finding.summary} — REFUTED because ${formatEvidence(finding.evidence)}`)
204
139
  .join("\n");
205
140
 
206
- const report = await agent(
141
+ const resolved = await synthesizeAdvisoryReport(api,
207
142
  `## Synthesis: final diagnosis report\n\n${ranked.length} hypotheses survived independent verification.\n\n${block}\n\n` +
208
143
  `## Refuted hypotheses for context\n${refutedBlock || "(none recorded)"}\n\n` +
209
- "Produce the shared advisory report shape. Only include confirmed or plausible root causes in findings. " +
210
- "Use categories such as root-cause, regression, configuration, dependency, or test-fixture. " +
144
+ "Select confirmed, plausible or explicitly unresolved root causes by ID. " +
211
145
  "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.",
212
- {
213
- phase: "Synthesize",
214
- label: "synthesize",
215
- tools: [],
216
- profile: "medium",
217
- resume: "read-only",
218
- schema: AdvisoryReportSchema,
219
- },
146
+ ranked, coverage,
220
147
  );
221
-
222
- if (!report) return emptyAdvisoryReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun diagnose with a narrower symptom."], stats);
223
-
224
- const findings = backfillAdvisoryFindings(report.findings, ranked, {
225
- impact: "Impact not restated by synthesis.",
226
- recommendation: "Validate this diagnosis with the smallest safe reproduction command.",
227
- });
228
- return { ...report, findings, stats };
229
- }
230
-
231
- function dedupe(candidates: Hypothesis[], onDropped: (dropped: number) => void): Hypothesis[] {
232
- const seen = new Set<string>();
233
- const novel: Hypothesis[] = [];
234
- for (const candidate of candidates) {
235
- const key = dedupKey(candidate);
236
- if (seen.has(key)) continue;
237
- seen.add(key);
238
- novel.push(candidate);
239
- }
240
- const dropped = candidates.length - novel.length;
241
- if (dropped > 0) onDropped(dropped);
242
- return novel;
148
+ return finishAdvisoryReport({ ...resolved, stats: { ...stats, kept: resolved.findings.length } }, coverage, verified);
243
149
  }
244
150
 
245
- function rank(finding: Verified): number {
151
+ function rank(finding: AdvisoryVerified): number {
246
152
  if (finding.verdict === "CONFIRMED") return 0;
247
153
  return 1;
248
154
  }