pi-workflow-engine 0.10.2 → 0.11.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 (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -120
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
  10. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
  11. package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
  12. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
  13. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
  14. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
  15. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
  16. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  17. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
  18. package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
  19. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  20. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  21. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  22. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
  23. package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
  24. package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
  25. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  26. package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
  27. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  28. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
  29. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  30. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  31. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  32. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  33. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  34. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  35. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  36. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
  40. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
  41. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
  42. package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
  43. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
  44. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  47. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
  48. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
  49. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
  50. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
  51. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
  52. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
  53. package/README.md +170 -226
  54. package/USAGE.md +92 -12
  55. package/package.json +9 -9
@@ -0,0 +1,121 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ResolvedWorkflowRunOptions } from "./options.ts";
3
+ import type { PerfAggregate, PerfSink, PerfSnapshot } from "./perf.ts";
4
+ import type { WorkflowProgressSnapshot } from "./progress.ts";
5
+ import type {
6
+ LoadedWorkflow,
7
+ WorkflowProgressSource,
8
+ WorkflowRef,
9
+ WorkflowRunMetadata,
10
+ } from "./types.ts";
11
+ import type { WorkflowUsageSnapshot } from "./usage.ts";
12
+
13
+ export interface WorkflowPerfDetails {
14
+ readonly enabled: boolean;
15
+ readonly startedAt: number;
16
+ readonly aggregates: readonly PerfAggregate[];
17
+ }
18
+
19
+ export interface WorkflowResultEnvelope {
20
+ readonly name: string;
21
+ readonly result: unknown;
22
+ readonly completedAt: number;
23
+ readonly usage?: WorkflowUsageSnapshot;
24
+ readonly perf?: WorkflowPerfDetails;
25
+ readonly runId?: string;
26
+ readonly resumedFromRunId?: string;
27
+ }
28
+
29
+ export type ResolvedWorkflowRunner = (
30
+ ctx: ExtensionContext,
31
+ mod: LoadedWorkflow,
32
+ args: string,
33
+ options: ResolvedWorkflowRunOptions,
34
+ ) => Promise<unknown>;
35
+
36
+ export interface WorkflowExecutionInput {
37
+ readonly ctx: ExtensionContext;
38
+ readonly name: string;
39
+ readonly mod: LoadedWorkflow;
40
+ readonly args: string;
41
+ readonly options: ResolvedWorkflowRunOptions;
42
+ readonly perfRecorder?: PerfSink;
43
+ readonly runResolvedWorkflow: ResolvedWorkflowRunner;
44
+ readonly resolveWorkflow: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
45
+ readonly onProgressSource?: (source: WorkflowProgressSource | undefined) => void | Promise<void>;
46
+ readonly onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void | Promise<void>;
47
+ }
48
+
49
+ export interface WorkflowExecution {
50
+ readonly metadata?: WorkflowRunMetadata;
51
+ readonly envelope: WorkflowResultEnvelope;
52
+ }
53
+
54
+ /** Shared command/tool execution path. The engine remains lazy-loaded by the caller. */
55
+ export async function executeWorkflowInvocation(input: WorkflowExecutionInput): Promise<WorkflowExecution> {
56
+ let perfSnapshot: PerfSnapshot | undefined;
57
+ let usageSnapshot: WorkflowUsageSnapshot | undefined;
58
+ let runMetadata: WorkflowRunMetadata | undefined;
59
+ const { options } = input;
60
+ const runOptions: ResolvedWorkflowRunOptions = {
61
+ ...options,
62
+ perfRecorder: input.perfRecorder,
63
+ resolveWorkflow: input.resolveWorkflow,
64
+ onProgressSource(source) {
65
+ return notifyLifecycleObservers(
66
+ () => input.onProgressSource?.(source),
67
+ () => options.onProgressSource?.(source),
68
+ );
69
+ },
70
+ onPerfSnapshot(snapshot) {
71
+ perfSnapshot = snapshot;
72
+ return notifyLifecycleObservers(() => options.onPerfSnapshot?.(snapshot));
73
+ },
74
+ onUsageSnapshot(snapshot) {
75
+ usageSnapshot = snapshot;
76
+ return notifyLifecycleObservers(() => options.onUsageSnapshot?.(snapshot));
77
+ },
78
+ onRunMetadata(metadata) {
79
+ runMetadata = metadata;
80
+ return notifyLifecycleObservers(() => options.onRunMetadata?.(metadata));
81
+ },
82
+ onProgressSnapshot(snapshot) {
83
+ return notifyLifecycleObservers(
84
+ () => input.onProgressSnapshot?.(snapshot),
85
+ () => options.onProgressSnapshot?.(snapshot),
86
+ );
87
+ },
88
+ };
89
+ const result = await input.runResolvedWorkflow(input.ctx, input.mod, input.args, runOptions);
90
+ const perf = compactPerfSnapshot(perfSnapshot);
91
+ return {
92
+ metadata: runMetadata,
93
+ envelope: {
94
+ name: input.name,
95
+ result,
96
+ completedAt: Date.now(),
97
+ usage: usageSnapshot,
98
+ perf,
99
+ runId: runMetadata?.runId,
100
+ resumedFromRunId: runMetadata?.resumedFromRunId,
101
+ },
102
+ };
103
+ }
104
+
105
+ function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
106
+ if (!snapshot?.enabled) return undefined;
107
+ return { enabled: true, startedAt: snapshot.startedAt, aggregates: snapshot.aggregates };
108
+ }
109
+
110
+ async function notifyLifecycleObservers(...observers: ReadonlyArray<() => void | Promise<void>>): Promise<void> {
111
+ const failures: unknown[] = [];
112
+ for (const observer of observers) {
113
+ try {
114
+ await observer();
115
+ } catch (error) {
116
+ failures.push(error);
117
+ }
118
+ }
119
+ if (failures.length === 1) throw failures[0];
120
+ if (failures.length > 1) throw new AggregateError(failures, "Workflow lifecycle observers failed");
121
+ }
@@ -1,4 +1,10 @@
1
- import type { WorkflowMeta, WorkflowModule } from "./types.ts";
1
+ import type {
2
+ LoadedWorkflow,
3
+ WorkflowMeta,
4
+ WorkflowModule,
5
+ WorkflowSourceIdentity,
6
+ } from "./types.ts";
7
+ import type { WorktreeBaseline } from "./worktree.ts";
2
8
 
