pi-workflow-engine 0.1.0 → 0.2.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/package.json +2 -1
- package/src/agent-runner.ts +8 -4
- package/src/engine.ts +19 -2
- package/src/index.ts +79 -14
- package/src/progress.ts +258 -23
- package/src/types.ts +31 -0
- package/src/ui/workflow-format.ts +83 -0
- package/src/ui/workflow-inspector.ts +248 -0
- package/src/ui/workflow-result-renderer.ts +149 -0
- package/src/ui/workflow-widget.ts +143 -0
- package/workflows/code-review.ts +174 -11
package/workflows/code-review.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
|
-
import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
|
|
3
|
+
import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
|
|
3
4
|
|
|
4
5
|
export const meta: WorkflowMeta = {
|
|
5
6
|
name: "code-review",
|
|
@@ -40,6 +41,8 @@ const ReportSchema = Type.Object({
|
|
|
40
41
|
severity: Type.Union([Type.Literal("bug"), Type.Literal("cleanup")]),
|
|
41
42
|
verdict: Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE")]),
|
|
42
43
|
summary: Type.String(),
|
|
44
|
+
evidence: Type.Optional(Type.String()),
|
|
45
|
+
failure_scenario: Type.Optional(Type.String()),
|
|
43
46
|
}),
|
|
44
47
|
),
|
|
45
48
|
});
|
|
@@ -72,10 +75,58 @@ const ANGLES: Angle[] = [
|
|
|
72
75
|
|
|
73
76
|
const TOOLS = ["read", "bash"];
|
|
74
77
|
const PER_ANGLE = 6;
|
|
78
|
+
const DIFF_EMBED_CAP = 60_000;
|
|
79
|
+
|
|
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
|
+
/** Parse a unified diff into the set of added/changed new-file line numbers per file. */
|
|
86
|
+
export function changedLines(diff: string): Map<string, Set<number>> {
|
|
87
|
+
const byFile = new Map<string, Set<number>>();
|
|
88
|
+
let file: string | null = null;
|
|
89
|
+
let newLine = 0;
|
|
90
|
+
for (const raw of diff.split("\n")) {
|
|
91
|
+
if (raw.startsWith("+++ ")) {
|
|
92
|
+
const path = raw.slice(4).trim();
|
|
93
|
+
file = path === "/dev/null" ? null : normalizePath(path);
|
|
94
|
+
if (file && !byFile.has(file)) byFile.set(file, new Set());
|
|
95
|
+
} else if (raw.startsWith("@@")) {
|
|
96
|
+
const match = /\+(\d+)/.exec(raw);
|
|
97
|
+
newLine = match ? Number(match[1]) : 0;
|
|
98
|
+
} else if (file === null || raw.startsWith("---") || raw.startsWith("\\")) {
|
|
99
|
+
// file header, deletion, or "No newline" marker — record nothing
|
|
100
|
+
} else if (raw.startsWith("+")) {
|
|
101
|
+
byFile.get(file)!.add(newLine++);
|
|
102
|
+
} else if (!raw.startsWith("-")) {
|
|
103
|
+
newLine++; // context line advances the new-file counter; deletions do not
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return byFile;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Is a finding inside the diff? File-level findings count if the file changed; ±1 line of fuzz. */
|
|
110
|
+
export function inDiff(changed: Map<string, Set<number>>, file: string, line?: number): boolean {
|
|
111
|
+
const set = changed.get(normalizePath(file));
|
|
112
|
+
if (!set) return false;
|
|
113
|
+
if (line == null) return true;
|
|
114
|
+
return set.has(line) || set.has(line - 1) || set.has(line + 1);
|
|
115
|
+
}
|
|
75
116
|
|
|
76
117
|
export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
77
|
-
const { agent, parallel, pipeline, phase, log, args } = api;
|
|
118
|
+
const { agent, parallel, pipeline, phase, log, progress, args, cwd } = api;
|
|
78
119
|
const target = args.trim();
|
|
120
|
+
let fileCount = 0;
|
|
121
|
+
let rawCandidateCount = 0;
|
|
122
|
+
let droppedCandidateCount = 0;
|
|
123
|
+
const makeStats = (verified: number, kept: number): WorkflowRunStats => ({
|
|
124
|
+
files: fileCount,
|
|
125
|
+
candidates: rawCandidateCount,
|
|
126
|
+
verified,
|
|
127
|
+
kept,
|
|
128
|
+
dropped: droppedCandidateCount,
|
|
129
|
+
});
|
|
79
130
|
|
|
80
131
|
// ─── Phase 0: Scope ───
|
|
81
132
|
phase("Scope");
|
|
@@ -98,14 +149,46 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
98
149
|
{ phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
99
150
|
);
|
|
100
151
|
|
|
101
|
-
if (!scope
|
|
102
|
-
return { summary: "No changes found to review.", findings: [], stats:
|
|
152
|
+
if (!scope) {
|
|
153
|
+
return { summary: "No changes found to review.", findings: [], stats: makeStats(0, 0) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fileCount = scope.files.length;
|
|
157
|
+
progress({ type: "summary", key: "files", value: scope.files.join(", ") || "(none)" });
|
|
158
|
+
progress({ type: "summary", key: "diffCommand", value: scope.diffCommand });
|
|
159
|
+
progress({ type: "counter", key: "files", label: "files", value: fileCount });
|
|
160
|
+
|
|
161
|
+
if (scope.files.length === 0) {
|
|
162
|
+
return { summary: "No changes found to review.", findings: [], stats: makeStats(0, 0) };
|
|
103
163
|
}
|
|
104
164
|
log(`${scope.files.length} changed files`);
|
|
105
165
|
|
|
166
|
+
// Capture the diff once, deterministically, so findings can be bounded to changed lines in code.
|
|
167
|
+
let changed: Map<string, Set<number>> | null = null;
|
|
168
|
+
let diffText = "";
|
|
169
|
+
if (/^(git diff|gh pr diff)\b/.test(scope.diffCommand)) {
|
|
170
|
+
try {
|
|
171
|
+
diffText = execSync(scope.diffCommand, { cwd, encoding: "utf8", maxBuffer: 16 << 20 });
|
|
172
|
+
changed = changedLines(diffText);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
log(`diff capture failed (${error instanceof Error ? error.message : String(error)}) — reviewing without the line gate`);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
log("diffCommand not in the git/gh allowlist — reviewing without the line gate");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const diffBlock = diffText
|
|
181
|
+
? `\n## Diff (review is bounded to these changed lines)\n\`\`\`diff\n${
|
|
182
|
+
diffText.length > DIFF_EMBED_CAP
|
|
183
|
+
? `${diffText.slice(0, DIFF_EMBED_CAP)}\n... (truncated — run \`${scope.diffCommand}\` for the full diff)`
|
|
184
|
+
: diffText
|
|
185
|
+
}\n\`\`\`\n`
|
|
186
|
+
: "";
|
|
187
|
+
|
|
106
188
|
const scopeBlock =
|
|
107
189
|
`## Diff command\n${scope.diffCommand}\n\n## Changed files\n${scope.files.map((file) => `- ${file}`).join("\n")}\n\n` +
|
|
108
190
|
`## Summary\n${scope.summary}\n\n## Conventions\n${scope.conventions ?? "(none noted)"}\n` +
|
|
191
|
+
diffBlock +
|
|
109
192
|
(target ? `\n## User instructions (verbatim)\n${target}\n` : "");
|
|
110
193
|
|
|
111
194
|
// Dedup state accumulates as finders complete (the pipeline has no barrier).
|
|
@@ -122,12 +205,37 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
122
205
|
const angle = item as Angle;
|
|
123
206
|
const found = await agent(
|
|
124
207
|
`## Code-review finder — ${angle.label}\n\n${scopeBlock}\n` +
|
|
125
|
-
`
|
|
126
|
-
|
|
208
|
+
`Review the change through ONLY this lens:\n${angle.text}\n` +
|
|
209
|
+
"Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
|
|
210
|
+
"You may read surrounding files for context, but never report issues in unchanged code. " +
|
|
211
|
+
`Surface up to ${PER_ANGLE} candidates, each with file, a line that appears in the diff, a one-line summary, and a concrete failure_scenario. ` +
|
|
127
212
|
"Pass through anything with a nameable failure scenario — a separate verifier judges them next. Structured output only.",
|
|
128
213
|
{ phase: "Find", label: `find:${angle.label}`, tools: TOOLS, thinkingLevel: "low", schema: CandidatesSchema },
|
|
129
214
|
);
|
|
130
|
-
|
|
215
|
+
const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
|
|
216
|
+
rawCandidateCount += raw.length;
|
|
217
|
+
progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
|
|
218
|
+
const gate = changed;
|
|
219
|
+
const bounded = gate ? raw.filter((candidate) => inDiff(gate, candidate.file, candidate.line)) : raw;
|
|
220
|
+
const dropped = raw.length - bounded.length;
|
|
221
|
+
if (dropped > 0) {
|
|
222
|
+
droppedCandidateCount += dropped;
|
|
223
|
+
progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
|
|
224
|
+
}
|
|
225
|
+
if (gate && dropped > 0) {
|
|
226
|
+
log(`find:${angle.label}: dropped ${dropped} out-of-diff candidate(s)`);
|
|
227
|
+
}
|
|
228
|
+
for (const candidate of bounded) {
|
|
229
|
+
progress({
|
|
230
|
+
type: "lane_item",
|
|
231
|
+
lane: "Candidates",
|
|
232
|
+
title: candidate.summary,
|
|
233
|
+
subtitle: formatLocation(candidate),
|
|
234
|
+
status: "pending",
|
|
235
|
+
details: candidate.failure_scenario,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return { angle, candidates: bounded };
|
|
131
239
|
},
|
|
132
240
|
// Stage 2: dedup against the shared set, then verify survivors concurrently.
|
|
133
241
|
async (prev) => {
|
|
@@ -148,7 +256,17 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
148
256
|
"with evidence quoting the line(s). Default toward REFUTED if you cannot substantiate it. Structured output only.",
|
|
149
257
|
{ phase: "Verify", label: `verify:${candidate.file.split("/").pop() ?? candidate.file}`, tools: TOOLS, thinkingLevel: "low", schema: VerdictSchema },
|
|
150
258
|
);
|
|
151
|
-
|
|
259
|
+
if (!judged) return null;
|
|
260
|
+
progress({ type: "counter_delta", key: `verdict.${judged.verdict.toLowerCase()}`, label: judged.verdict, delta: 1 });
|
|
261
|
+
progress({
|
|
262
|
+
type: "lane_item",
|
|
263
|
+
lane: verdictLane(judged.verdict),
|
|
264
|
+
title: candidate.summary,
|
|
265
|
+
subtitle: formatLocation(candidate),
|
|
266
|
+
status: verdictStatus(judged.verdict),
|
|
267
|
+
details: judged.evidence,
|
|
268
|
+
});
|
|
269
|
+
return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind };
|
|
152
270
|
}),
|
|
153
271
|
);
|
|
154
272
|
return verdicts.filter((value): value is Verified => value !== null);
|
|
@@ -157,10 +275,15 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
157
275
|
|
|
158
276
|
const verified = (perAngle as Verified[][]).flat();
|
|
159
277
|
const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
|
|
278
|
+
const stats = makeStats(verified.length, surviving.length);
|
|
279
|
+
progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
|
|
280
|
+
progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
|
|
281
|
+
progress({ type: "summary", key: "verified", value: verified.length });
|
|
282
|
+
progress({ type: "summary", key: "kept", value: surviving.length });
|
|
160
283
|
log(`${verified.length} verified → ${surviving.length} kept`);
|
|
161
284
|
|
|
162
285
|
if (surviving.length === 0) {
|
|
163
|
-
return { summary: "No findings survived verification.", findings: [], stats
|
|
286
|
+
return { summary: "No findings survived verification.", findings: [], stats };
|
|
164
287
|
}
|
|
165
288
|
|
|
166
289
|
// ─── Synthesize: rank, merge, report ───
|
|
@@ -177,9 +300,49 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
177
300
|
|
|
178
301
|
const report = await agent(
|
|
179
302
|
`## Synthesis: final code-review report\n\n${ranked.length} findings survived independent verification.\n\n${block}\n\n` +
|
|
180
|
-
"Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final report. Structured output only.",
|
|
303
|
+
"Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final report. Include evidence and failure_scenario for each finding when available. Structured output only.",
|
|
181
304
|
{ phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: ReportSchema },
|
|
182
305
|
);
|
|
183
306
|
|
|
184
|
-
|
|
307
|
+
if (!report) return { summary: "Synthesis produced no output.", findings: [], stats };
|
|
308
|
+
|
|
309
|
+
const findings = report.findings.map((finding) => {
|
|
310
|
+
const source = ranked.find((candidate) => sameFinding(candidate, finding));
|
|
311
|
+
return {
|
|
312
|
+
...finding,
|
|
313
|
+
evidence: finding.evidence ?? source?.evidence,
|
|
314
|
+
failure_scenario: finding.failure_scenario ?? source?.failure_scenario,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
return { ...report, findings, stats };
|
|
318
|
+
}
|
|
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;
|
|
185
348
|
}
|