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.
@@ -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 || scope.files.length === 0) {
102
- return { summary: "No changes found to review.", findings: [], stats: { candidates: 0, verified: 0 } };
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
- `Run the diff command and review ONLY through this lens:\n${angle.text}\n` +
126
- `Surface up to ${PER_ANGLE} candidates, each with file, line, a one-line summary, and a concrete failure_scenario. ` +
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
- return { angle, candidates: (found?.candidates ?? []).slice(0, PER_ANGLE) };
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
- return judged ? { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind } : null;
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: { candidates: seen.size, verified: verified.length } };
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
- return report ?? { summary: "Synthesis produced no output.", findings: [] };
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
  }