3
9
  type WorkflowMetaCandidate = {
4
10
  readonly name?: unknown;
@@ -30,6 +36,18 @@ export function parseWorkflowModule(value: unknown): { module: WorkflowModule }
30
36
  return { module: { meta: parsedMeta.meta, default: candidate.default } };
31
37
  }
32
38
 
39
+ /** Attach engine-owned source provenance after an authored module has been validated. */
40
+ export function loadWorkflow(
41
+ module: WorkflowModule,
42
+ source: WorkflowSourceIdentity,
43
+ isolatedWorktreeBaseline?: WorktreeBaseline,
44
+ ): LoadedWorkflow {
45
+ const loaded = { meta: module.meta, default: module.default, source };
46
+ return isolatedWorktreeBaseline === undefined
47
+ ? loaded
48
+ : { ...loaded, isolatedWorktreeBaseline };
49
+ }
50
+
33
51
  function parseWorkflowMetaObject(meta: WorkflowMetaCandidate): { meta: WorkflowMeta } | { reason: string } {
34
52
  if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
35
53
 
@@ -1,8 +1,9 @@
1
- import type { WorkflowModule } from "./types.ts";
1
+ import { fileURLToPath } from "node:url";
2
2
  import * as codeReview from "../workflows/code-review.ts";
3
3
  import * as refactorScout from "../workflows/refactor-scout.ts";
4
4
  import * as diagnose from "../workflows/diagnose.ts";
5
5
  import * as perfReview from "../workflows/perf-review.ts";
6
+ import type { WorkflowModule } from "./types.ts";
6
7
 
7
8
  /**
8
9
  * Workflows statically imported by the extension. These are loaded through pi's own
@@ -18,6 +19,30 @@ import * as perfReview from "../workflows/perf-review.ts";
18
19
  * dynamically imported built-ins. Startup optimizations should happen at the extension
19
20
  * entrypoint/discovery boundary first.
20
21
  */
21
- export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, refactorScout, diagnose, perfReview];
22
- export const BUILTIN_WORKFLOW_FILES = new Set(["code-review.ts", "refactor-scout.ts", "diagnose.ts", "perf-review.ts"]);
22
+ export const BUILTIN_SOURCE_ROOT = fileURLToPath(new URL("..", import.meta.url));
23
+
24
+ export interface BuiltinWorkflowDefinition {
25
+ readonly module: WorkflowModule;
26
+ readonly filename: string;
27
+ readonly path: string;
28
+ readonly root: string;
29
+ }
30
+
31
+ export const BUILTIN_WORKFLOW_DEFINITIONS: readonly BuiltinWorkflowDefinition[] = [
32
+ defineBuiltinWorkflow(codeReview, "code-review.ts"),
33
+ defineBuiltinWorkflow(refactorScout, "refactor-scout.ts"),
34
+ defineBuiltinWorkflow(diagnose, "diagnose.ts"),
35
+ defineBuiltinWorkflow(perfReview, "perf-review.ts"),
36
+ ];
37
+ export const BUILTIN_WORKFLOWS = BUILTIN_WORKFLOW_DEFINITIONS.map(({ module }) => module);
38
+ export const BUILTIN_WORKFLOW_FILES = new Set(BUILTIN_WORKFLOW_DEFINITIONS.map(({ filename }) => filename));
23
39
  export const BUILTIN_WORKFLOW_NAMES = BUILTIN_WORKFLOWS.map((mod) => mod.meta.name);
