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.
- package/.pi/extensions/pi-workflow-engine/index.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +64 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +114 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
- package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
- package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +198 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +24 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +89 -145
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +244 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +188 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +181 -0
- package/.pi/settings.json +3 -0
- package/README.md +163 -63
- package/assets/preview-candidate-core.png +0 -0
- package/assets/preview-candidate-relic-emblem.png +0 -0
- package/assets/preview-candidate-robot-squad.png +0 -0
- package/assets/preview.png +0 -0
- package/package.json +32 -6
- package/src/agent-runner.ts +0 -122
- package/src/concurrency.ts +0 -55
- package/src/discovery.ts +0 -54
- package/src/index.ts +0 -141
- package/src/ui/workflow-result-renderer.ts +0 -149
- package/src/ui/workflow-widget.ts +0 -143
- 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
|
|
32
|
+
conventions: Type.Optional(Type.String({ description: "Relevant AGENTS.md / project conventions" })),
|
|
17
33
|
});
|
|
18
34
|
|
|
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
|
-
});
|
|
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: "
|
|
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,
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
176
|
+
// ─── Find barrier → dedup → Verify ───
|
|
200
177
|
phase("Find");
|
|
201
|
-
const perAngle = await
|
|
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
|
|
212
|
-
"
|
|
213
|
-
|
|
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.
|
|
210
|
+
details: candidate.impact,
|
|
236
211
|
});
|
|
237
212
|
}
|
|
238
213
|
return { angle, candidates: bounded };
|
|
239
|
-
},
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
|
297
|
-
|
|
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.
|
|
304
|
-
|
|
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
|
|
310
|
-
|
|
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
|
+
|