pi-workflow-engine 0.3.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/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/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-result-renderer.ts +11 -8
- 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/{src → .pi/extensions/pi-workflow-engine/src}/workflows.ts +7 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +57 -125
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/diagnose.ts +12 -73
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/perf-review.ts +41 -148
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/refactor-scout.ts +43 -151
- package/.pi/settings.json +3 -0
- package/README.md +70 -23
- 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-widget.ts +0 -143
- /package/{src → .pi/extensions/pi-workflow-engine/src}/advisory-schema.ts +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
|
|
2
|
+
import type { AgentOptions, WorkflowApi, WorkflowProgressEvent } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface AdvisoryLens {
|
|
5
|
+
label: string;
|
|
6
|
+
category: string;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type AdvisoryVerified<Candidate extends AdvisoryCandidate = AdvisoryCandidate> = Candidate & {
|
|
11
|
+
verdict: AdvisoryVerdict["verdict"];
|
|
12
|
+
evidence: string[];
|
|
13
|
+
confidence?: AdvisoryVerdict["confidence"];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface LensVerificationPipelineResult<Verified> {
|
|
17
|
+
verified: Verified[];
|
|
18
|
+
rawCandidates: number;
|
|
19
|
+
dropped: number;
|
|
20
|
+
refuted: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_ADVISORY_TOOLS = ["read", "bash"];
|
|
24
|
+
|
|
25
|
+
export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
|
|
26
|
+
|
|
27
|
+
export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Verified extends AdvisoryVerified> {
|
|
28
|
+
api: Pick<WorkflowApi, "agent" | "parallel" | "pipeline" | "progress" | "log">;
|
|
29
|
+
lenses: readonly Lens[];
|
|
30
|
+
perLens: number;
|
|
31
|
+
tools?: AgentOptions["tools"];
|
|
32
|
+
finderPhase?: string;
|
|
33
|
+
verifierPhase?: string;
|
|
34
|
+
schedulingMode?: AdvisorySchedulingMode;
|
|
35
|
+
finderPrompt(lens: Lens): string;
|
|
36
|
+
verifierPrompt(candidate: AdvisoryCandidate): string;
|
|
37
|
+
makeVerified(candidate: AdvisoryCandidate, lens: Lens, verdict: AdvisoryVerdict): Verified;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AdvisoryBackfillDefaults {
|
|
41
|
+
impact: string;
|
|
42
|
+
recommendation?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface FoundForLens<Lens> {
|
|
46
|
+
lens: Lens;
|
|
47
|
+
candidates: AdvisoryCandidate[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface NovelCandidate<Lens> {
|
|
51
|
+
lens: Lens;
|
|
52
|
+
candidate: AdvisoryCandidate;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Verified extends AdvisoryVerified>(
|
|
56
|
+
options: LensVerificationPipelineOptions<Lens, Verified>,
|
|
57
|
+
): Promise<LensVerificationPipelineResult<Verified>> {
|
|
58
|
+
const {
|
|
59
|
+
api,
|
|
60
|
+
lenses,
|
|
61
|
+
perLens,
|
|
62
|
+
tools = DEFAULT_ADVISORY_TOOLS,
|
|
63
|
+
finderPhase = "Find",
|
|
64
|
+
verifierPhase = "Verify",
|
|
65
|
+
schedulingMode = "pipeline",
|
|
66
|
+
finderPrompt,
|
|
67
|
+
verifierPrompt,
|
|
68
|
+
makeVerified,
|
|
69
|
+
} = options;
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
let rawCandidates = 0;
|
|
72
|
+
let dropped = 0;
|
|
73
|
+
let refuted = 0;
|
|
74
|
+
|
|
75
|
+
const findForLens = async (lens: Lens): Promise<FoundForLens<Lens>> => {
|
|
76
|
+
const found = await api.agent(finderPrompt(lens), {
|
|
77
|
+
phase: finderPhase,
|
|
78
|
+
label: `find:${lens.label}`,
|
|
79
|
+
tools,
|
|
80
|
+
thinkingLevel: "low",
|
|
81
|
+
schema: AdvisoryCandidatesSchema,
|
|
82
|
+
});
|
|
83
|
+
const candidates = (found?.candidates ?? []).slice(0, perLens);
|
|
84
|
+
rawCandidates += candidates.length;
|
|
85
|
+
api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: candidates.length });
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
api.progress({
|
|
88
|
+
type: "lane_item",
|
|
89
|
+
lane: "Candidates",
|
|
90
|
+
title: candidate.summary,
|
|
91
|
+
subtitle: formatLocation(candidate),
|
|
92
|
+
status: "pending",
|
|
93
|
+
details: candidate.impact,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { lens, candidates };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const dedupeFound = (found: FoundForLens<Lens>): NovelCandidate<Lens>[] => {
|
|
100
|
+
const novel = candidatesNovelToRun(found.candidates, seen).map((candidate) => ({ lens: found.lens, candidate }));
|
|
101
|
+
const droppedForLens = found.candidates.length - novel.length;
|
|
102
|
+
if (droppedForLens > 0) {
|
|
103
|
+
dropped += droppedForLens;
|
|
104
|
+
api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: droppedForLens });
|
|
105
|
+
api.log(`find:${found.lens.label}: dropped ${droppedForLens} duplicate candidate(s)`);
|
|
106
|
+
}
|
|
107
|
+
return novel;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const verifyCandidate = async ({ lens, candidate }: NovelCandidate<Lens>): Promise<Verified | null> => {
|
|
111
|
+
const location = primaryLocation(candidate);
|
|
112
|
+
const judged = await api.agent(verifierPrompt(candidate), {
|
|
113
|
+
phase: verifierPhase,
|
|
114
|
+
label: `verify:${location.file.split("/").pop() ?? location.file}`,
|
|
115
|
+
tools,
|
|
116
|
+
thinkingLevel: "low",
|
|
117
|
+
schema: AdvisoryVerdictSchema,
|
|
118
|
+
});
|
|
119
|
+
if (!judged) return null;
|
|
120
|
+
recordVerdictProgress(api.progress, candidate, judged, () => {
|
|
121
|
+
refuted += 1;
|
|
122
|
+
});
|
|
123
|
+
return makeVerified(candidate, lens, judged);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (schedulingMode === "finder-barrier") {
|
|
127
|
+
const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
|
|
128
|
+
const novel = found.flatMap(dedupeFound);
|
|
129
|
+
const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
|
|
130
|
+
return { verified: verdicts.filter((value): value is Verified => value !== null), rawCandidates, dropped, refuted };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const perLensVerified = await api.pipeline(
|
|
134
|
+
lenses,
|
|
135
|
+
async (_prev, lens) => findForLens(lens),
|
|
136
|
+
async (found) => {
|
|
137
|
+
const novel = dedupeFound(found);
|
|
138
|
+
const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
|
|
139
|
+
return verdicts.filter((value): value is Verified => value !== null);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return { verified: perLensVerified.flat(), rawCandidates, dropped, refuted };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
|
|
147
|
+
return candidates.filter((candidate) => {
|
|
148
|
+
const key = advisoryDedupKey(candidate);
|
|
149
|
+
if (seen.has(key)) return false;
|
|
150
|
+
seen.add(key);
|
|
151
|
+
return true;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">): AdvisoryLocation {
|
|
156
|
+
return candidate.locations[0] ?? { file: "" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function formatLocation(candidate: Pick<AdvisoryCandidate, "locations">): string {
|
|
160
|
+
const location = primaryLocation(candidate);
|
|
161
|
+
const line = location.line != null ? `:${location.line}` : "";
|
|
162
|
+
const symbol = location.symbol ? ` (${location.symbol})` : "";
|
|
163
|
+
return `${location.file}${line}${symbol}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function formatEvidence(evidence: readonly string[]): string {
|
|
167
|
+
return evidence.join("; ");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function normalizePath(path: string): string {
|
|
171
|
+
return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function advisoryDedupKey(candidate: AdvisoryCandidate): string {
|
|
175
|
+
const location = primaryLocation(candidate);
|
|
176
|
+
const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : "file";
|
|
177
|
+
return `${candidate.category}:${normalizePath(location.file)}:${lineKey}:${candidate.summary.slice(0, 60).toLowerCase()}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function verdictLane(verdict: AdvisoryVerdict["verdict"]): string {
|
|
181
|
+
switch (verdict) {
|
|
182
|
+
case "CONFIRMED":
|
|
183
|
+
return "Confirmed";
|
|
184
|
+
case "PLAUSIBLE":
|
|
185
|
+
return "Plausible";
|
|
186
|
+
case "REFUTED":
|
|
187
|
+
return "Refuted";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function verdictStatus(verdict: AdvisoryVerdict["verdict"]): "success" | "warning" | "error" {
|
|
192
|
+
switch (verdict) {
|
|
193
|
+
case "CONFIRMED":
|
|
194
|
+
return "success";
|
|
195
|
+
case "PLAUSIBLE":
|
|
196
|
+
return "warning";
|
|
197
|
+
case "REFUTED":
|
|
198
|
+
return "error";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function verdictConfidence(verdict: AdvisoryVerdict["verdict"]): "high" | "medium" | "low" {
|
|
203
|
+
switch (verdict) {
|
|
204
|
+
case "CONFIRMED":
|
|
205
|
+
return "high";
|
|
206
|
+
case "PLAUSIBLE":
|
|
207
|
+
return "medium";
|
|
208
|
+
case "REFUTED":
|
|
209
|
+
return "low";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function sameFinding(candidate: Pick<AdvisoryCandidate, "locations">, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
|
|
214
|
+
return findingLocationKey(candidate) === findingLocationKey(finding);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function findingLocationKey(value: Pick<AdvisoryCandidate, "locations"> | Pick<AdvisoryFinding, "locations">): string {
|
|
218
|
+
const location = primaryLocation(value);
|
|
219
|
+
return `${normalizePath(location.file)}:${location.line ?? "file"}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function recordVerdictProgress(
|
|
223
|
+
progress: (event: WorkflowProgressEvent) => void,
|
|
224
|
+
candidate: Pick<AdvisoryCandidate, "locations" | "summary">,
|
|
225
|
+
verdict: Pick<AdvisoryVerdict, "verdict" | "evidence">,
|
|
226
|
+
onRefuted?: () => void,
|
|
227
|
+
): void {
|
|
228
|
+
progress({ type: "counter_delta", key: `verdict.${verdict.verdict.toLowerCase()}`, label: verdict.verdict, delta: 1 });
|
|
229
|
+
if (verdict.verdict === "REFUTED") {
|
|
230
|
+
onRefuted?.();
|
|
231
|
+
progress({ type: "counter_delta", key: "refuted", label: "refuted", delta: 1 });
|
|
232
|
+
}
|
|
233
|
+
progress({
|
|
234
|
+
type: "lane_item",
|
|
235
|
+
lane: verdictLane(verdict.verdict),
|
|
236
|
+
title: candidate.summary,
|
|
237
|
+
subtitle: formatLocation(candidate),
|
|
238
|
+
status: verdictStatus(verdict.verdict),
|
|
239
|
+
details: formatEvidence(verdict.evidence),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function backfillAdvisoryFindings<Source extends AdvisoryVerified>(
|
|
244
|
+
findings: AdvisoryReport["findings"],
|
|
245
|
+
ranked: readonly Source[],
|
|
246
|
+
defaults: AdvisoryBackfillDefaults,
|
|
247
|
+
): AdvisoryReport["findings"] {
|
|
248
|
+
const rankedByLocation = new Map<string, Source>();
|
|
249
|
+
for (const candidate of ranked) {
|
|
250
|
+
const key = findingLocationKey(candidate);
|
|
251
|
+
if (!rankedByLocation.has(key)) rankedByLocation.set(key, candidate);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return findings.map((finding) => {
|
|
255
|
+
const source = rankedByLocation.get(findingLocationKey(finding));
|
|
256
|
+
return {
|
|
257
|
+
...finding,
|
|
258
|
+
evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
|
|
259
|
+
impact: finding.impact || source?.impact || defaults.impact,
|
|
260
|
+
recommendation: finding.recommendation || source?.recommendation || defaults.recommendation || "",
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
@@ -13,5 +13,12 @@ import * as perfReview from "../workflows/perf-review.ts";
|
|
|
13
13
|
* To add a guaranteed workflow: drop a `.ts` in `workflows/`, import it here, and add
|
|
14
14
|
* it to this array. (Drop-in workflows discovered at runtime are also supported — see
|
|
15
15
|
* discovery.ts — but the static registry is the always-correct path.)
|
|
16
|
+
*
|
|
17
|
+
* Do not replace these built-in imports with dynamic imports unless a dedicated test
|
|
18
|
+
* proves pi's jiti virtual modules preserve the same bundled `typebox` identity for
|
|
19
|
+
* dynamically imported built-ins. Startup optimizations should happen at the extension
|
|
20
|
+
* entrypoint/discovery boundary first.
|
|
16
21
|
*/
|
|
17
22
|
export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, ping, refactorScout, diagnose, perfReview];
|
|
23
|
+
export const BUILTIN_WORKFLOW_FILES = new Set(["code-review.ts", "ping.ts", "refactor-scout.ts", "diagnose.ts", "perf-review.ts"]);
|
|
24
|
+
export const BUILTIN_WORKFLOW_NAMES = BUILTIN_WORKFLOWS.map((mod) => mod.meta.name);
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
1
|
import { Type } from "typebox";
|
|
3
2
|
import {
|
|
4
3
|
AdvisoryCandidatesSchema,
|
|
5
4
|
AdvisoryReportSchema,
|
|
6
5
|
AdvisoryVerdictSchema,
|
|
7
6
|
type AdvisoryCandidate,
|
|
8
|
-
type AdvisoryFinding,
|
|
9
|
-
type AdvisoryLocation,
|
|
10
7
|
type AdvisoryVerdict,
|
|
11
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";
|
|
12
19
|
import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
|
|
13
20
|
|
|
14
21
|
export const meta: WorkflowMeta = {
|
|
@@ -22,7 +29,7 @@ const ScopeSchema = Type.Object({
|
|
|
22
29
|
diffCommand: Type.String({ description: "Exact git command that produces the review diff" }),
|
|
23
30
|
files: Type.Array(Type.String(), { description: "Changed file paths" }),
|
|
24
31
|
summary: Type.String({ description: "One-paragraph summary of the change" }),
|
|
25
|
-
conventions: Type.Optional(Type.String({ description: "Relevant
|
|
32
|
+
conventions: Type.Optional(Type.String({ description: "Relevant AGENTS.md / project conventions" })),
|
|
26
33
|
});
|
|
27
34
|
|
|
28
35
|
type Candidate = AdvisoryCandidate;
|
|
@@ -51,11 +58,6 @@ const TOOLS = ["read", "bash"];
|
|
|
51
58
|
const PER_ANGLE = 6;
|
|
52
59
|
const DIFF_EMBED_CAP = 60_000;
|
|
53
60
|
|
|
54
|
-
/** Strip leading "./", "a/", "b/" so diff paths and finder-reported paths compare equal. */
|
|
55
|
-
export function normalizePath(path: string): string {
|
|
56
|
-
return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
61
|
/** Parse a unified diff into the set of added/changed new-file line numbers per file. */
|
|
60
62
|
export function changedLines(diff: string): Map<string, Set<number>> {
|
|
61
63
|
const byFile = new Map<string, Set<number>>();
|
|
@@ -89,7 +91,7 @@ export function inDiff(changed: Map<string, Set<number>>, file: string, line?: n
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
92
|
-
const { agent, parallel,
|
|
94
|
+
const { agent, parallel, phase, log, progress, args, cwd, signal } = api;
|
|
93
95
|
const target = args.trim();
|
|
94
96
|
let fileCount = 0;
|
|
95
97
|
let rawCandidateCount = 0;
|
|
@@ -118,7 +120,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
118
120
|
"If the branch itself is main/master, use `git diff HEAD~1`.\n" +
|
|
119
121
|
"4. Run the chosen command to confirm the diff is non-empty.\n\n" +
|
|
120
122
|
"Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
|
|
121
|
-
"and read any relevant
|
|
123
|
+
"and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
|
|
122
124
|
"Return diffCommand exactly as a reviewer should run it. Structured output only.",
|
|
123
125
|
{ phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
124
126
|
);
|
|
@@ -140,15 +142,13 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
140
142
|
// Capture the diff once, deterministically, so findings can be bounded to changed lines in code.
|
|
141
143
|
let changed: Map<string, Set<number>> | null = null;
|
|
142
144
|
let diffText = "";
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
log(`diff capture failed (${error instanceof Error ? error.message : String(error)}) — reviewing without the line gate`);
|
|
149
|
-
}
|
|
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 });
|
|
150
150
|
} else {
|
|
151
|
-
log(
|
|
151
|
+
log(`diff capture failed (${capturedDiff.error ?? "unknown error"}) — reviewing without the line gate`);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
const diffBlock = diffText
|
|
@@ -165,7 +165,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
165
165
|
diffBlock +
|
|
166
166
|
(target ? `\n## User instructions (verbatim)\n${target}\n` : "");
|
|
167
167
|
|
|
168
|
-
// Dedup
|
|
168
|
+
// Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
|
|
169
169
|
const seen = new Set<string>();
|
|
170
170
|
const dedupKey = (candidate: Candidate): string => {
|
|
171
171
|
const location = primaryLocation(candidate);
|
|
@@ -173,13 +173,10 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
173
173
|
return `${normalizePath(location.file)}:${lineKey}`;
|
|
174
174
|
};
|
|
175
175
|
|
|
176
|
-
// ─── Find → dedup → Verify
|
|
176
|
+
// ─── Find barrier → dedup → Verify ───
|
|
177
177
|
phase("Find");
|
|
178
|
-
const perAngle = await
|
|
179
|
-
ANGLES
|
|
180
|
-
// Stage 1: each angle finds candidates through its single lens.
|
|
181
|
-
async (_prev, item) => {
|
|
182
|
-
const angle = item as Angle;
|
|
178
|
+
const perAngle = await parallel(
|
|
179
|
+
ANGLES.map((angle) => async () => {
|
|
183
180
|
const found = await agent(
|
|
184
181
|
`## Code-review finder — ${angle.label}\n\n${scopeBlock}\n` +
|
|
185
182
|
`Review the change through ONLY this lens:\n${angle.text}\n` +
|
|
@@ -214,51 +211,45 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
214
211
|
});
|
|
215
212
|
}
|
|
216
213
|
return { angle, candidates: bounded };
|
|
217
|
-
},
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const novel = perAngle.flatMap(({ angle, candidates }) =>
|
|
218
|
+
candidates
|
|
219
|
+
.filter((candidate) => {
|
|
222
220
|
const key = dedupKey(candidate);
|
|
223
221
|
if (seen.has(key)) return false;
|
|
224
222
|
seen.add(key);
|
|
225
223
|
return true;
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
type: "lane_item",
|
|
248
|
-
lane: verdictLane(judged.verdict),
|
|
249
|
-
title: candidate.summary,
|
|
250
|
-
subtitle: formatLocation(candidate),
|
|
251
|
-
status: verdictStatus(judged.verdict),
|
|
252
|
-
details: formatEvidence(judged.evidence),
|
|
253
|
-
});
|
|
254
|
-
return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
|
|
255
|
-
}),
|
|
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
|
+
},
|
|
256
245
|
);
|
|
257
|
-
|
|
258
|
-
|
|
246
|
+
if (!judged) return null;
|
|
247
|
+
recordVerdictProgress(progress, candidate, judged);
|
|
248
|
+
return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
|
|
249
|
+
}),
|
|
259
250
|
);
|
|
260
251
|
|
|
261
|
-
const verified = (
|
|
252
|
+
const verified = verdicts.filter((value): value is Verified => value !== null);
|
|
262
253
|
const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
|
|
263
254
|
const stats = makeStats(verified.length, surviving.length);
|
|
264
255
|
progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
|
|
@@ -294,67 +285,8 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
294
285
|
|
|
295
286
|
if (!report) return { summary: "Synthesis produced no output.", findings: [], nextSteps: ["Re-run the workflow or inspect verifier evidence manually."], stats };
|
|
296
287
|
|
|
297
|
-
const findings = report.findings
|
|
298
|
-
|
|
299
|
-
return {
|
|
300
|
-
...finding,
|
|
301
|
-
evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
|
|
302
|
-
impact: finding.impact || source?.impact || "Impact not restated by synthesis.",
|
|
303
|
-
};
|
|
288
|
+
const findings = backfillAdvisoryFindings(report.findings, ranked, {
|
|
289
|
+
impact: "Impact not restated by synthesis.",
|
|
304
290
|
});
|
|
305
291
|
return { ...report, findings, stats };
|
|
306
292
|
}
|
|
307
|
-
|
|
308
|
-
function primaryLocation(candidate: Pick<Candidate, "locations">): AdvisoryLocation {
|
|
309
|
-
return candidate.locations[0] ?? { file: "" };
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function formatLocation(candidate: Pick<Candidate, "locations">): string {
|
|
313
|
-
const location = primaryLocation(candidate);
|
|
314
|
-
const line = location.line != null ? `:${location.line}` : "";
|
|
315
|
-
const symbol = location.symbol ? ` (${location.symbol})` : "";
|
|
316
|
-
return `${location.file}${line}${symbol}`;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function formatEvidence(evidence: string[]): string {
|
|
320
|
-
return evidence.join("; ");
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function verdictLane(verdict: Verified["verdict"]): string {
|
|
324
|
-
switch (verdict) {
|
|
325
|
-
case "CONFIRMED":
|
|
326
|
-
return "Confirmed";
|
|
327
|
-
case "PLAUSIBLE":
|
|
328
|
-
return "Plausible";
|
|
329
|
-
case "REFUTED":
|
|
330
|
-
return "Refuted";
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function verdictStatus(verdict: Verified["verdict"]): "success" | "warning" | "error" {
|
|
335
|
-
switch (verdict) {
|
|
336
|
-
case "CONFIRMED":
|
|
337
|
-
return "success";
|
|
338
|
-
case "PLAUSIBLE":
|
|
339
|
-
return "warning";
|
|
340
|
-
case "REFUTED":
|
|
341
|
-
return "error";
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function verdictConfidence(verdict: Verified["verdict"]): "high" | "medium" | "low" {
|
|
346
|
-
switch (verdict) {
|
|
347
|
-
case "CONFIRMED":
|
|
348
|
-
return "high";
|
|
349
|
-
case "PLAUSIBLE":
|
|
350
|
-
return "medium";
|
|
351
|
-
case "REFUTED":
|
|
352
|
-
return "low";
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function sameFinding(candidate: Verified, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
|
|
357
|
-
const candidateLocation = primaryLocation(candidate);
|
|
358
|
-
const findingLocation = primaryLocation(finding);
|
|
359
|
-
return normalizePath(candidateLocation.file) === normalizePath(findingLocation.file) && candidateLocation.line === findingLocation.line;
|
|
360
|
-
}
|
|
@@ -4,11 +4,17 @@ import {
|
|
|
4
4
|
AdvisoryReportSchema,
|
|
5
5
|
AdvisoryVerdictSchema,
|
|
6
6
|
type AdvisoryCandidate,
|
|
7
|
-
type AdvisoryFinding,
|
|
8
|
-
type AdvisoryLocation,
|
|
9
7
|
type AdvisoryReport,
|
|
10
8
|
type AdvisoryVerdict,
|
|
11
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";
|
|
12
18
|
import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
|
|
13
19
|
|
|
14
20
|
export const meta: WorkflowMeta = {
|
|
@@ -156,18 +162,8 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
156
162
|
},
|
|
157
163
|
);
|
|
158
164
|
if (!judged) return null;
|
|
159
|
-
progress
|
|
160
|
-
if (judged.verdict === "REFUTED") {
|
|
165
|
+
recordVerdictProgress(progress, hypothesis, judged, () => {
|
|
161
166
|
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
167
|
});
|
|
172
168
|
return { ...hypothesis, verdict: judged.verdict, evidence: judged.evidence, confidence: judged.confidence };
|
|
173
169
|
}),
|
|
@@ -216,14 +212,9 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
216
212
|
|
|
217
213
|
if (!report) return emptyReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun diagnose with a narrower symptom."], stats);
|
|
218
214
|
|
|
219
|
-
const findings = report.findings
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
};
|
|
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.",
|
|
227
218
|
});
|
|
228
219
|
return { ...report, findings, stats };
|
|
229
220
|
}
|
|
@@ -246,60 +237,8 @@ function dedupe(candidates: Hypothesis[], onDropped: (dropped: number) => void):
|
|
|
246
237
|
return novel;
|
|
247
238
|
}
|
|
248
239
|
|
|
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
240
|
function rank(finding: Verified): number {
|
|
275
241
|
if (finding.verdict === "CONFIRMED") return 0;
|
|
276
242
|
return 1;
|
|
277
243
|
}
|
|
278
244
|
|
|
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
|
-
}
|