40
+
41
+ function defineBuiltinWorkflow(module: WorkflowModule, filename: string): BuiltinWorkflowDefinition {
42
+ return {
43
+ module: { meta: module.meta, default: module.default },
44
+ filename,
45
+ path: fileURLToPath(new URL(`../workflows/${filename}`, import.meta.url)),
46
+ root: BUILTIN_SOURCE_ROOT,
47
+ };
48
+ }
@@ -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,101 @@ 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
269
  const snapshotError = await createUnbornRepoSnapshot({ repoCwd: options.repoCwd, path, timeoutMs: options.timeoutMs, signal: options.signal })
180
270
  .then(() => undefined)
181
- .catch((error: unknown) => formatError(error));
271
+ .catch((error: unknown) => unknownErrorMessage(error));
182
272
  if (snapshotError !== undefined) return { path, snapshot: true, error: `git worktree add failed (${message}); unborn-repo snapshot fallback failed: ${snapshotError}` };
183
- return { path, snapshot: true };
273
+ return await finalizeWorktreeRef({ path, snapshot: true, runner, signal: options.signal, timeoutMs });
274
+ }
275
+
276
+ async function finalizeWorktreeRef(options: {
277
+ readonly path: string;
278
+ readonly snapshot?: boolean;
279
+ readonly runner: WorktreeGitRunner;
280
+ readonly signal?: AbortSignal;
281
+ readonly timeoutMs: number;
282
+ }): Promise<WorktreeRef | WorktreeAddFailure> {
283
+ const resolved = await options.runner
284
+ .runGit({
285
+ cwd: options.path,
286
+ args: ["rev-parse", "--verify", "HEAD^{commit}"],
287
+ signal: options.signal,
288
+ timeoutMs: options.timeoutMs,
289
+ })
290
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
291
+ if (!resolved.ok) {
292
+ return {
293
+ path: options.path,
294
+ snapshot: options.snapshot,
295
+ error: resolved.error ?? (resolved.stderr.trim() || "failed to resolve isolated worktree baseline"),
296
+ };
297
+ }
298
+ const baselineOid = resolved.stdout.trim();
299
+ if (!isGitObjectId(baselineOid)) {
300
+ return { path: options.path, snapshot: options.snapshot, error: "isolated worktree baseline is not a commit OID" };
301
+ }
302
+ return { path: options.path, baselineOid, snapshot: options.snapshot };
303
+ }
304
+
305
+ async function prepareWorktreeBaseline(options: {
306
+ readonly path: string;
307
+ readonly patch: string;
308
+ readonly runner: WorktreeGitRunner;
309
+ readonly signal?: AbortSignal;
310
+ readonly timeoutMs: number;
311
+ }): Promise<WorktreeGitCommandResult> {
312
+ const applied = await options.runner.runGit({
313
+ cwd: options.path,
314
+ args: ["apply", "--index", "--binary", "--whitespace=nowarn", "-"],
315
+ stdin: options.patch,
316
+ signal: options.signal,
317
+ timeoutMs: options.timeoutMs,
318
+ });
319
+ if (!applied.ok) return applied;
320
+ const hooksPath = await mkdtemp(join(tmpdir(), "pi-workflow-empty-hooks-"));
321
+ try {
322
+ return await options.runner.runGit({
323
+ cwd: options.path,
324
+ args: syntheticCommitArgs(hooksPath, "pi workflow reviewed snapshot"),
325
+ env: SYNTHETIC_COMMIT_ENV,
326
+ signal: options.signal,
327
+ timeoutMs: options.timeoutMs,
328
+ });
329
+ } finally {
330
+ await rm(hooksPath, { recursive: true, force: true });
331
+ }
184
332
  }
