pi-workflow-engine 0.10.3 → 0.12.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.
Files changed (88) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. package/package.json +9 -9
@@ -1,12 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { tmpdir } from "node:os";
3
- import { cp, mkdir, rm } from "node:fs/promises";
4
- import { join, resolve, sep } from "node:path";
3
+ import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { runBoundedProcess } from "./process-runner.ts";
6
+ import { unknownErrorMessage } from "./unknown-error.ts";
6
7
 
7
8
  export interface WorktreeGitCommandOptions {
8
9
  readonly cwd: string;
9
10
  readonly args: readonly string[];
11
+ readonly env?: NodeJS.ProcessEnv;
12
+ readonly stdin?: string;
10
13
  readonly signal?: AbortSignal;
11
14
  readonly timeoutMs: number;
12
15
  readonly maxBufferBytes?: number;
@@ -25,9 +28,17 @@ export interface WorktreeGitRunner {
25
28
 
26
29
  export interface WorktreeRef {
27
30
  readonly path: string;
31
+ /** Exact post-setup commit used as the immutable base for every result patch. */
32
+ readonly baselineOid: string;
28
33
  readonly snapshot?: boolean;
29
34
  }
30
35
 
36
+ /** Immutable commit plus an optional patch used to reconstruct a reviewed snapshot. */
37
+ export interface WorktreeBaseline {
38
+ readonly ref: string;
39
+ readonly patch?: string;
40
+ }
41
+
31
42
  export interface WorktreeAddFailure {
32
43
  readonly path: string;
33
44
  readonly error: string;
@@ -45,6 +56,21 @@ export interface WorktreeRemovalOutcome extends WorktreeGitCommandResult {
45
56
  readonly path: string;
46
57
  }
47
58
 
59
+ export class WorktreeCleanupError extends AggregateError {
60
+ readonly outcomes: readonly WorktreeRemovalOutcome[];
61
+
62
+ constructor(outcomes: readonly WorktreeRemovalOutcome[]) {
63
+ const failures = outcomes.filter((outcome) => !outcome.ok);
64
+ const details = failures.map((failure) => `${failure.path} (${worktreeRemovalError(failure)})`);
65
+ super(
66
+ failures.map((failure) => new Error(`Failed to remove isolated worktree ${failure.path}: ${worktreeRemovalError(failure)}`)),
67
+ `Failed to remove ${failures.length} isolated worktree${failures.length === 1 ? "" : "s"}: ${details.join(", ")}`,
68
+ );
69
+ this.name = "WorktreeCleanupError";
70
+ this.outcomes = outcomes;
71
+ }
72
+ }
73
+
48
74
  export interface WorktreePatch {
49
75
  readonly patch: string;
50
76
  readonly changed: boolean;
@@ -59,6 +85,7 @@ export interface WorktreeRegistryOptions {
59
85
 
60
86
  export type WorktreePatchCapture = (options: {
61
87
  readonly worktreePath: string;
88
+ readonly baselineOid: string;
62
89
  readonly runner?: WorktreeGitRunner;
63
90
  readonly signal?: AbortSignal;
64
91
  readonly timeoutMs?: number;
@@ -66,11 +93,20 @@ export type WorktreePatchCapture = (options: {
66
93
 
67
94
  const DEFAULT_WORKTREE_TIMEOUT_MS = 30_000;
68
95
  const WORKTREE_DIFF_MAX_BYTES = 16 << 20;
96
+ const SYNTHETIC_COMMIT_ENV: NodeJS.ProcessEnv = {
97
+ GIT_AUTHOR_NAME: "pi-workflow",
98
+ GIT_AUTHOR_EMAIL: "pi-workflow@example.invalid",
99
+ GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z",
100
+ GIT_COMMITTER_NAME: "pi-workflow",
101
+ GIT_COMMITTER_EMAIL: "pi-workflow@example.invalid",
102
+ GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z",
103
+ };
69
104
  let pathCounter = 0;
70
105
 
71
106
  export class WorktreeRegistry {
72
107
  private readonly paths = new Set<string>();
73
108
  private readonly snapshots = new Set<string>();
109
+ private readonly removals = new Map<string, Promise<WorktreeGitCommandResult>>();
74
110
  private readonly runner: WorktreeGitRunner;
75
111
  private readonly patchCapture: WorktreePatchCapture;
76
112
  private readonly timeoutMs: number;
@@ -93,8 +129,8 @@ export class WorktreeRegistry {
93
129
  return await isGitWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
94
130
  }
95
131
 
96
- async add(signal?: AbortSignal): Promise<WorktreeRef | WorktreeAddFailure> {
97
- const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
132
+ async add(signal?: AbortSignal, baseline?: WorktreeBaseline): Promise<WorktreeRef | WorktreeAddFailure> {
133
+ const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs, baseline });
98
134
  this.register(added.path);
99
135
  if (added.snapshot === true) this.snapshots.add(added.path);
100
136
  if ("error" in added) {
@@ -104,16 +140,62 @@ export class WorktreeRegistry {
104
140
  return added;
105
141
  }
106
142
 
107
- async capturePatch(path: string, signal?: AbortSignal): Promise<WorktreePatch | { readonly error: string }> {
108
- return await this.patchCapture({ worktreePath: path, runner: this.runner, signal, timeoutMs: this.timeoutMs });
143
+ async capturePatch(
144
+ path: string,
145
+ baselineOid: string,
146
+ signal?: AbortSignal,
147
+ ): Promise<WorktreePatch | { readonly error: string }> {
148
+ return await this.patchCapture({
149
+ worktreePath: path,
150
+ baselineOid,
151
+ runner: this.runner,
152
+ signal,
153
+ timeoutMs: this.timeoutMs,
154
+ });
155
+ }
156
+
157
+ async validatePatch(path: string, candidate: WorktreePatch, signal?: AbortSignal): Promise<WorktreeGitCommandResult> {
158
+ return await validateWorktreePatch({
159
+ worktreePath: path,
160
+ candidate,
161
+ runner: this.runner,
162
+ signal,
163
+ timeoutMs: this.timeoutMs,
164
+ });
109
165
  }
110
166
 
111
167
  async remove(path: string): Promise<WorktreeGitCommandResult> {
112
- const result = await removeWorktree({ repoCwd: this.repoCwd, path, runner: this.runner, timeoutMs: this.timeoutMs, snapshot: this.snapshots.has(path) }).catch((error: unknown) => ({
168
+ const pending = this.removals.get(path);
169
+ if (pending) return await pending;
170
+
171
+ const removal = this.removeOnce(path).finally(() => this.removals.delete(path));
172
+ this.removals.set(path, removal);
173
+ return await removal;
174
+ }
175
+
176
+ async removeAll(): Promise<readonly WorktreeRemovalOutcome[]> {
177
+ const outcomes = await Promise.all(
178
+ [...this.paths].map(async (path) => ({
179
+ path,
180
+ ...(await this.remove(path)),
181
+ })),
182
+ );
183
+ if (outcomes.some((outcome) => !outcome.ok)) throw new WorktreeCleanupError(outcomes);
184
+ return outcomes;
185
+ }
186
+
187
+ private async removeOnce(path: string): Promise<WorktreeGitCommandResult> {
188
+ const result = await removeWorktree({
189
+ repoCwd: this.repoCwd,
190
+ path,
191
+ runner: this.runner,
192
+ timeoutMs: this.timeoutMs,
193
+ snapshot: this.snapshots.has(path),
194
+ }).catch((error: unknown) => ({
113
195
  ok: false,
114
196
  stdout: "",
115
197
  stderr: "",
116
- error: formatError(error),
198
+ error: unknownErrorMessage(error),
117
199
  }));
118
200
  if (result.ok) {
119
201
  this.paths.delete(path);
@@ -121,15 +203,6 @@ export class WorktreeRegistry {
121
203
  }
122
204
  return result;
123
205
  }
124
-
125
- async removeAll(): Promise<readonly WorktreeRemovalOutcome[]> {
126
- return await Promise.all(
127
- [...this.paths].map(async (path) => ({
128
- path,
129
- ...(await this.remove(path)),
130
- })),
131
- );
132
- }
133
206
  }
134
207
 
135
208
  export function createWorktreePath(baseDir = tmpdir()): string {
@@ -150,7 +223,7 @@ export async function isGitWorktree(options: {
150
223
  signal: options.signal,
151
224
  timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
152
225
  })
153
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
226
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
154
227
  if (!result.ok) return { ok: false, inside: false, error: result.error ?? (result.stderr.trim() || "git worktree probe failed") };
155
228
  return { ok: true, inside: result.stdout.trim() === "true" };
156
229
  }
@@ -161,26 +234,138 @@ export async function addWorktree(options: {
161
234
  readonly signal?: AbortSignal;
162
235
  readonly timeoutMs?: number;
163
236
  readonly baseDir?: string;
237
+ readonly baseline?: WorktreeBaseline;
164
238
  }): Promise<WorktreeRef | WorktreeAddFailure> {
165
239
  const path = createWorktreePath(options.baseDir);
166
- const result = await (options.runner ?? spawnGitRunner)
240
+ const ref = options.baseline?.ref ?? "HEAD";
241
+ const runner = options.runner ?? spawnGitRunner;
242
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
243
+ const result = await runner
167
244
  .runGit({
168
245
  cwd: options.repoCwd,
169
- args: ["worktree", "add", "--detach", path, "HEAD"],
246
+ args: ["worktree", "add", "--detach", path, ref],
170
247
  signal: options.signal,
171
- timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
248
+ timeoutMs,
172
249
  })
173
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
174
- if (result.ok) return { path };
250
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
251
+ if (result.ok) {
252
+ const patch = options.baseline?.patch;
253
+ if (patch !== undefined && patch.trim().length > 0) {
254
+ const prepared = await prepareWorktreeBaseline({
255
+ path,
256
+ patch,
257
+ runner,
258
+ signal: options.signal,
259
+ timeoutMs,
260
+ }).catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
261
+ if (!prepared.ok) return { path, error: prepared.error ?? (prepared.stderr.trim() || "failed to prepare worktree baseline") };
262
+ }
263
+ return await finalizeWorktreeRef({ path, runner, signal: options.signal, timeoutMs });
264
+ }
175
265
 
176
266
  const message = result.error ?? (result.stderr.trim() || "git worktree add failed");
177
267
  if (!isInvalidHeadError(message)) return { path, error: message };
178
268
 
179
- const snapshotError = await createUnbornRepoSnapshot({ repoCwd: options.repoCwd, path, timeoutMs: options.timeoutMs, signal: options.signal })
269
+ return await addUnbornRepositoryWorktree({
270
+ repoCwd: options.repoCwd,
271
+ path,
272
+ addError: message,
273
+ runner,
274
+ signal: options.signal,
275
+ snapshotTimeoutMs: options.timeoutMs,
276
+ timeoutMs,
277
+ });
278
+ }
279
+
280
+ async function addUnbornRepositoryWorktree(options: {
281
+ readonly repoCwd: string;
282
+ readonly path: string;
283
+ readonly addError: string;
284
+ readonly runner: WorktreeGitRunner;
285
+ readonly signal?: AbortSignal;
286
+ readonly snapshotTimeoutMs?: number;
287
+ readonly timeoutMs: number;
288
+ }): Promise<WorktreeRef | WorktreeAddFailure> {
289
+ const snapshotError = await createUnbornRepoSnapshot({
290
+ repoCwd: options.repoCwd,
291
+ path: options.path,
292
+ timeoutMs: options.snapshotTimeoutMs,
293
+ signal: options.signal,
294
+ })
180
295
  .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 };
296
+ .catch((error: unknown) => unknownErrorMessage(error));
297
+ if (snapshotError !== undefined) {
298
+ return {
299
+ path: options.path,
300
+ snapshot: true,
301
+ error: `git worktree add failed (${options.addError}); unborn-repo snapshot fallback failed: ${snapshotError}`,
302
+ };
303
+ }
304
+ return await finalizeWorktreeRef({
305
+ path: options.path,
306
+ snapshot: true,
307
+ runner: options.runner,
308
+ signal: options.signal,
309
+ timeoutMs: options.timeoutMs,
310
+ });
311
+ }
312
+
313
+ async function finalizeWorktreeRef(options: {
314
+ readonly path: string;
315
+ readonly snapshot?: boolean;
316
+ readonly runner: WorktreeGitRunner;
317
+ readonly signal?: AbortSignal;
318
+ readonly timeoutMs: number;
319
+ }): Promise<WorktreeRef | WorktreeAddFailure> {
320
+ const resolved = await options.runner
321
+ .runGit({
322
+ cwd: options.path,
323
+ args: ["rev-parse", "--verify", "HEAD^{commit}"],
324
+ signal: options.signal,
325
+ timeoutMs: options.timeoutMs,
326
+ })
327
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
328
+ if (!resolved.ok) {
329
+ return {
330
+ path: options.path,
331
+ snapshot: options.snapshot,
332
+ error: resolved.error ?? (resolved.stderr.trim() || "failed to resolve isolated worktree baseline"),
333
+ };
334
+ }
335
+ const baselineOid = resolved.stdout.trim();
336
+ if (!isGitObjectId(baselineOid)) {
337
+ return { path: options.path, snapshot: options.snapshot, error: "isolated worktree baseline is not a commit OID" };
338
+ }
339
+ return { path: options.path, baselineOid, snapshot: options.snapshot };
340
+ }
341
+
342
+ async function prepareWorktreeBaseline(options: {
343
+ readonly path: string;
344
+ readonly patch: string;
345
+ readonly runner: WorktreeGitRunner;
346
+ readonly signal?: AbortSignal;
347
+ readonly timeoutMs: number;
348
+ }): Promise<WorktreeGitCommandResult> {
349
+ const applied = await options.runner.runGit({
350
+ cwd: options.path,
351
+ args: ["apply", "--index", "--binary", "--whitespace=nowarn", "-"],
352
+ stdin: options.patch,
353
+ signal: options.signal,
354
+ timeoutMs: options.timeoutMs,
355
+ });
356
+ if (!applied.ok) return applied;
357
+ const hooksPath = await mkdtemp(join(tmpdir(), "pi-workflow-empty-hooks-"));
358
+ try {
359
+ return await options.runner.runGit({
360
+ cwd: options.path,
361
+ args: syntheticCommitArgs(hooksPath, "pi workflow reviewed snapshot"),
362
+ env: SYNTHETIC_COMMIT_ENV,
363
+ signal: options.signal,
364
+ timeoutMs: options.timeoutMs,
365
+ });
366
+ } finally {
367
+ await rm(hooksPath, { recursive: true, force: true });
368
+ }
184
369
  }
185
370
 
186
371
  export async function removeWorktree(options: {
@@ -203,10 +388,12 @@ export async function removeWorktree(options: {
203
388
 
204
389
  export async function captureWorktreePatch(options: {
205
390
  readonly worktreePath: string;
391
+ readonly baselineOid: string;
206
392
  readonly runner?: WorktreeGitRunner;
207
393
  readonly signal?: AbortSignal;
208
394
  readonly timeoutMs?: number;
209
395
  }): Promise<WorktreePatch | { readonly error: string }> {
396
+ if (!isGitObjectId(options.baselineOid)) return { error: "worktree patch baseline is not a commit OID" };
210
397
  const runner = options.runner ?? spawnGitRunner;
211
398
  const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
212
399
  const intentToAdd = await runner
@@ -216,54 +403,144 @@ export async function captureWorktreePatch(options: {
216
403
  signal: options.signal,
217
404
  timeoutMs,
218
405
  })
219
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
406
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
220
407
  if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
221
408
 
222
409
  const diff = await runner
223
410
  .runGit({
224
411
  cwd: options.worktreePath,
225
- args: ["diff", "--no-color", "HEAD"],
412
+ args: ["diff", "--binary", "--full-index", "--no-ext-diff", "--no-color", options.baselineOid, "--"],
226
413
  signal: options.signal,
227
414
  timeoutMs,
228
415
  maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
229
416
  })
230
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
417
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
231
418
  if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
232
419
  return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
233
420
  }
234
421
 
422
+ export async function validateWorktreePatch(options: {
423
+ readonly worktreePath: string;
424
+ readonly candidate: WorktreePatch;
425
+ readonly runner?: WorktreeGitRunner;
426
+ readonly signal?: AbortSignal;
427
+ readonly timeoutMs?: number;
428
+ }): Promise<WorktreeGitCommandResult> {
429
+ const hasPatch = options.candidate.patch.trim().length > 0;
430
+ if (options.candidate.changed !== hasPatch) {
431
+ return invalidPatchResult("cached worktree patch changed flag does not match patch content");
432
+ }
433
+ if (!hasPatch) return { ok: true, stdout: "", stderr: "" };
434
+
435
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
436
+ return await (options.runner ?? spawnGitRunner)
437
+ .runGit({
438
+ cwd: options.worktreePath,
439
+ args: ["apply", "--check", "--binary", "-"],
440
+ stdin: options.candidate.patch,
441
+ signal: options.signal,
442
+ timeoutMs,
443
+ })
444
+ .catch((error: unknown) => invalidPatchResult(unknownErrorMessage(error)));
445
+ }
446
+
447
+ function invalidPatchResult(error: string): WorktreeGitCommandResult {
448
+ return { ok: false, stdout: "", stderr: "", error };
449
+ }
450
+
235
451
  async function createUnbornRepoSnapshot(options: {
236
452
  readonly repoCwd: string;
237
453
  readonly path: string;
238
454
  readonly signal?: AbortSignal;
239
455
  readonly timeoutMs?: number;
240
456
  }): Promise<void> {
457
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
458
+ const rootProbe = await runGitCommand({
459
+ cwd: options.repoCwd,
460
+ args: ["rev-parse", "--show-toplevel"],
461
+ signal: options.signal,
462
+ timeoutMs,
463
+ });
464
+ if (!rootProbe.ok) {
465
+ throw new Error(rootProbe.error ?? (rootProbe.stderr.trim() || "failed to resolve unborn repository root"));
466
+ }
467
+ const sourceRoot = parseGitTopLevel(rootProbe.stdout, options.repoCwd);
468
+ if (!sourceRoot) throw new Error("unborn repository root probe returned an invalid path");
469
+
241
470
  const targetRoot = resolve(options.path);
242
471
  await rm(options.path, { recursive: true, force: true });
243
472
  await mkdir(options.path, { recursive: true });
244
- await cp(options.repoCwd, options.path, {
473
+ await cp(sourceRoot, options.path, {
245
474
  recursive: true,
246
475
  force: true,
247
476
  filter: (source) => {
248
477
  const resolvedSource = resolve(source);
249
478
  if (resolvedSource === targetRoot || resolvedSource.startsWith(`${targetRoot}${sep}`)) return false;
250
- return !resolvedSource.split(/[\\/]/).includes(".git");
479
+ return !isExcludedSnapshotPath(sourceRoot, resolvedSource);
251
480
  },
252
481
  });
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
- });
482
+ await requireGitCommand({ cwd: options.path, args: ["init"], signal: options.signal, timeoutMs });
483
+ await requireGitCommand({ cwd: options.path, args: ["add", "-A"], signal: options.signal, timeoutMs });
484
+ const hooksPath = await mkdtemp(join(tmpdir(), "pi-workflow-empty-hooks-"));
485
+ try {
486
+ await requireGitCommand({
487
+ cwd: options.path,
488
+ args: syntheticCommitArgs(hooksPath, "pi workflow baseline"),
489
+ env: SYNTHETIC_COMMIT_ENV,
490
+ signal: options.signal,
491
+ timeoutMs,
492
+ });
493
+ } finally {
494
+ await rm(hooksPath, { recursive: true, force: true });
495
+ }
496
+ await requireGitCommand({ cwd: options.path, args: ["clean", "-ffdx"], signal: options.signal, timeoutMs });
497
+ }
498
+
499
+ function syntheticCommitArgs(hooksPath: string, message: string): readonly string[] {
500
+ return [
501
+ "-c",
502
+ `core.hooksPath=${hooksPath}`,
503
+ "-c",
504
+ "commit.gpgSign=false",
505
+ "commit",
506
+ "--allow-empty",
507
+ "--no-verify",
508
+ "--no-gpg-sign",
509
+ "-m",
510
+ message,
511
+ ];
512
+ }
513
+
514
+ async function requireGitCommand(options: WorktreeGitCommandOptions): Promise<void> {
515
+ const result = await runGitCommand(options);
516
+ if (!result.ok) throw new Error(result.error ?? (result.stderr.trim() || "git command failed"));
517
+ }
518
+
519
+ function parseGitTopLevel(output: string, cwd: string): string | undefined {
520
+ const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
521
+ const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
522
+ if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
523
+ const root = resolve(cwd, value);
524
+ const cwdFromRoot = relative(root, resolve(cwd));
525
+ return isAbsolute(cwdFromRoot) || cwdFromRoot === ".." || cwdFromRoot.startsWith(`..${sep}`) ? undefined : root;
526
+ }
527
+
528
+ function isExcludedSnapshotPath(sourceRoot: string, source: string): boolean {
529
+ const path = relative(sourceRoot, source);
530
+ if (path.length === 0) return false;
531
+ const segments = path.split(sep);
532
+ if (segments.includes(".git")) return true;
533
+ return segments.some((segment, index) => segment === ".pi" && segments[index + 1] === ".workflow-runs");
261
534
  }
262
535
 
263
536
  function isInvalidHeadError(message: string): boolean {
264
537
  return /invalid reference:\s*HEAD/i.test(message) || /ambiguous argument ['"]?HEAD/i.test(message) || /unknown revision or path.*HEAD/i.test(message);
265
538
  }
266
539
 
540
+ function isGitObjectId(value: string): boolean {
541
+ return /^[0-9a-f]{40,64}$/i.test(value);
542
+ }
543
+
267
544
  export const spawnGitRunner: WorktreeGitRunner = {
268
545
  async runGit(options) {
269
546
  return await runGitCommand(options);
@@ -271,7 +548,7 @@ export const spawnGitRunner: WorktreeGitRunner = {
271
548
  };
272
549
 
273
550
  async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
274
- const env = { ...process.env };
551
+ const env = { ...process.env, ...options.env };
275
552
  delete env.GIT_EXTERNAL_DIFF;
276
553
  delete env.GIT_DIFF_OPTS;
277
554
  return await runBoundedProcess({
@@ -279,6 +556,7 @@ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<Worktr
279
556
  args: options.args,
280
557
  cwd: options.cwd,
281
558
  env,
559
+ stdin: options.stdin,
282
560
  signal: options.signal,
283
561
  timeoutMs: options.timeoutMs,
284
562
  maxBufferBytes: options.maxBufferBytes,
@@ -289,6 +567,6 @@ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<Worktr
289
567
  });
290
568
  }
291
569
 
292
- function formatError(error: unknown): string {
293
- return error instanceof Error ? error.message : String(error);
570
+ function worktreeRemovalError(outcome: WorktreeRemovalOutcome): string {
571
+ return outcome.error ?? (outcome.stderr.trim() || "unknown error");
294
572
  }