@jameslovespancakes/pi-plus 1.0.11 → 1.0.12

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