185
333
 
186
334
  export async function removeWorktree(options: {
@@ -203,10 +351,12 @@ export async function removeWorktree(options: {
203
351
 
204
352
  export async function captureWorktreePatch(options: {
205
353
  readonly worktreePath: string;
354
+ readonly baselineOid: string;
206
355
  readonly runner?: WorktreeGitRunner;
207
356
  readonly signal?: AbortSignal;
208
357
  readonly timeoutMs?: number;
209
358
  }): Promise<WorktreePatch | { readonly error: string }> {
359
+ if (!isGitObjectId(options.baselineOid)) return { error: "worktree patch baseline is not a commit OID" };
210
360
  const runner = options.runner ?? spawnGitRunner;
211
361
  const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
212
362
  const intentToAdd = await runner
@@ -216,54 +366,144 @@ export async function captureWorktreePatch(options: {
216
366
  signal: options.signal,
217
367
  timeoutMs,
218
368
  })
219
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
369
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
220
370
  if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
221
371
 
222
372
  const diff = await runner
223
373
  .runGit({
224
374
  cwd: options.worktreePath,
225
- args: ["diff", "--no-color", "HEAD"],
375
+ args: ["diff", "--binary", "--full-index", "--no-ext-diff", "--no-color", options.baselineOid, "--"],
226
376
  signal: options.signal,
227
377
  timeoutMs,
228
378
  maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
229
379
  })
230
- .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
380
+ .catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: unknownErrorMessage(error) }));
231
381
  if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
232
382
  return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
233
383
  }
234
384
 
385
+ export async function validateWorktreePatch(options: {
386
+ readonly worktreePath: string;
387
+ readonly candidate: WorktreePatch;
388
+ readonly runner?: WorktreeGitRunner;
389
+ readonly signal?: AbortSignal;
390
+ readonly timeoutMs?: number;
391
+ }): Promise<WorktreeGitCommandResult> {
392
+ const hasPatch = options.candidate.patch.trim().length > 0;
393
+ if (options.candidate.changed !== hasPatch) {
394
+ return invalidPatchResult("cached worktree patch changed flag does not match patch content");
395
+ }
396
+ if (!hasPatch) return { ok: true, stdout: "", stderr: "" };
397
+
398
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
399
+ return await (options.runner ?? spawnGitRunner)
400
+ .runGit({
401
+ cwd: options.worktreePath,
402
+ args: ["apply", "--check", "--binary", "-"],
403
+ stdin: options.candidate.patch,
404
+ signal: options.signal,
405
+ timeoutMs,
406
+ })
407
+ .catch((error: unknown) => invalidPatchResult(unknownErrorMessage(error)));
408
+ }
409
+
410
+ function invalidPatchResult(error: string): WorktreeGitCommandResult {
411
+ return { ok: false, stdout: "", stderr: "", error };
412
+ }
413
+
235
414
  async function createUnbornRepoSnapshot(options: {
236
415
  readonly repoCwd: string;
237
416
  readonly path: string;
238
417
  readonly signal?: AbortSignal;
239
418
  readonly timeoutMs?: number;
240
419
  }): Promise<void> {
420
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
421
+ const rootProbe = await runGitCommand({
422
+ cwd: options.repoCwd,
423
+ args: ["rev-parse", "--show-toplevel"],
424
+ signal: options.signal,
425
+ timeoutMs,
426
+ });
427
+ if (!rootProbe.ok) {
428
+ throw new Error(rootProbe.error ?? (rootProbe.stderr.trim() || "failed to resolve unborn repository root"));
429
+ }
430
+ const sourceRoot = parseGitTopLevel(rootProbe.stdout, options.repoCwd);
431
+ if (!sourceRoot) throw new Error("unborn repository root probe returned an invalid path");
432
+
241
433
  const targetRoot = resolve(options.path);
242
434
  await rm(options.path, { recursive: true, force: true });
243
435
  await mkdir(options.path, { recursive: true });
244
- await cp(options.repoCwd, options.path, {
436
+ await cp(sourceRoot, options.path, {
245
437
  recursive: true,
246
438
  force: true,
247
439
  filter: (source) => {
248
440
  const resolvedSource = resolve(source);
249
441
  if (resolvedSource === targetRoot || resolvedSource.startsWith(`${targetRoot}${sep}`)) return false;
250
- return !resolvedSource.split(/[\\/]/).includes(".git");
442
+ return !isExcludedSnapshotPath(sourceRoot, resolvedSource);
251
443
  },
252
444
  });
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
- });
445
+ await requireGitCommand({ cwd: options.path, args: ["init"], signal: options.signal, timeoutMs });
446
+ await requireGitCommand({ cwd: options.path, args: ["add", "-A"], signal: options.signal, timeoutMs });
447
+ const hooksPath = await mkdtemp(join(tmpdir(), "pi-workflow-empty-hooks-"));
448
+ try {
449
+ await requireGitCommand({
450
+ cwd: options.path,
451
+ args: syntheticCommitArgs(hooksPath, "pi workflow baseline"),
452
+ env: SYNTHETIC_COMMIT_ENV,
453
+ signal: options.signal,
454
+ timeoutMs,
455
+ });
456
+ } finally {
457
+ await rm(hooksPath, { recursive: true, force: true });
458
+ }
459
+ await requireGitCommand({ cwd: options.path, args: ["clean", "-ffdx"], signal: options.signal, timeoutMs });
460
+ }
461
+
462
+ function syntheticCommitArgs(hooksPath: string, message: string): readonly string[] {
463
+ return [
464
+ "-c",
465
+ `core.hooksPath=${hooksPath}`,
466
+ "-c",
467
+ "commit.gpgSign=false",
468
+ "commit",
469
+ "--allow-empty",
470
+ "--no-verify",
471
+ "--no-gpg-sign",
472
+ "-m",
473
+ message,
474
+ ];
475
+ }
476
+
477
+ async function requireGitCommand(options: WorktreeGitCommandOptions): Promise<void> {
478
+ const result = await runGitCommand(options);
479
+ if (!result.ok) throw new Error(result.error ?? (result.stderr.trim() || "git command failed"));
480
+ }
481
+
482
+ function parseGitTopLevel(output: string, cwd: string): string | undefined {
483
+ const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
484
+ const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
485
+ if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
486
+ const root = resolve(cwd, value);
487
+ const cwdFromRoot = relative(root, resolve(cwd));
488
+ return isAbsolute(cwdFromRoot) || cwdFromRoot === ".." || cwdFromRoot.startsWith(`..${sep}`) ? undefined : root;
489
+ }
490
+
491
+ function isExcludedSnapshotPath(sourceRoot: string, source: string): boolean {
492
+ const path = relative(sourceRoot, source);
493
+ if (path.length === 0) return false;
494
+ const segments = path.split(sep);
495
+ if (segments.includes(".git")) return true;
496
+ return segments.some((segment, index) => segment === ".pi" && segments[index + 1] === ".workflow-runs");
261
497
  }
262
498
 
263
499
  function isInvalidHeadError(message: string): boolean {
264
500
  return /invalid reference:\s*HEAD/i.test(message) || /ambiguous argument ['"]?HEAD/i.test(message) || /unknown revision or path.*HEAD/i.test(message);
265
501
  }
266
502
 
503
+ function isGitObjectId(value: string): boolean {
504
+ return /^[0-9a-f]{40,64}$/i.test(value);
505
+ }
506
+
267
507
  export const spawnGitRunner: WorktreeGitRunner = {
268
508
  async runGit(options) {
269
509
  return await runGitCommand(options);
@@ -271,7 +511,7 @@ export const spawnGitRunner: WorktreeGitRunner = {
271
511
  };
272
512
 
273
513
  async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
274
- const env = { ...process.env };
514
+ const env = { ...process.env, ...options.env };
275
515
  delete env.GIT_EXTERNAL_DIFF;
276
516
  delete env.GIT_DIFF_OPTS;
277
517
  return await runBoundedProcess({
@@ -279,6 +519,7 @@ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<Worktr
279
519
  args: options.args,
280
520
  cwd: options.cwd,
281
521
  env,
522
+ stdin: options.stdin,
282
523
  signal: options.signal,
283
524
  timeoutMs: options.timeoutMs,
284
525
  maxBufferBytes: options.maxBufferBytes,
@@ -289,6 +530,6 @@ async function runGitCommand(options: WorktreeGitCommandOptions): Promise<Worktr
289
530
  });
290
531
  }
291
532
 
292
- function formatError(error: unknown): string {
293
- return error instanceof Error ? error.message : String(error);
533
+ function worktreeRemovalError(outcome: WorktreeRemovalOutcome): string {
534
+ return outcome.error ?? (outcome.stderr.trim() || "unknown error");
294
535
  }