pi-crew 0.6.0 → 0.6.3

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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. package/test-integration-check.ts +4 -0
@@ -25,11 +25,16 @@ export interface WorktreeDiffStat {
25
25
  }
26
26
 
27
27
  function git(cwd: string, args: string[]): string {
28
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true }).trim();
28
+ // SECURITY: PI_* and PI_CREW_* wildcards removed they could match secret vars like PI_PASSWORD.
29
+ // Git operations do not need PI_CREW_* execution-control vars.
30
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }, windowsHide: true }).trim();
29
31
  }
30
32
 
33
+ // Dots are removed from branch names since they are used in path construction,
34
+ // and dots could cause ambiguity with relative path handling on some platforms.
35
+ // Branch names themselves support dots in git, but we strip them for safe path use.
31
36
  function sanitizeBranchPart(value: string): string {
32
- return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
37
+ return value.toLowerCase().replace(/[^a-z0-9_/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
33
38
  }
34
39
 
35
40
  export function findGitRoot(cwd: string): string {
@@ -64,7 +69,7 @@ function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boole
64
69
  function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
65
70
  const resolved = path.resolve(worktreePath, rawPath);
66
71
  const relative = path.relative(worktreePath, resolved);
67
- if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`synthetic path escapes worktree: ${rawPath}`);
72
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`synthetic path escapes worktree: ${rawPath}`);
68
73
  return path.normalize(relative);
69
74
  }
70
75
 
@@ -118,6 +123,12 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
118
123
  }
119
124
  // SECURITY WARNING: Home directory hooks (~/.pi/hooks/) are user-writable and not project-scoped.
120
125
  // A rogue npm postinstall script could place malicious hooks there. Log for visibility.
126
+ //
127
+ // SECURITY ASSUMPTION: This function trusts that the hook scripts themselves are not malicious.
128
+ // Hook scripts are executed with the same privileges as the Pi process. The caller is responsible
129
+ // for ensuring that only trusted hook scripts are configured. Path containment validation
130
+ // (isAllowedSetupHook, isHookPathContainedInRepoRoot) prevents hook scripts from writing outside
131
+ // the worktree, but cannot prevent a trusted hook from performing harmful operations within it.
121
132
  if (path.isAbsolute(rawHookPath)) {
122
133
  logInternalError("worktree.setupHook.homeHook", new Error("Home directory hook used — ensure ~/.pi/hooks/ is trusted"), `hookPath=${rawHookPath}`);
123
134
  }
@@ -128,8 +139,14 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
128
139
  logInternalError("worktree.setupHook.contained", new Error("hook path escapes repoRoot after realpath resolution: " + hookPath), `repoRoot=${repoRoot}`);
129
140
  return [];
130
141
  }
