pi-workflow-engine 0.12.0 → 1.0.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 (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +90 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -8,6 +8,7 @@ import {
8
8
  transitionWorkflowRun,
9
9
  updateWorkflowRunProgress,
10
10
  type WorkflowRunRecord,
11
+ type WorkflowRunState,
11
12
  type WorkflowRunTransition,
12
13
  } from "./workflow-run-record.ts";
13
14
 
@@ -37,8 +38,9 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
37
38
  try {
38
39
  await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
39
40
  await rename(temporaryPath, path);
40
- } finally {
41
+ } catch (error) {
41
42
  await rm(temporaryPath, { force: true }).catch(() => undefined);
43
+ throw error;
42
44
  }
43
45
  }
44
46
 
@@ -47,43 +49,22 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
47
49
  }
48
50
 
49
51
  async list(): Promise<WorkflowRunRecord[]> {
50
- const dir = join(this.cwd, WORKFLOW_RUNS_DIR);
51
- let files: string[];
52
- try {
53
- files = await readdir(dir);
54
- } catch {
55
- return [];
56
- }
57
- const records = await Promise.all(
58
- files
59
- .filter((file) => file.endsWith(WORKFLOW_RUN_RECORD_SUFFIX))
60
- .map((file) => loadWorkflowRunRecord(join(dir, file))),
61
- );
52
+ const records = await Promise.all((await this.recordPaths()).map(loadWorkflowRunRecord));
62
53
  return records
63
54
  .filter((record): record is WorkflowRunRecord => record !== undefined)
64
55
  .sort((left, right) => right.updatedAt - left.updatedAt);
65
56
  }
66
57
 
