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