131
- if (!fs.existsSync(hookPath) || fs.statSync(hookPath).isDirectory()) {
132
- logInternalError("worktree.setupHook.missing", new Error("hook not found or is directory: " + hookPath), `cwd=${manifest.cwd}`);
142
+ try {
143
+ const hookStat = fs.lstatSync(hookPath);
144
+ if (!hookStat.isFile()) {
145
+ logInternalError("worktree.setupHook.missing", new Error("hook not found or is directory: " + hookPath), `cwd=${manifest.cwd}`);
146
+ return [];
147
+ }
148
+ } catch {
149
+ logInternalError("worktree.setupHook.missing", new Error("hook not found: " + hookPath), `cwd=${manifest.cwd}`);
133
150
  return [];
134
151
  }
135
152
  const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs");
@@ -142,26 +159,41 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
142
159
  if (process.platform === "win32" && !nodeHook && !isBatchFile) {
143
160
  logInternalError("worktree.setupHook.windowsNoShell", new Error("Non-node, non-batch hook skipped on Windows (shell:true disabled for security)"), `hook=${hookPath}`);
144
161
  }
162
+ // SECURITY: Resolve the hook to its real path before execution to close the TOCTOU window.
163
+ // This prevents a symlink swap between the containment check and actual execution.
164
+ // KNOWN LIMITATION: There is a residual TOCTOU window between realpathSync validation
165
+ // (line 166) and spawnSync execution (line 183). A sufficiently fast attacker could
166
+ // theoretically swap the symlink between these two operations. The realpathSync + O_NOFOLLOW
167
+ // approach minimizes but does not eliminate this window. To fully close it, consider
168
+ // opening the hook file once via a file descriptor and executing via fd passing (not available
169
+ // in Node.js spawnSync). This is documented as a known limitation rather than a bug.
170
+ let realHookPath: string;
171
+ try {
172
+ realHookPath = fs.realpathSync(hookPath);
173
+ } catch {
174
+ logInternalError("worktree.setupHook.realpath", new Error("hook realpath resolution failed: " + hookPath), `cwd=${manifest.cwd}`);
175
+ return [];
176
+ }
145
177
  const result = isBatchFile
146
- ? spawnSync("cmd.exe", ["/c", hookPath], {
178
+ ? spawnSync("cmd.exe", ["/c", realHookPath], {
147
179
  cwd: worktreePath,
148
180
  encoding: "utf-8",
149
181
  input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
150
182
  timeout: cfg.setupHookTimeoutMs ?? 30_000,
151
183
  shell: false, // cmd.exe /c handles batch files safely
152
184
  env: sanitizeEnvSecrets(process.env, {
153
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
185
+ allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
154
186
  }),
155
187
  windowsHide: true,
156
188
  })
157
- : spawnSync(nodeHook ? process.execPath : hookPath, nodeHook ? [hookPath] : [], {
189
+ : spawnSync(nodeHook ? process.execPath : realHookPath, nodeHook ? [realHookPath] : [], {
158
190
  cwd: worktreePath,
159
191
  encoding: "utf-8",
160
192
  input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
161
193
  timeout: cfg.setupHookTimeoutMs ?? 30_000,
162
- shell: useShell,
194
+ shell: false,
163
195
  env: sanitizeEnvSecrets(process.env, {
164
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
196
+ allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
165
197
  }),
166
198
  windowsHide: true,
167
199
  });
@@ -226,6 +258,24 @@ export function normalizeSeedPaths(seedPaths: string[], repoRoot: string): strin
226
258
  throw new Error(`seedPaths entries must stay inside repoRoot: ${entry}`);
227
259
  }
228
260
 
261
+ // Reject symlinks to prevent escape via symlink-based path traversal.
262
+ // This check is also performed in overlaySeedPaths for defense-in-depth.
263
+ // ENOENT is acceptable — seed paths may reference files that don't exist yet
264
+ // (they are validated at copy time by overlaySeedPaths).
265
+ try {
266
+ const stat = fs.lstatSync(absolutePath);
267
+ if (stat.isSymbolicLink()) {
268
+ throw new Error(`seedPaths entries cannot be symlinks: ${entry}`);
269
+ }
270
+ } catch (error) {
271
+ if (error instanceof Error && error.message.startsWith("seedPaths entries")) throw error;
272
+ // ENOENT is acceptable — seed paths may reference files that don't exist yet.
273
+ // Skip symlink check but still include the path in results.
274
+ if (!(error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT")) {
275
+ throw new Error(`seedPaths entries must be accessible: ${entry}`);
276
+ }
277
+ }
278
+
229
279
  const normalizedPath = relativePath.split(path.sep).join("/");
230
280
  if (seen.has(normalizedPath)) continue;
231
281
  seen.add(normalizedPath);
@@ -247,15 +297,27 @@ export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPat
247
297
  const sourcePath = path.join(repoRoot, seedPath);
248
298
  const destinationPath = path.join(worktreePath, seedPath);
249
299
 
250
- if (!fs.existsSync(sourcePath)) {
300
+ let sourceStat: fs.Stats;
301
+ try {
302
+ sourceStat = fs.lstatSync(sourcePath);
303
+ } catch {
251
304
  logInternalError("worktree.seedPaths.missing", new Error(`Seed path does not exist: ${seedPath}`));
252
305
  continue;
253
306
  }
307
+ // Reject symlinks in seed paths to prevent copying symlinks into worktree (which could point outside repoRoot).
308
+ if (sourceStat.isSymbolicLink()) {
309
+ logInternalError("worktree.seedPaths.symlink", new Error(`Seed path is a symlink — rejected: ${seedPath}`));
310
+ continue;
311
+ }
312
+ if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
313
+ logInternalError("worktree.seedPaths.invalid", new Error(`Seed path is neither file nor directory: ${seedPath}`));
314
+ continue;
315
+ }
254
316
 
255
317
  fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
256
318
  fs.rmSync(destinationPath, { force: true, recursive: true });
257
319
  fs.cpSync(sourcePath, destinationPath, {
258
- dereference: false,
320
+ dereference: true,
259
321
  force: true,
260
322
  preserveTimestamps: true,
261
323
  recursive: true,
@@ -268,11 +330,44 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
268
330
  const repoRoot = findGitRoot(manifest.cwd);
269
331
  const loadedConfig = loadConfig(manifest.cwd);
270
332
  if (loadedConfig.config.requireCleanWorktreeLeader !== false) assertCleanLeader(repoRoot);
271
- const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, manifest.runId);
333
+ const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
334
+ const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
272
335
  fs.mkdirSync(worktreeRoot, { recursive: true });
273
- const worktreePath = path.join(worktreeRoot, task.id);
336
+ // Resolve through realpathSync.native to get long-name form on Windows.
337
+ // git worktree uses long-name paths, so we must match. If .native fails,
338
+ // fall back to non-native (preserves input form).
339
+ let resolvedWorktreeRoot = worktreeRoot;
340
+ try {
341
+ const r = fs.realpathSync.native(worktreeRoot);
342
+ resolvedWorktreeRoot = r.startsWith("\\\\?\\") ? r.slice(4) : r;
343
+ } catch {
344
+ try { resolvedWorktreeRoot = fs.realpathSync(worktreeRoot); } catch { /* keep as-is */ }
345
+ }
346
+ const sanitizedTaskId = sanitizeBranchPart(task.id);
347
+ const worktreePath = path.join(resolvedWorktreeRoot, sanitizedTaskId);
274
348
  const branch = `pi-crew/${sanitizeBranchPart(manifest.runId)}/${sanitizeBranchPart(task.id)}`;
275
- if (fs.existsSync(worktreePath)) {
349
+ // Use `git worktree list --porcelain` to atomically verify the worktree exists.
350
+ // This avoids a TOCTOU race between fs.existsSync and git branch verification.
351
+ let worktreeExists = false;
352
+ try {
353
+ const worktreeList = git(repoRoot, ["worktree", "list", "--porcelain"]);
354
+ // `git worktree list --porcelain` outputs "worktree /path" per entry.
355
+ // We must compare against the path part (after "worktree ").
356
+ // On Windows, git may return forward-slash long-name paths while
357
+ // worktreePath uses short-name backslash form. Resolve both through
358
+ // realpathSync.native (which always returns long-name on Windows)
359
+ // for consistent comparison.
360
+ const normalizedWtPath = process.platform === "win32" ? (() => { try { const r = fs.realpathSync.native(worktreePath); return r.startsWith("\\\\?\\") ? r.slice(4) : r; } catch { return worktreePath; } })().replace(/\\/g, "/").toLowerCase() : worktreePath;
361
+ worktreeExists = worktreeList.split("\n").some((line) => {
362
+ const trimmed = line.trim();
363
+ const matchPath = trimmed.startsWith("worktree ") ? trimmed.slice(9) : trimmed;
364
+ if (process.platform === "win32") {
365
+ return matchPath.replace(/\\/g, "/").toLowerCase() === normalizedWtPath;
366
+ }
367
+ return matchPath === worktreePath;
368
+ });
369
+ } catch { worktreeExists = false; }
370
+ if (worktreeExists) {
276
371
  let currentBranch: string;
277
372
  try {
278
373
  currentBranch = git(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -282,16 +377,27 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
282
377
  if (currentBranch !== branch) {
283
378
  throw new Error(`Existing worktree branch mismatch at ${worktreePath}: expected '${branch}', got '${currentBranch}'.`);
284
379
  }
380
+ // Check for uncommitted changes from previous run before reusing
381
+ const dirtyStatus = git(worktreePath, ["status", "--porcelain"]);
382
+ if (dirtyStatus.trim()) {
383
+ // Discard uncommitted changes to ensure clean slate for new task
384
+ logInternalError("worktree.reused.dirty", new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath}`), `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`);
385
+ git(worktreePath, ["checkout", "--", "."]);
386
+ git(worktreePath, ["clean", "-fd"]);
387
+ }
285
388
  // Overlay seed paths from config + step-level seedPaths (reused worktree)
286
389
  const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
287
390
  const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
288
391
  if (mergedReused.length > 0) {
289
392
  overlaySeedPaths(repoRoot, worktreePath, mergedReused);
290
393
  }
394
+ // Re-validate leader is still clean before reusing — leader state may have changed since first preparation
395
+ assertCleanLeader(repoRoot);
291
396
  return { cwd: worktreePath, worktreePath, branch, reused: true };
292
397
  }
293
398
  pruneStaleWorktrees(repoRoot);
294
399
  const exists = branchExists(repoRoot, branch);
400
+ let worktreeCreated = false;
295
401
  try {
296
402
  if (exists.local) {
297
403
  git(repoRoot, ["worktree", "add", worktreePath, branch]);
@@ -301,7 +407,14 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
301
407
  }
302
408
  git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
303
409
  }
410
+ worktreeCreated = true;
304
411
  } catch (error) {
412
+ // Clean up orphaned worktree directory if git worktree add failed
413
+ if (fs.existsSync(worktreePath)) {
414
+ try {
415
+ fs.rmSync(worktreePath, { recursive: true, force: true });
416
+ } catch { /* best-effort cleanup */ }
417
+ }
305
418
  const msg = error instanceof Error ? error.message : String(error);
306
419
  if (/already checked out|is already used by worktree/i.test(msg)) {
307
420
  throw new Error(`Branch '${branch}' is checked out at another worktree. Run \`team cleanup runId=${manifest.runId} force=true\` or manually remove the conflicting worktree.`);
package/test-bugs-all.mjs CHANGED
@@ -69,13 +69,17 @@ if (completedIdsFix) {
69
69
 
70
70
  // Check dist file
71
71
  console.log("\n=== Checking dist/index.mjs ===");
72
- const distContent = fs.readFileSync("dist/index.mjs", "utf-8");
73
- const distNeedsAttention = distContent.includes('t2.status === "completed" || t2.status === "needs_attention"');
74
- if (distNeedsAttention) {
75
- console.log(" ✅ Bug #20 fix is in dist/index.mjs");
72
+ if (fs.existsSync("dist/index.mjs")) {
73
+ const distContent = fs.readFileSync("dist/index.mjs", "utf-8");
74
+ const distNeedsAttention = distContent.includes('t2.status === "completed" || t2.status === "needs_attention"');
75
+ if (distNeedsAttention) {
76
+ console.log(" ✅ Bug #20 fix is in dist/index.mjs");
77
+ } else {
78
+ console.log(" ❌ Bug #20 fix NOT in dist/index.mjs - rebuild needed");
79
+ allPassed = false;
80
+ }
76
81
  } else {
77
- console.log(" ❌ Bug #20 fix NOT in dist/index.mjs - rebuild needed");
78
- allPassed = false;
82
+ console.log(" ⚠️ dist/index.mjs not found - run npm run build first");
79
83
  }
80
84
 
81
85
  console.log("\n" + "=".repeat(40));
@@ -65,7 +65,9 @@ test("fast-fix team run completes successfully with mock child Pi", async () =>
65
65
 
66
66
  const prevExec = process.env.PI_TEAMS_EXECUTE_WORKERS;
67
67
  const prevMock = process.env.PI_TEAMS_MOCK_CHILD_PI;
68
+ const prevAllow = process.env.PI_CREW_ALLOW_MOCK;
68
69
  process.env.PI_TEAMS_EXECUTE_WORKERS = "1";
70
+ process.env.PI_CREW_ALLOW_MOCK = "1";
69
71
  process.env.PI_TEAMS_MOCK_CHILD_PI = "success";
70
72
 
71
73
  try {
@@ -109,6 +111,8 @@ test("fast-fix team run completes successfully with mock child Pi", async () =>
109
111
  else process.env.PI_TEAMS_EXECUTE_WORKERS = prevExec;
110
112
  if (prevMock === undefined) delete process.env.PI_TEAMS_MOCK_CHILD_PI;
111
113
  else process.env.PI_TEAMS_MOCK_CHILD_PI = prevMock;
114
+ if (prevAllow === undefined) delete process.env.PI_CREW_ALLOW_MOCK;
115
+ else process.env.PI_CREW_ALLOW_MOCK = prevAllow;
112
116
  fs.rmSync(cwd, { recursive: true, force: true });
113
117
  }
114
118
  });