67
58
  async prune(keep = WORKFLOW_RUN_RECORD_KEEP): Promise<void> {
68
- const dir = join(this.cwd, WORKFLOW_RUNS_DIR);
69
- let files: string[];
70
- try {
71
- files = await readdir(dir);
72
- } catch {
73
- return;
74
- }
75
59
  const candidates = await Promise.all(
76
- files
77
- .filter((file) => file.endsWith(WORKFLOW_RUN_RECORD_SUFFIX))
78
- .map(async (file) => {
79
- const path = join(dir, file);
80
- try {
81
- const info = await stat(path);
82
- return { path, mtimeMs: info.mtimeMs };
83
- } catch {
84
- return undefined;
85
- }
86
- }),
60
+ (await this.recordPaths()).map(async (path) => {
61
+ try {
62
+ const info = await stat(path);
63
+ return { path, mtimeMs: info.mtimeMs };
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }),
87
68
  );
88
69
  const stale = candidates
89
70
  .filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
@@ -91,6 +72,17 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
91
72
  .slice(Math.max(0, keep));
92
73
  await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
93
74
  }
75
+
76
+ /** Record file paths in the runs directory; empty when the directory cannot be read. */
77
+ private async recordPaths(): Promise<string[]> {
78
+ const dir = join(this.cwd, WORKFLOW_RUNS_DIR);
79
+ try {
80
+ const files = await readdir(dir);
81
+ return files.filter((file) => file.endsWith(WORKFLOW_RUN_RECORD_SUFFIX)).map((file) => join(dir, file));
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
94
86
  }
95
87
 
96
88
  /** Owns one run record and coalesces progress writes while a prior atomic save is in flight. */
@@ -110,6 +102,10 @@ export class DurableWorkflowRun {
110
102
  this.queue(initial);
111
103
  }
112
104
 
105
+ get state(): WorkflowRunState {
106
+ return this.record.state;
107
+ }
108
+
113
109
  updateProgress(progress: WorkflowProgressSnapshot, at = Date.now()): void {
114
110
  this.record = updateWorkflowRunProgress(this.record, progress, at);
115
111
  this.queue(this.record);
@@ -13,7 +13,7 @@ interface ScheduledUsageLimitResume {
13
13
  readonly handle: unknown;
14
14
  }
15
15
 
16
- export const defaultWorkflowUsageLimitSchedulerClock: WorkflowUsageLimitSchedulerClock = {
16
+ const defaultWorkflowUsageLimitSchedulerClock: WorkflowUsageLimitSchedulerClock = {
17
17
  now: () => Date.now(),
18
18
  setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
19
19
  clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
@@ -73,8 +73,4 @@ export class WorkflowUsageLimitScheduler {
73
73
  activateSession(ctx: Pick<ExtensionContext, "sessionManager">): void {
74
74
  this.inactiveSessions.delete(ctx.sessionManager.getSessionId());
75
75
  }
76
-
77
- has(runId: string): boolean {
78
- return this.scheduled.has(runId);
79
- }
80
76
  }
@@ -12,8 +12,9 @@ import type { WorkflowModule } from "./types.ts";
12
12
  * pi's bundled `typebox` / SDK — guaranteeing schema validation works.
13
13
  *
14
14
  * To add a guaranteed workflow: drop a `.ts` in `workflows/`, import it here, and add
15
- * it to this array. (Drop-in workflows discovered at runtime are also supported — see
16
- * discovery.ts — but the static registry is the always-correct path.)
15
+ * a `defineBuiltinWorkflow(mod, "<name>.ts")` entry to `BUILTIN_WORKFLOW_DEFINITIONS`.
16
+ * (Drop-in workflows discovered at runtime are also supported — see discovery.ts — but
17
+ * the static registry is the always-correct path.)
17
18
  *
18
19
  * Do not replace these built-in imports with dynamic imports unless a dedicated test
19
20
  * proves pi's jiti virtual modules preserve the same bundled `typebox` identity for
@@ -26,7 +27,6 @@ export interface BuiltinWorkflowDefinition {
26
27
  readonly module: WorkflowModule;
27
28
  readonly filename: string;
28
29
  readonly path: string;
29
- readonly root: string;
30
30
  }
31
31
 
32
32
  export const BUILTIN_WORKFLOW_DEFINITIONS: readonly BuiltinWorkflowDefinition[] = [
@@ -36,15 +36,12 @@ export const BUILTIN_WORKFLOW_DEFINITIONS: readonly BuiltinWorkflowDefinition[]
36
36
  defineBuiltinWorkflow(perfReview, "perf-review.ts"),
37
37
  defineBuiltinWorkflow(research, "research.ts"),
38
38
  ];
39
- export const BUILTIN_WORKFLOWS = BUILTIN_WORKFLOW_DEFINITIONS.map(({ module }) => module);
40
39
  export const BUILTIN_WORKFLOW_FILES = new Set(BUILTIN_WORKFLOW_DEFINITIONS.map(({ filename }) => filename));
41
- export const BUILTIN_WORKFLOW_NAMES = BUILTIN_WORKFLOWS.map((mod) => mod.meta.name);
42
40
 
43
41
  function defineBuiltinWorkflow(module: WorkflowModule, filename: string): BuiltinWorkflowDefinition {
44
42
  return {
45
43
  module: { meta: module.meta, default: module.default },
46
44
  filename,
47
45
  path: fileURLToPath(new URL(`../workflows/${filename}`, import.meta.url)),
48
- root: BUILTIN_SOURCE_ROOT,
49
46
  };
50
47
  }
@@ -1,26 +1,18 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { tmpdir } from "node:os";
3
+ import { existsSync } from "node:fs";
3
4
  import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
4
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { runBoundedProcess } from "./process-runner.ts";
5
+ import { join, resolve, sep } from "node:path";
6
+ import { isGitObjectId, parseGitTopLevel, runGit, type GitCommandOptions } from "./git.ts";
7
+ import { GIT_DIFF_MACHINE_FORMAT } from "./review-diff-target.ts";
8
+ import { FINGERPRINT_EXCLUDED_RELATIVE_PATHS, isExcludedDeclaredInput, portableRelativePath } from "./tree-fingerprint.ts";
6
9
  import { unknownErrorMessage } from "./unknown-error.ts";
7
10
 
8
- export interface WorktreeGitCommandOptions {
9
- readonly cwd: string;
10
- readonly args: readonly string[];
11
- readonly env?: NodeJS.ProcessEnv;
12
- readonly stdin?: string;
13
- readonly signal?: AbortSignal;
14
- readonly timeoutMs: number;
15
- readonly maxBufferBytes?: number;
16
- }
11
+ export type WorktreeGitCommandOptions = Omit<GitCommandOptions, "file" | "label">;
17
12
 
18
- export interface WorktreeGitCommandResult {
19
- readonly ok: boolean;
20
- readonly stdout: string;
21
- readonly stderr: string;
22
- readonly error?: string;
23
- }
13
+ export type WorktreeGitCommandResult =
14
+ | { readonly ok: true; readonly stdout: string; readonly stderr: string }
15
+ | { readonly ok: false; readonly stdout: string; readonly stderr: string; readonly error: string };
24
16
 
25
17
  export interface WorktreeGitRunner {
26
18
  runGit(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult>;
@@ -46,24 +38,18 @@ export interface WorktreeAddFailure {
46
38
  readonly cleanup?: WorktreeGitCommandResult;
47
39
  }
48
40
 
49
- export interface GitWorktreeProbe {
50
- readonly ok: boolean;
51
- readonly inside: boolean;
52
- readonly error?: string;
53
- }
41
+ export type GitWorktreeProbe = { readonly ok: true; readonly inside: boolean } | { readonly ok: false; readonly error: string };
54
42
 
55
- export interface WorktreeRemovalOutcome extends WorktreeGitCommandResult {
56
- readonly path: string;
57
- }
43
+ export type WorktreeRemovalOutcome = WorktreeGitCommandResult & { readonly path: string };
58
44
 
59
45
  export class WorktreeCleanupError extends AggregateError {
60
46
  readonly outcomes: readonly WorktreeRemovalOutcome[];
61
47
 
62
48
  constructor(outcomes: readonly WorktreeRemovalOutcome[]) {
63
49
  const failures = outcomes.filter((outcome) => !outcome.ok);
64
- const details = failures.map((failure) => `${failure.path} (${worktreeRemovalError(failure)})`);
50
+ const details = failures.map((failure) => `${failure.path} (${failure.error})`);
65
51
  super(
66
- failures.map((failure) => new Error(`Failed to remove isolated worktree ${failure.path}: ${worktreeRemovalError(failure)}`)),
52
+ failures.map((failure) => new Error(`Failed to remove isolated worktree ${failure.path}: ${failure.error}`)),
67
53
  `Failed to remove ${failures.length} isolated worktree${failures.length === 1 ? "" : "s"}: ${details.join(", ")}`,
68
54
  );
69
55
  this.name = "WorktreeCleanupError";
@@ -77,7 +63,6 @@ export interface WorktreePatch {
77
63
  }
78
64
 
79
65
  export interface WorktreeRegistryOptions {
80
- readonly repoCwd: string;
81
66
  readonly runner?: WorktreeGitRunner;
82
67
  readonly patchCapture?: WorktreePatchCapture;
83
68
  readonly timeoutMs?: number;
@@ -111,7 +96,7 @@ export class WorktreeRegistry {
111
96
  private readonly patchCapture: WorktreePatchCapture;
112
97
  private readonly timeoutMs: number;
113
98
 
114
- constructor(private readonly repoCwd: string, options: Omit<WorktreeRegistryOptions, "repoCwd"> = {}) {
99
+ constructor(private readonly repoCwd: string, options: WorktreeRegistryOptions = {}) {
115
100
  this.runner = options.runner ?? spawnGitRunner;
116
101
  this.patchCapture = options.patchCapture ?? captureWorktreePatch;
117
102
  this.timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
@@ -131,6 +116,8 @@ export class WorktreeRegistry {
131
116
 
132
117
  async add(signal?: AbortSignal, baseline?: WorktreeBaseline): Promise<WorktreeRef | WorktreeAddFailure> {
133
118
  const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs, baseline });
119
+ // A rejected `git worktree add` creates nothing; tracking it would make cleanup fail on a path git never knew.
120
+ if ("error" in added && !existsSync(added.path)) return added;
134
121
  this.register(added.path);
135
122
  if (added.snapshot === true) this.snapshots.add(added.path);
136
123
  if ("error" in added) {
@@ -154,6 +141,13 @@ export class WorktreeRegistry {
154
141
  });
155
142
  }
156
143
 
144
+ async applyPatch(path: string, patch: string, signal?: AbortSignal): Promise<WorktreeGitCommandResult> {
145
+ return this.runner.runGit({
146
+ cwd: path, args: ["apply", "--index", "--binary", "-"],
147
+ stdin: patch, signal, timeoutMs: this.timeoutMs,
148
+ });
149
+ }
150
+
157
151
  async validatePatch(path: string, candidate: WorktreePatch, signal?: AbortSignal): Promise<WorktreeGitCommandResult> {
158
152
  return await validateWorktreePatch({
159
153
  worktreePath: path,
@@ -191,12 +185,7 @@ export class WorktreeRegistry {
191
185
  runner: this.runner,
192
186
  timeoutMs: this.timeoutMs,
193
187
  snapshot: this.snapshots.has(path),
194
- }).catch((error: unknown) => ({
195
- ok: false,
196
- stdout: "",
197
- stderr: "",
198
- error: unknownErrorMessage(error),
199
- }));
188
+ }).catch(toGitFailure);
200
189
  if (result.ok) {
201
190
  this.paths.delete(path);
202
191
  this.snapshots.delete(path);
@@ -223,8 +212,8 @@ export async function isGitWorktree(options: {
223
212
  signal: options.signal,
224
213
  timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
225
214
  })
226
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
227
- if (!result.ok) return { ok: false, inside: false, error: result.error ?? (result.stderr.trim() || "git worktree probe failed") };
215
+ .catch(toGitFailure);
216
+ if (!result.ok) return { ok: false, error: result.error };
228
217
  return { ok: true, inside: result.stdout.trim() === "true" };
229
218
  }
230
219
 
@@ -247,7 +236,7 @@ export async function addWorktree(options: {
247
236
  signal: options.signal,
248
237
  timeoutMs,
249
238
  })
250
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
239
+ .catch(toGitFailure);
251
240
  if (result.ok) {
252
241
  const patch = options.baseline?.patch;
253
242
  if (patch !== undefined && patch.trim().length > 0) {
@@ -257,22 +246,20 @@ export async function addWorktree(options: {
257
246
  runner,
258
247
  signal: options.signal,
259
248
  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") };
249
+ }).catch(toGitFailure);
250
+ if (!prepared.ok) return { path, error: prepared.error };
262
251
  }
263
252
  return await finalizeWorktreeRef({ path, runner, signal: options.signal, timeoutMs });
264
253
  }
265
254
 
266
- const message = result.error ?? (result.stderr.trim() || "git worktree add failed");
267
- if (!isInvalidHeadError(message)) return { path, error: message };
255
+ if (!isInvalidHeadError(result.error)) return { path, error: result.error };
268
256
 
269
257
  return await addUnbornRepositoryWorktree({
270
258
  repoCwd: options.repoCwd,
271
259
  path,
272
- addError: message,
260
+ addError: result.error,
273
261
  runner,
274
262
  signal: options.signal,
275
- snapshotTimeoutMs: options.timeoutMs,
276
263
  timeoutMs,
277
264
  });
278
265
  }
@@ -283,13 +270,12 @@ async function addUnbornRepositoryWorktree(options: {
283
270
  readonly addError: string;
284
271
  readonly runner: WorktreeGitRunner;
285
272
  readonly signal?: AbortSignal;
286
- readonly snapshotTimeoutMs?: number;
287
273
  readonly timeoutMs: number;
288
274
  }): Promise<WorktreeRef | WorktreeAddFailure> {
289
275
  const snapshotError = await createUnbornRepoSnapshot({
290
276
  repoCwd: options.repoCwd,
291
277
  path: options.path,
292
- timeoutMs: options.snapshotTimeoutMs,
278
+ timeoutMs: options.timeoutMs,
293
279
  signal: options.signal,
294
280
  })
295
281
  .then(() => undefined)
@@ -324,12 +310,12 @@ async function finalizeWorktreeRef(options: {
324
310
  signal: options.signal,
325
311
  timeoutMs: options.timeoutMs,
326
312
  })
327
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
313
+ .catch(toGitFailure);
328
314
  if (!resolved.ok) {
329
315
  return {
330
316
  path: options.path,
331
317
  snapshot: options.snapshot,
332
- error: resolved.error ?? (resolved.stderr.trim() || "failed to resolve isolated worktree baseline"),
318
+ error: resolved.error,
333
319
  };
334
320
  }
335
321
  const baselineOid = resolved.stdout.trim();
@@ -403,19 +389,19 @@ export async function captureWorktreePatch(options: {
403
389
  signal: options.signal,
404
390
  timeoutMs,
405
391
  })
406
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
407
- if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
392
+ .catch(toGitFailure);
393
+ if (!intentToAdd.ok) return { error: intentToAdd.error };
408
394
 
409
395
  const diff = await runner
410
396
  .runGit({
411
397
  cwd: options.worktreePath,
412
- args: ["diff", "--binary", "--full-index", "--no-ext-diff", "--no-color", options.baselineOid, "--"],
398
+ args: ["diff", "--binary", "--full-index", "--no-ext-diff", ...GIT_DIFF_MACHINE_FORMAT, options.baselineOid, "--"],
413
399
  signal: options.signal,
414
400
  timeoutMs,
415
401
  maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
416
402
  })
417
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
418
- if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
403
+ .catch(toGitFailure);
404
+ if (!diff.ok) return { error: diff.error };
419
405
  return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
420
406
  }
421
407
 
@@ -428,7 +414,7 @@ export async function validateWorktreePatch(options: {
428
414
  }): Promise<WorktreeGitCommandResult> {
429
415
  const hasPatch = options.candidate.patch.trim().length > 0;
430
416
  if (options.candidate.changed !== hasPatch) {
431
- return invalidPatchResult("cached worktree patch changed flag does not match patch content");
417
+ return gitFailure("cached worktree patch changed flag does not match patch content");
432
418
  }
433
419
  if (!hasPatch) return { ok: true, stdout: "", stderr: "" };
434
420
 
@@ -441,29 +427,31 @@ export async function validateWorktreePatch(options: {
441
427
  signal: options.signal,
442
428
  timeoutMs,
443
429
  })
444
- .catch((error: unknown) => invalidPatchResult(unknownErrorMessage(error)));
430
+ .catch(toGitFailure);
445
431
  }
446
432
 
447
- function invalidPatchResult(error: string): WorktreeGitCommandResult {
433
+ function gitFailure(error: string): WorktreeGitCommandResult {
448
434
  return { ok: false, stdout: "", stderr: "", error };
449
435
  }
450
436
 
437
+ function toGitFailure(error: unknown): WorktreeGitCommandResult {
438
+ return gitFailure(unknownErrorMessage(error));
439
+ }
440
+
451
441
  async function createUnbornRepoSnapshot(options: {
452
442
  readonly repoCwd: string;
453
443
  readonly path: string;
454
444
  readonly signal?: AbortSignal;
455
- readonly timeoutMs?: number;
445
+ readonly timeoutMs: number;
456
446
  }): Promise<void> {
457
- const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
447
+ const { timeoutMs } = options;
458
448
  const rootProbe = await runGitCommand({
459
449
  cwd: options.repoCwd,
460
450
  args: ["rev-parse", "--show-toplevel"],
461
451
  signal: options.signal,
462
452
  timeoutMs,
463
453
  });
464
- if (!rootProbe.ok) {
465
- throw new Error(rootProbe.error ?? (rootProbe.stderr.trim() || "failed to resolve unborn repository root"));
466
- }
454
+ if (!rootProbe.ok) throw new Error(rootProbe.error);
467
455
  const sourceRoot = parseGitTopLevel(rootProbe.stdout, options.repoCwd);
468
456
  if (!sourceRoot) throw new Error("unborn repository root probe returned an invalid path");
469
457
 
@@ -473,10 +461,12 @@ async function createUnbornRepoSnapshot(options: {
473
461
  await cp(sourceRoot, options.path, {
474
462
  recursive: true,
475
463
  force: true,
464
+ // Keep link targets as stored, like a git checkout; resolved targets would point into the source repo.
465
+ verbatimSymlinks: true,
476
466
  filter: (source) => {
477
467
  const resolvedSource = resolve(source);
478
468
  if (resolvedSource === targetRoot || resolvedSource.startsWith(`${targetRoot}${sep}`)) return false;
479
- return !isExcludedSnapshotPath(sourceRoot, resolvedSource);
469
+ return !isExcludedDeclaredInput(portableRelativePath(sourceRoot, resolvedSource), FINGERPRINT_EXCLUDED_RELATIVE_PATHS);
480
470
  },
481
471
  });
482
472
  await requireGitCommand({ cwd: options.path, args: ["init"], signal: options.signal, timeoutMs });
@@ -513,60 +503,15 @@ function syntheticCommitArgs(hooksPath: string, message: string): readonly strin
513
503
 
514
504
  async function requireGitCommand(options: WorktreeGitCommandOptions): Promise<void> {
515
505
  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");
506
+ if (!result.ok) throw new Error(result.error);
534
507
  }
535
508
 
536
509
  function isInvalidHeadError(message: string): boolean {
537
510
  return /invalid reference:\s*HEAD/i.test(message) || /ambiguous argument ['"]?HEAD/i.test(message) || /unknown revision or path.*HEAD/i.test(message);
538
511
  }
539
512
 
540
- function isGitObjectId(value: string): boolean {
541
- return /^[0-9a-f]{40,64}$/i.test(value);
542
- }
543
-
544
- export const spawnGitRunner: WorktreeGitRunner = {
545
- async runGit(options) {
546
- return await runGitCommand(options);
547
- },
548
- };
513
+ export const spawnGitRunner: WorktreeGitRunner = { runGit: runGitCommand };
549
514
 
550
515
  async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
551
- const env = { ...process.env, ...options.env };
552
- delete env.GIT_EXTERNAL_DIFF;
553
- delete env.GIT_DIFF_OPTS;
554
- return await runBoundedProcess({
555
- file: "git",
556
- args: options.args,
557
- cwd: options.cwd,
558
- env,
559
- stdin: options.stdin,
560
- signal: options.signal,
561
- timeoutMs: options.timeoutMs,
562
- maxBufferBytes: options.maxBufferBytes,
563
- abortError: "git worktree command aborted",
564
- timeoutError: `git worktree command timed out after ${options.timeoutMs}ms`,
565
- maxBufferError: options.maxBufferBytes === undefined ? undefined : `git worktree command exceeded ${options.maxBufferBytes} bytes`,
566
- exitError: (stderr, code, signal) => stderr.trim() || `git exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
567
- });
568
- }
569
-
570
- function worktreeRemovalError(outcome: WorktreeRemovalOutcome): string {
571
- return outcome.error ?? (outcome.stderr.trim() || "unknown error");
516
+ return await runGit({ ...options, label: "git worktree command" });
572
517
  }