pi-workflow-engine 0.1.0 → 0.1.1
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 +1 -1
- package/src/engine.ts +1 -0
- package/src/types.ts +2 -0
- package/workflows/code-review.ts +74 -4
package/package.json
CHANGED
package/src/engine.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -46,6 +46,8 @@ export interface WorkflowApi {
|
|
|
46
46
|
log(message: string): void;
|
|
47
47
|
/** Raw argument string passed after the workflow name. */
|
|
48
48
|
args: string;
|
|
49
|
+
/** Working directory of the host session (typically the repo root). */
|
|
50
|
+
cwd: string;
|
|
49
51
|
/** Abort signal for the run, propagated from the host. */
|
|
50
52
|
signal: AbortSignal | undefined;
|
|
51
53
|
}
|
package/workflows/code-review.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
3
|
import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
|
|
3
4
|
|
|
@@ -72,9 +73,47 @@ const ANGLES: Angle[] = [
|
|
|
72
73
|
|
|
73
74
|
const TOOLS = ["read", "bash"];
|
|
74
75
|
const PER_ANGLE = 6;
|
|
76
|
+
const DIFF_EMBED_CAP = 60_000;
|
|
77
|
+
|
|
78
|
+
/** Strip leading "./", "a/", "b/" so diff paths and finder-reported paths compare equal. */
|
|
79
|
+
export function normalizePath(path: string): string {
|
|
80
|
+
return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parse a unified diff into the set of added/changed new-file line numbers per file. */
|
|
84
|
+
export function changedLines(diff: string): Map<string, Set<number>> {
|
|
85
|
+
const byFile = new Map<string, Set<number>>();
|
|
86
|
+
let file: string | null = null;
|
|
87
|
+
let newLine = 0;
|
|
88
|
+
for (const raw of diff.split("\n")) {
|
|
89
|
+
if (raw.startsWith("+++ ")) {
|
|
90
|
+
const path = raw.slice(4).trim();
|
|
91
|
+
file = path === "/dev/null" ? null : normalizePath(path);
|
|
92
|
+
if (file && !byFile.has(file)) byFile.set(file, new Set());
|
|
93
|
+
} else if (raw.startsWith("@@")) {
|
|
94
|
+
const match = /\+(\d+)/.exec(raw);
|
|
95
|
+
newLine = match ? Number(match[1]) : 0;
|
|
96
|
+
} else if (file === null || raw.startsWith("---") || raw.startsWith("\\")) {
|
|
97
|
+
// file header, deletion, or "No newline" marker — record nothing
|
|
98
|
+
} else if (raw.startsWith("+")) {
|
|
99
|
+
byFile.get(file)!.add(newLine++);
|
|
100
|
+
} else if (!raw.startsWith("-")) {
|
|
101
|
+
newLine++; // context line advances the new-file counter; deletions do not
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return byFile;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Is a finding inside the diff? File-level findings count if the file changed; ±1 line of fuzz. */
|
|
108
|
+
export function inDiff(changed: Map<string, Set<number>>, file: string, line?: number): boolean {
|
|
109
|
+
const set = changed.get(normalizePath(file));
|
|
110
|
+
if (!set) return false;
|
|
111
|
+
if (line == null) return true;
|
|
112
|
+
return set.has(line) || set.has(line - 1) || set.has(line + 1);
|
|
113
|
+
}
|
|
75
114
|
|
|
76
115
|
export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
77
|
-
const { agent, parallel, pipeline, phase, log, args } = api;
|
|
116
|
+
const { agent, parallel, pipeline, phase, log, args, cwd } = api;
|
|
78
117
|
const target = args.trim();
|
|
79
118
|
|
|
80
119
|
// ─── Phase 0: Scope ───
|
|
@@ -103,9 +142,32 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
103
142
|
}
|
|
104
143
|
log(`${scope.files.length} changed files`);
|
|
105
144
|
|
|
145
|
+
// Capture the diff once, deterministically, so findings can be bounded to changed lines in code.
|
|
146
|
+
let changed: Map<string, Set<number>> | null = null;
|
|
147
|
+
let diffText = "";
|
|
148
|
+
if (/^(git diff|gh pr diff)\b/.test(scope.diffCommand)) {
|
|
149
|
+
try {
|
|
150
|
+
diffText = execSync(scope.diffCommand, { cwd, encoding: "utf8", maxBuffer: 16 << 20 });
|
|
151
|
+
changed = changedLines(diffText);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
log(`diff capture failed (${error instanceof Error ? error.message : String(error)}) — reviewing without the line gate`);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
log("diffCommand not in the git/gh allowlist — reviewing without the line gate");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const diffBlock = diffText
|
|
160
|
+
? `\n## Diff (review is bounded to these changed lines)\n\`\`\`diff\n${
|
|
161
|
+
diffText.length > DIFF_EMBED_CAP
|
|
162
|
+
? `${diffText.slice(0, DIFF_EMBED_CAP)}\n... (truncated — run \`${scope.diffCommand}\` for the full diff)`
|
|
163
|
+
: diffText
|
|
164
|
+
}\n\`\`\`\n`
|
|
165
|
+
: "";
|
|
166
|
+
|
|
106
167
|
const scopeBlock =
|
|
107
168
|
`## Diff command\n${scope.diffCommand}\n\n## Changed files\n${scope.files.map((file) => `- ${file}`).join("\n")}\n\n` +
|
|
108
169
|
`## Summary\n${scope.summary}\n\n## Conventions\n${scope.conventions ?? "(none noted)"}\n` +
|
|
170
|
+
diffBlock +
|
|
109
171
|
(target ? `\n## User instructions (verbatim)\n${target}\n` : "");
|
|
110
172
|
|
|
111
173
|
// Dedup state accumulates as finders complete (the pipeline has no barrier).
|
|
@@ -122,12 +184,20 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
122
184
|
const angle = item as Angle;
|
|
123
185
|
const found = await agent(
|
|
124
186
|
`## Code-review finder — ${angle.label}\n\n${scopeBlock}\n` +
|
|
125
|
-
`
|
|
126
|
-
|
|
187
|
+
`Review the change through ONLY this lens:\n${angle.text}\n` +
|
|
188
|
+
"Only flag issues on lines that are part of the diff above (run the diff command if it is not shown). " +
|
|
189
|
+
"You may read surrounding files for context, but never report issues in unchanged code. " +
|
|
190
|
+
`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
191
|
"Pass through anything with a nameable failure scenario — a separate verifier judges them next. Structured output only.",
|
|
128
192
|
{ phase: "Find", label: `find:${angle.label}`, tools: TOOLS, thinkingLevel: "low", schema: CandidatesSchema },
|
|
129
193
|
);
|
|
130
|
-
|
|
194
|
+
const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
|
|
195
|
+
const gate = changed;
|
|
196
|
+
const bounded = gate ? raw.filter((candidate) => inDiff(gate, candidate.file, candidate.line)) : raw;
|
|
197
|
+
if (gate && raw.length - bounded.length > 0) {
|
|
198
|
+
log(`find:${angle.label}: dropped ${raw.length - bounded.length} out-of-diff candidate(s)`);
|
|
199
|
+
}
|
|
200
|
+
return { angle, candidates: bounded };
|
|
131
201
|
},
|
|
132
202
|
// Stage 2: dedup against the shared set, then verify survivors concurrently.
|
|
133
203
|
async (prev) => {
|