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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-workflow-engine",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "A customisable multi-agent workflow-orchestration engine for the pi coding agent.",
6
6
  "keywords": ["pi-package"],
package/src/engine.ts CHANGED
@@ -32,6 +32,7 @@ export async function runWorkflow(ctx: ExtensionContext, mod: WorkflowModule, ar
32
32
  phase: (title) => progress.phase(title),
33
33
  log: (message) => progress.log(message),
34
34
  args,
35
+ cwd: ctx.cwd,
35
36
  signal: ctx.signal,
36
37
  };
37
38
 
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
  }
@@ -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
- `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. ` +
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
- return { angle, candidates: (found?.candidates ?? []).slice(0, PER_ANGLE) };
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) => {