pi-workflow-engine 0.10.0 → 0.10.2

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.
@@ -358,6 +358,8 @@ Use the workflow tool with a script argument, not a saved workflow name.
358
358
  The script must start with export const meta = { ... } and default-export an async workflow function.
359
359
  Use the injected Type object for schemas. Do not import anything or use dynamic import().
360
360
  Set thinkingLevel explicitly on each agent() call.
361
+ Always pass a plain string as the first api.agent() argument; build prompts with template strings before calling agent().
362
+ If using \`isolation: "worktree"\`, remember api.agent() returns \`{ result, patch, changed }\`; read \`.result\` for the agent answer and \`.patch\` for the diff.
361
363
  When the run is budgeted, guard expensive loops with \`while (api.budget.total && api.budget.remaining() > N) { ... }\`; api.agent() throws once the budget is spent.
362
364
  Subagents receive no skills by default. When the brief asks for a skill or a stage clearly benefits from one, pass \`skills: ["skill-name"]\` on that agent call only.
363
365
  Do not edit files unless the user explicitly requested edits.`;
@@ -566,6 +568,8 @@ export default function workflowEngine(pi: ExtensionAPI): void {
566
568
  "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
567
569
  "Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
568
570
  "Subagents receive no skills by default. In inline workflows, pass `skills: [\"skill-name\"]` per `agent()` call when the user asks for a skill or a stage should use one; grant only the needed skills.",
571
+ "Always pass a plain string as the first `api.agent()` argument; build prompts with template strings before calling agent().",
572
+ "When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.",
569
573
  "If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.",
570
574
  "`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.",
571
575
  "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
@@ -354,6 +354,10 @@ async function recordJournalResult(rc: RunContext, label: string, key: string, v
354
354
  * no event-stream parsing is needed.
355
355
  */
356
356
  export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
357
+ if (typeof prompt !== "string") {
358
+ throw new Error(`agent() prompt must be a string; received ${describeAgentPrompt(prompt)}`);
359
+ }
360
+
357
361
  const label = opts.label ?? "agent";
358
362
  const phase = opts.phase ?? "Workflow";
359
363
  const tags = { label, phase };
@@ -553,6 +557,12 @@ async function isolatedResult(rc: RunContext, worktreePath: string, result: unkn
553
557
  return { result, patch: patch.patch, changed: patch.changed };
554
558
  }
555
559
 
560
+ function describeAgentPrompt(value: unknown): string {
561
+ if (value === null) return "null";
562
+ if (Array.isArray(value)) return "array";
563
+ return typeof value;
564
+ }
565
+
556
566
  function errorMessage(error: unknown): string {
557
567
  return error instanceof Error ? error.message : String(error);
558
568
  }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
3
+ import { cp, mkdir, rm } from "node:fs/promises";
4
+ import { join, resolve, sep } from "node:path";
4
5
  import { runBoundedProcess } from "./process-runner.ts";
5
6
 
6
7
  export interface WorktreeGitCommandOptions {
@@ -24,11 +25,13 @@ export interface WorktreeGitRunner {
24
25
 
25
26
  export interface WorktreeRef {
26
27
  readonly path: string;
28
+ readonly snapshot?: boolean;
27
29
  }
28
30
 
29
31
  export interface WorktreeAddFailure {
30
32
  readonly path: string;
31
33
  readonly error: string;
34
+ readonly snapshot?: boolean;
32
35
  readonly cleanup?: WorktreeGitCommandResult;
33
36
  }
34
37
 
@@ -67,6 +70,7 @@ let pathCounter = 0;
67
70
 
68
71
  export class WorktreeRegistry {
69
72
  private readonly paths = new Set<string>();
73
+ private readonly snapshots = new Set<string>();
70
74
  private readonly runner: WorktreeGitRunner;
71
75
  private readonly patchCapture: WorktreePatchCapture;
72
76
  private readonly timeoutMs: number;
@@ -92,6 +96,7 @@ export class WorktreeRegistry {
92
96
  async add(signal?: AbortSignal): Promise<WorktreeRef | WorktreeAddFailure> {
93
97
  const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
94
98
  this.register(added.path);
99
+ if (added.snapshot === true) this.snapshots.add(added.path);
95
100
  if ("error" in added) {
96
101
  const cleanup = await this.remove(added.path);
97
102
  return { ...added, cleanup };
@@ -104,13 +109,16 @@ export class WorktreeRegistry {
104
109
  }
105
110
 
106
111
  async remove(path: string): Promise<WorktreeGitCommandResult> {
107
- const result = await removeWorktree({ repoCwd: this.repoCwd, path, runner: this.runner, timeoutMs: this.timeoutMs }).catch((error: unknown) => ({
112
+ const result = await removeWorktree({ repoCwd: this.repoCwd, path, runner: this.runner, timeoutMs: this.timeoutMs, snapshot: this.snapshots.has(path) }).catch((error: unknown) => ({
108
113
  ok: false,
109
114
  stdout: "",
110
115
  stderr: "",
111
116
  error: formatError(error),
112
117
  }));
113
- if (result.ok) this.paths.delete(path);
118
+ if (result.ok) {
119
+ this.paths.delete(path);
120
+ this.snapshots.delete(path);
121
+ }
114
122
  return result;
115
123
  }
116
124
 
@@ -163,8 +171,16 @@ export async function addWorktree(options: {
163
171
  timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
164
172
  })
165
173
  .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
166
- if (!result.ok) return { path, error: result.error ?? (result.stderr.trim() || "git worktree add failed") };
167
- return { path };
174
+ if (result.ok) return { path };
175
+
176
+ const message = result.error ?? (result.stderr.trim() || "git worktree add failed");
177
+ if (!isInvalidHeadError(message)) return { path, error: message };
178
+
179
+ const snapshotError = await createUnbornRepoSnapshot({ repoCwd: options.repoCwd, path, timeoutMs: options.timeoutMs, signal: options.signal })
180
+ .then(() => undefined)
181
+ .catch((error: unknown) => formatError(error));
182
+ if (snapshotError !== undefined) return { path, snapshot: true, error: `git worktree add failed (${message}); unborn-repo snapshot fallback failed: ${snapshotError}` };
183
+ return { path, snapshot: true };
168
184
  }
169
185
 
170
186
  export async function removeWorktree(options: {
@@ -172,7 +188,12 @@ export async function removeWorktree(options: {
172
188
  readonly path: string;
173
189
  readonly runner?: WorktreeGitRunner;
174
190
  readonly timeoutMs?: number;
191
+ readonly snapshot?: boolean;
175
192
  }): Promise<WorktreeGitCommandResult> {
193
+ if (options.snapshot === true) {
194
+ await rm(options.path, { recursive: true, force: true });
195
+ return { ok: true, stdout: "", stderr: "" };
196
+ }
176
197
  return await (options.runner ?? spawnGitRunner).runGit({
177
198
  cwd: options.repoCwd,
178
199
  args: ["worktree", "remove", "--force", options.path],
@@ -211,6 +232,38 @@ export async function captureWorktreePatch(options: {
211
232
  return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
212
233
  }
213
234
 
235
+ async function createUnbornRepoSnapshot(options: {
236
+ readonly repoCwd: string;
237
+ readonly path: string;
238
+ readonly signal?: AbortSignal;
239
+ readonly timeoutMs?: number;
240
+ }): Promise<void> {
241
+ const targetRoot = resolve(options.path);
242
+ await rm(options.path, { recursive: true, force: true });
243
+ await mkdir(options.path, { recursive: true });
244
+ await cp(options.repoCwd, options.path, {
245
+ recursive: true,
246
+ force: true,
247
+ filter: (source) => {
248
+ const resolvedSource = resolve(source);
249
+ if (resolvedSource === targetRoot || resolvedSource.startsWith(`${targetRoot}${sep}`)) return false;
250
+ return !resolvedSource.split(/[\\/]/).includes(".git");
251
+ },
252
+ });
253
+ await runGitCommand({ cwd: options.path, args: ["init"], signal: options.signal, timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS });
254
+ await runGitCommand({ cwd: options.path, args: ["add", "-A"], signal: options.signal, timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS });
255
+ await runGitCommand({
256
+ cwd: options.path,
257
+ args: ["-c", "user.name=pi-workflow", "-c", "user.email=pi-workflow@example.invalid", "commit", "--allow-empty", "-m", "pi workflow baseline"],
258
+ signal: options.signal,
259
+ timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
260
+ });
261
+ }
262
+
263
+ function isInvalidHeadError(message: string): boolean {
264
+ return /invalid reference:\s*HEAD/i.test(message) || /ambiguous argument ['"]?HEAD/i.test(message) || /unknown revision or path.*HEAD/i.test(message);
265
+ }
266
+
214
267
  export const spawnGitRunner: WorktreeGitRunner = {
215
268
  async runGit(options) {
216
269
  return await runGitCommand(options);
@@ -218,11 +271,14 @@ export const spawnGitRunner: WorktreeGitRunner = {
218
271
  };
219
272
 
220
273
  async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
274
+ const env = { ...process.env };
275
+ delete env.GIT_EXTERNAL_DIFF;
276
+ delete env.GIT_DIFF_OPTS;
221
277
  return await runBoundedProcess({
222
278
  file: "git",
223
279
  args: options.args,
224
280
  cwd: options.cwd,
225
- env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
281
+ env,
226
282
  signal: options.signal,
227
283
  timeoutMs: options.timeoutMs,
228
284
  maxBufferBytes: options.maxBufferBytes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-workflow-engine",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency Dynamax workflows for pi: live parallel subagents, typed handoffs, synthesized results.",
6
6
  "keywords": [