pi-crew 0.6.1 → 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 (102) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +70 -33
  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/config/config.ts +106 -15
  7. package/src/errors.ts +107 -0
  8. package/src/extension/async-notifier.ts +6 -2
  9. package/src/extension/crew-cleanup.ts +8 -5
  10. package/src/extension/management.ts +464 -109
  11. package/src/extension/register.ts +194 -34
  12. package/src/extension/registration/commands.ts +2 -2
  13. package/src/extension/registration/subagent-helpers.ts +2 -2
  14. package/src/extension/registration/subagent-tools.ts +1 -1
  15. package/src/extension/registration/team-tool.ts +1 -1
  16. package/src/extension/registration/viewers.ts +2 -2
  17. package/src/extension/team-tool/api.ts +3 -3
  18. package/src/extension/team-tool/cancel.ts +3 -3
  19. package/src/extension/team-tool/explain.ts +1 -1
  20. package/src/extension/team-tool/handle-schedule.ts +40 -0
  21. package/src/extension/team-tool/inspect.ts +3 -3
  22. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  23. package/src/extension/team-tool/respond.ts +2 -2
  24. package/src/extension/team-tool/run.ts +60 -11
  25. package/src/extension/team-tool/status.ts +1 -1
  26. package/src/extension/team-tool.ts +173 -46
  27. package/src/hooks/registry.ts +77 -13
  28. package/src/hooks/types.ts +9 -0
  29. package/src/plugins/plugin-define.ts +6 -0
  30. package/src/plugins/plugin-registry.ts +32 -0
  31. package/src/plugins/plugins/index.ts +3 -0
  32. package/src/plugins/plugins/nextjs.ts +19 -0
  33. package/src/plugins/plugins/vite.ts +14 -0
  34. package/src/plugins/plugins/vitest.ts +14 -0
  35. package/src/runtime/async-runner.ts +35 -7
  36. package/src/runtime/background-runner.ts +529 -144
  37. package/src/runtime/chain-parser.ts +5 -1
  38. package/src/runtime/chain-runner.ts +9 -3
  39. package/src/runtime/checkpoint.ts +262 -180
  40. package/src/runtime/child-pi.ts +164 -37
  41. package/src/runtime/crash-recovery.ts +25 -14
  42. package/src/runtime/crew-agent-records.ts +2 -0
  43. package/src/runtime/diagnostic-export.ts +1 -1
  44. package/src/runtime/dynamic-script-runner.ts +10 -7
  45. package/src/runtime/foreground-watchdog.ts +1 -1
  46. package/src/runtime/heartbeat-watcher.ts +19 -2
  47. package/src/runtime/intercom-bridge.ts +7 -0
  48. package/src/runtime/iteration-hooks.ts +1 -1
  49. package/src/runtime/manifest-cache.ts +4 -0
  50. package/src/runtime/orphan-worker-registry.ts +444 -0
  51. package/src/runtime/parent-guard.ts +70 -16
  52. package/src/runtime/pi-args.ts +437 -14
  53. package/src/runtime/pi-spawn.ts +1 -0
  54. package/src/runtime/post-checks.ts +1 -1
  55. package/src/runtime/retry-runner.ts +9 -3
  56. package/src/runtime/run-tracker.ts +38 -10
  57. package/src/runtime/sandbox.ts +17 -2
  58. package/src/runtime/scheduler.ts +25 -2
  59. package/src/runtime/skill-effectiveness.ts +105 -62
  60. package/src/runtime/skill-instructions.ts +110 -32
  61. package/src/runtime/stale-reconciler.ts +310 -69
  62. package/src/runtime/subagent-manager.ts +251 -57
  63. package/src/runtime/task-health.ts +76 -0
  64. package/src/runtime/task-id.ts +20 -0
  65. package/src/runtime/task-runner/live-executor.ts +1 -1
  66. package/src/runtime/task-runner/progress.ts +1 -1
  67. package/src/runtime/task-runner/state-helpers.ts +110 -6
  68. package/src/runtime/task-runner.ts +92 -70
  69. package/src/runtime/team-runner.ts +186 -20
  70. package/src/schema/team-tool-schema.ts +27 -9
  71. package/src/skills/discover-skills.ts +9 -3
  72. package/src/state/active-run-registry.ts +170 -38
  73. package/src/state/artifact-store.ts +25 -13
  74. package/src/state/atomic-write-v2.ts +86 -0
  75. package/src/state/atomic-write.ts +346 -55
  76. package/src/state/blob-store.ts +178 -10
  77. package/src/state/crew-init.ts +161 -28
  78. package/src/state/decision-ledger.ts +172 -111
  79. package/src/state/event-log-rotation.ts +82 -52
  80. package/src/state/event-log.ts +254 -70
  81. package/src/state/health-store.ts +71 -0
  82. package/src/state/locks.ts +102 -20
  83. package/src/state/mailbox.ts +45 -7
  84. package/src/state/observation-store.ts +4 -1
  85. package/src/state/run-graph.ts +141 -130
  86. package/src/state/run-metrics.ts +24 -8
  87. package/src/state/state-store.ts +330 -43
  88. package/src/ui/live-run-sidebar.ts +1 -1
  89. package/src/ui/loaders.ts +4 -0
  90. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  91. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  92. package/src/ui/run-action-dispatcher.ts +2 -2
  93. package/src/ui/run-snapshot-cache.ts +1 -1
  94. package/src/utils/env-filter.ts +66 -0
  95. package/src/utils/file-coalescer.ts +9 -1
  96. package/src/utils/paths.ts +80 -5
  97. package/src/utils/redaction.ts +6 -1
  98. package/src/utils/safe-paths.ts +281 -10
  99. package/src/worktree/cleanup.ts +112 -24
  100. package/src/worktree/worktree-manager.ts +128 -15
  101. package/test-bugs-all.mjs +10 -6
  102. package/test-integration-check.ts +4 -0
@@ -1,18 +1,57 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
5
+ import { atomicWriteFile, renameWithRetry } from "./atomic-write.ts";
6
+ import { withFileLockSync } from "./locks.ts";
7
+ import { sleepSync } from "../utils/sleep.ts";
5
8
 
6
9
  const SHA256_HEX = /^[a-f0-9]{64}$/i;
7
10
 
8
- function validateBlobHash(hash: string): void {
11
+ function validateBlobHash(hash: string, algorithm?: string): void {
9
12
  if (!SHA256_HEX.test(hash)) throw new Error(`Invalid blob hash: ${hash}`);
13
+ if (algorithm !== undefined && algorithm !== SHA256_PREFIX) {
14
+ throw new Error(`Invalid blob algorithm: ${algorithm} (expected ${SHA256_PREFIX})`);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Atomically write a Buffer to a file using temp-file + rename pattern.
20
+ * Prevents partial writes on crash and handles concurrent writes safely.
21
+ */
22
+ function atomicWriteBuffer(filePath: string, content: Buffer): void {
23
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
24
+ const tempPath = `${filePath}.${randomUUID()}.tmp`;
25
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
26
+ const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
27
+ try {
28
+ const openedStat = fs.fstatSync(fd);
29
+ if (!openedStat.isFile()) {
30
+ throw new Error(`Refusing to write: opened path is not a regular file: ${tempPath}`);
31
+ }
32
+ fs.writeSync(fd, content, 0, content.length);
33
+ renameWithRetry(tempPath, filePath);
34
+ } catch (error) {
35
+ try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
36
+ throw error;
37
+ } finally {
38
+ // Always close fd; closeSync is safe to call even if fd was already closed
39
+ try { fs.closeSync(fd); } catch { /* best-effort */ }
40
+ }
10
41
  }
11
42
 
12
43
  const BLOBS_DIR = "blobs";
13
44
  const BLOB_META_DIR = "blob-metadata";
14
45
  const SHA256_PREFIX = "sha256";
15
46
 
47
+ /**
48
+ * File-based lock for cross-process metadata write protection.
49
+ * Uses withFileLockSync which provides O_EXCL atomic create + token-guarded release.
50
+ */
51
+ function withMetadataLock<T>(metadataPath: string, fn: () => T): T {
52
+ return withFileLockSync(metadataPath, fn);
53
+ }
54
+
16
55
  export interface BlobMetadata {
17
56
  blobHash: string;
18
57
  blobAlgorithm: string;
@@ -42,6 +81,9 @@ function sha256Of(content: string | Buffer): string {
42
81
  /**
43
82
  * Write content-addressed blob to the blobs directory under artifactsRoot.
44
83
  * Content is deduplicated by hash; metadata sidecar is always written.
84
+ * FIX: Both content and metadata writes now use atomicWriteFile to prevent
85
+ * partial writes on crash. The deduplication check is now advisory (the atomic
86
+ * write handles concurrent writes correctly via O_EXCL temp file pattern).
45
87
  */
46
88
  export function writeBlob(artifactsRoot: string, input: {
47
89
  content: string | Buffer;
@@ -58,14 +100,19 @@ export function writeBlob(artifactsRoot: string, input: {
58
100
  const algorithm = SHA256_PREFIX;
59
101
  const blobDir = path.join(artifactsRoot, BLOBS_DIR, algorithm);
60
102
  const metaDir = path.join(artifactsRoot, BLOB_META_DIR);
103
+ // Issue 2 fix: Validate parent directories are not symlinks before mkdirSync.
104
+ // An attacker could plant a symlink before we create the directory, causing
105
+ // blob content to be written to an attacker-controlled location.
106
+ resolveRealContainedPath(artifactsRoot, blobDir);
107
+ resolveRealContainedPath(artifactsRoot, metaDir);
61
108
  fs.mkdirSync(blobDir, { recursive: true });
62
109
  fs.mkdirSync(metaDir, { recursive: true });
63
110
 
64
111
  const blobPath = path.join(blobDir, hash);
65
- if (!fs.existsSync(blobPath)) {
66
- fs.writeFileSync(blobPath, content, typeof input.content === "string" ? "utf-8" : undefined);
67
- }
68
-
112
+ // Issue 6 fix: Validate blobPath BEFORE writing to ensure the path is safe.
113
+ // If resolveRealContainedPath throws after the write, the blob may already
114
+ // exist at an unexpected location. Validate first to prevent this.
115
+ resolveRealContainedPath(artifactsRoot, blobPath);
69
116
  const metadata: BlobMetadata = {
70
117
  blobHash: hash,
71
118
  blobAlgorithm: algorithm,
@@ -79,11 +126,90 @@ export function writeBlob(artifactsRoot: string, input: {
79
126
  retention: input.retention ?? "run",
80
127
  createdAt: new Date().toISOString(),
81
128
  };
82
-
83
129
  const metadataPath = path.join(metaDir, `${hash}.json`);
84
- fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
85
130
 
86
- return { hash, algorithm, blobPath: resolveRealContainedPath(artifactsRoot, blobPath), metadataPath: resolveRealContainedPath(artifactsRoot, metadataPath), sizeBytes: metadata.sizeBytes };
131
+ // Both content and metadata use atomic writes to prevent partial writes on crash.
132
+ // Content is immutable and content-addressed (same hash = same content), so
133
+ // concurrent writes to the same hash are safe. Metadata (mime, retention, etc.)
134
+ // is protected by a per-hash lock to make the check-then-write atomic, preventing
135
+ // concurrent writers from racing to write different metadata for the same hash.
136
+ // Issue 3 fix: wrap metadata check-and-write in a lock to make it atomic.
137
+ // Without the lock, two processes could read the same metadata concurrently,
138
+ // both pass the check, and the second write would silently overwrite the first.
139
+ // FIX: Write blob content FIRST, then metadata. If blob write fails, no orphan metadata.
140
+ // Previous order was metadata first, then blob - causing orphan metadata on blob failure.
141
+ let blobContentWritten = false;
142
+ try {
143
+ // Write blob content first (immutable, content-addressed)
144
+ atomicWriteBuffer(blobPath, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf-8"));
145
+ blobContentWritten = true;
146
+ } catch (error) {
147
+ // Blob write failed - no metadata to clean up since we haven't written it yet
148
+ throw error;
149
+ }
150
+
151
+ // Metadata only after blob content is successfully written
152
+ // Issue 3 fix: Use two-phase commit for metadata - write to temp file first,
153
+ // then atomically rename to final location. This prevents orphan blobs if
154
+ // the process crashes after blob write but before metadata rename.
155
+ let metadataWritten = false;
156
+ try {
157
+ withMetadataLock(metadataPath, () => {
158
+ try {
159
+ const existingMeta = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as BlobMetadata;
160
+ // Compare fields that indicate concurrent write with different metadata.
161
+ // Note: taskId is intentionally NOT part of the conflict check because
162
+ // it represents which task produced the blob and may legitimately differ
163
+ // across processes (last-write-wins for taskId). If concurrent writes
164
+ // have identical mime/retention/producer/originalPath but different
165
+ // taskId, the conflict check passes and both writes succeed — the
166
+ // final taskId reflects whichever write completed last.
167
+ // Callers should not rely on taskId for correctness-critical decisions.
168
+ if (existingMeta.mime !== metadata.mime ||
169
+ existingMeta.retention !== metadata.retention ||
170
+ existingMeta.producer !== metadata.producer ||
171
+ existingMeta.originalPath !== metadata.originalPath) {
172
+ throw new Error(`Concurrent metadata write conflict for blob ${hash}: different metadata values detected. Existing: ${JSON.stringify(existingMeta)}, New: ${JSON.stringify(metadata)}`);
173
+ }
174
+ } catch (err) {
175
+ if (err instanceof Error && err.message.includes("ENOENT")) {
176
+ // OK - metadata doesn't exist yet, proceed to write
177
+ } else {
178
+ // Re-throw non-ENOENT errors (e.g., permission denied, corrupt file)
179
+ throw err;
180
+ }
181
+ }
182
+ // Issue 3 fix: Two-phase commit for metadata
183
+ // Write to temp file first, then atomically rename to final location.
184
+ // Issue 1 fix: Use atomicWriteFile instead of plain writeSync for the temp file
185
+ // to prevent partial writes on crash. atomicWriteFile uses O_EXCL + write + rename
186
+ // pattern which is consistent with how blob content is written.
187
+ const tempMetaPath = `${metadataPath}.${randomUUID()}.tmp`;
188
+ atomicWriteFile(tempMetaPath, JSON.stringify(metadata, null, 2));
189
+ renameWithRetry(tempMetaPath, metadataPath);
190
+ metadataWritten = true;
191
+ });
192
+ } catch (error) {
193
+ // Issue 4 fix: Clean up orphaned blob if metadata write fails.
194
+ // If metadata write fails (e.g., concurrent conflict), the blob content
195
+ // is orphaned since no metadata references it. Clean it up to reclaim space.
196
+ // Issue 8 fix: Do NOT delete blob content on metadata failure.
197
+ // If metadata write fails due to concurrent conflict (different values),
198
+ // the blob content is still valid. Another process has written metadata
199
+ // referencing this blob - deleting the blob would orphan their metadata.
200
+ // The caller can retry the metadata write if needed.
201
+ // However, if metadata was never written (metadataWritten === false),
202
+ // the blob is orphaned and should be cleaned up.
203
+ if (!blobContentWritten) {
204
+ try { fs.rmSync(blobPath, { force: true }); } catch { /* best-effort */ }
205
+ }
206
+ throw error;
207
+ }
208
+
209
+ // Issue 2 fix: resolve paths before writes and return cached values
210
+ const resolvedBlobPath = resolveRealContainedPath(artifactsRoot, blobPath);
211
+ const resolvedMetadataPath = resolveRealContainedPath(artifactsRoot, metadataPath);
212
+ return { hash, algorithm, blobPath: resolvedBlobPath, metadataPath: resolvedMetadataPath, sizeBytes: metadata.sizeBytes };
87
213
  }
88
214
 
89
215
  /**
@@ -114,4 +240,46 @@ export function readBlobMetadata(artifactsRoot: string, hash: string): BlobMetad
114
240
  } catch {
115
241
  return undefined;
116
242
  }
117
- }
243
+ }
244
+ /**
245
+ * Cleanup orphaned blobs - blobs without corresponding metadata entries.
246
+ * Called periodically to reclaim storage from blobs that were written but whose
247
+ * metadata write failed (e.g., crash between content and metadata write).
248
+ * Returns the number of orphaned blobs cleaned up.
249
+ */
250
+ export function cleanupOrphanedBlobs(artifactsRoot: string): number {
251
+ const blobDir = path.join(artifactsRoot, BLOBS_DIR, SHA256_PREFIX);
252
+ const metaDir = path.join(artifactsRoot, BLOB_META_DIR);
253
+
254
+ let cleaned = 0;
255
+ try {
256
+ const blobFiles = fs.readdirSync(blobDir);
257
+ for (const blobFile of blobFiles) {
258
+ // Skip non-hash files (e.g., temp files from atomicWriteBuffer)
259
+ if (!SHA256_HEX.test(blobFile)) continue;
260
+
261
+ const metaPath = path.join(metaDir, `${blobFile}.json`);
262
+ // Issue 4 fix: Use lock to prevent race between stat and delete.
263
+ // A concurrent process could write metadata between stat and delete,
264
+ // causing valid blob to be deleted while metadata remains.
265
+ withMetadataLock(metaPath, () => {
266
+ try {
267
+ fs.statSync(metaPath);
268
+ // Metadata exists - blob is not orphaned
269
+ } catch {
270
+ // Metadata does not exist - blob is orphaned, delete it
271
+ const blobPath = path.join(blobDir, blobFile);
272
+ try {
273
+ fs.rmSync(blobPath, { force: true });
274
+ cleaned++;
275
+ } catch {
276
+ // Best-effort cleanup - continue to next blob
277
+ }
278
+ }
279
+ });
280
+ }
281
+ } catch {
282
+ // Blobs directory doesn't exist or inaccessible - nothing to clean
283
+ }
284
+ return cleaned;
285
+ }
@@ -3,10 +3,17 @@
3
3
  * Called on first team run in a workspace to ensure all required
4
4
  * directories and files exist.
5
5
  *
6
- * IMPORTANT: This module must be COMPLETELY self-contained with NO dependencies
7
- * on other pi-crew modules (especially paths.ts). It is called via dynamic
8
- * import from child-process contexts (background runners, subagents) where
9
- * module binding can fail. Keep this file minimal and self-contained.
6
+ * IMPORTANT: This module is dynamically `import()`'d from concurrent child
7
+ * Pi subprocesses (3+ parallel subagents). Under load, the `path` namespace
8
+ * binding can intermittently arrive as `undefined` in jiti's ESM/CJS interop
9
+ * layer. We therefore use the inline helpers `parseRoot`, `safeJoin`,
10
+ * `safeDirname`, and `safeResolve` so that critical path operations do not
11
+ * depend on the `path` namespace binding.
12
+ *
13
+ * The `node:path` import is retained as a *fallback* (only used when the
14
+ * binding is healthy). Don't add new dependencies on other pi-crew modules.
15
+ *
16
+ * See: https://github.com/baphuongna/pi-crew/issues/28
10
17
  */
11
18
  import * as fs from "node:fs";
12
19
  import * as path from "node:path";
@@ -49,25 +56,139 @@ team action='cache' action='clear'
49
56
  * Inline implementation to avoid module dependency on paths.ts.
50
57
  * Matches the logic in src/utils/paths.ts:computeRepoRoot().
51
58
  */
52
- function findProjectRoot(start: string): string | undefined {
59
+ /**
60
+ * Detect filesystem root for `start` without relying on `path.parse()`.
61
+ *
62
+ * **Why this exists**: This module is dynamically `import()`'d from concurrent
63
+ * child Pi subprocesses (3+ parallel subagents). Under load, the `path` namespace
64
+ * binding can intermittently arrive as `undefined` in jiti's ESM/CJS interop layer,
65
+ * crashing `findProjectRoot` with `TypeError: Cannot read properties of undefined
66
+ * (reading 'parse')` — see https://github.com/baphuongna/pi-crew/issues/28.
67
+ *
68
+ * Inlining `parse` for the termination root eliminates the dependency on the
69
+ * `path` binding for that critical call path.
70
+ */
71
+ function parseRoot(start: string): string {
72
+ if (!start) return "/";
73
+ if (start[0] === "/") return "/";
74
+ // Windows: "C:\\" or "C:/" -> "C:\\"
75
+ if (/^[A-Za-z]:[\\/]/.test(start)) return start.slice(0, 3);
76
+ // UNC: "\\\\server\\share" — find the second path separator.
77
+ if (start.startsWith("\\\\") || start.startsWith("//")) {
78
+ const rest = start.slice(2);
79
+ const firstSep = Math.max(rest.indexOf("\\"), rest.indexOf("/"));
80
+ if (firstSep === -1) return start;
81
+ const secondSep = Math.max(
82
+ rest.indexOf("\\", firstSep + 1),
83
+ rest.indexOf("/", firstSep + 1),
84
+ );
85
+ if (secondSep === -1) return start;
86
+ // secondSep is an index into `rest`; add 2 to map back to `start`.
87
+ return start.slice(0, 2 + secondSep);
88
+ }
89
+ // Relative path — no fixed root, use start itself as terminator.
90
+ return start;
91
+ }
92
+
93
+ /**
94
+ * Defensive wrappers around `path` for use in dynamic-import contexts.
95
+ *
96
+ * **Why these exist**: This module is dynamically `import()`'d from concurrent
97
+ * child Pi subprocesses (3+ parallel subagents). Under load, the `path` namespace
98
+ * binding can intermittently arrive as `undefined` in jiti's ESM/CJS interop layer,
99
+ * crashing `findProjectRoot` with `TypeError: Cannot read properties of undefined
100
+ * (reading 'parse')` — see https://github.com/baphuongna/pi-crew/issues/28.
101
+ *
102
+ * Each helper checks that the corresponding `path` function exists before
103
+ * calling it, falling back to an inline implementation. This keeps the file
104
+ * self-contained even if the namespace binding is missing.
105
+ */
106
+ function safeJoin(...parts: string[]): string {
107
+ // Cross-platform join — picks the separator based on the parts.
108
+ // Don't delegate to `path.join` because POSIX/Windows disagree on which
109
+ // separator is the path separator, and the dynamic-import context (issue
110
+ // #28) may have a partially-initialized `path` namespace.
111
+ const filtered = parts.filter(Boolean);
112
+ if (filtered.length === 0) return "";
113
+ const sep = filtered.some((p) => p.includes("\\")) ? "\\" : "/";
114
+ // Detect if the first part begins with a leading separator (or UNC "\\\\")
115
+ // so we can preserve it. F-8: collapses runs of the separator everywhere
116
+ // (including the body), but re-prepends the leading separator that the
117
+ // collapse regex would otherwise eat.
118
+ const firstPart = filtered[0];
119
+ let leading = "";
120
+ if (sep === "\\") {
121
+ if (firstPart.startsWith("\\\\")) leading = "\\\\";
122
+ else if (firstPart.startsWith("\\")) leading = "\\";
123
+ } else if (firstPart.startsWith("/")) {
124
+ leading = "/";
125
+ }
126
+ // Strip the leading separator(s) from the first part before joining, so
127
+ // the collapse regex doesn't re-collapse them.
128
+ const firstPartStripped =
129
+ sep === "\\"
130
+ ? firstPart.replace(/^\\{1,2}/, "")
131
+ : firstPart.replace(/^\/+/, "");
132
+ const rest = filtered.slice(1);
133
+ const joined = [firstPartStripped, ...rest].filter(Boolean).join(sep);
134
+ // Collapse internal runs of the separator.
135
+ const collapsed = joined.replace(
136
+ new RegExp(`${sep === "\\" ? "\\\\" : "/"}{2,}`, "g"),
137
+ sep,
138
+ );
139
+ return leading + collapsed;
140
+ }
141
+
142
+ function safeDirname(p: string): string {
143
+ // Cross-platform dirname — handles BOTH `/` and `\` separators.
144
+ // Note: we don't delegate to `path.dirname` here because on POSIX it treats
145
+ // backslashes as part of a filename, and on Windows it treats forward
146
+ // slashes the same way. The dynamic-import context (issue #28) may also
147
+ // have a partially-initialized `path` namespace. Using a unified inline
148
+ // implementation ensures consistent behavior across all platforms.
149
+ const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
150
+ if (idx === -1) return p; // No separator at all
151
+ if (idx === 0) return p[0] === "/" || p[0] === "\\" ? p[0] : p; // Root: "/" or "\"
152
+ // Preserve drive letter roots like "C:\"
153
+ if (idx === 2 && /[A-Za-z]:/.test(p.slice(0, 2))) return p.slice(0, 3);
154
+ return p.slice(0, idx);
155
+ }
156
+
157
+ function safeResolve(p: string, pathDep?: typeof path): string {
158
+ const dep = pathDep ?? path;
159
+ if (dep && typeof dep.resolve === "function") return dep.resolve(p);
160
+ return p;
161
+ }
162
+
163
+ function findProjectRoot(
164
+ start: string,
165
+ pathDep?: typeof path,
166
+ ): string | undefined {
53
167
  const dirMarkers = [".git", ".hg", ".svn"];
54
- const fileMarkers = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
55
- const root = path.parse(start).root;
56
- let current = path.resolve(start);
168
+ const fileMarkers = [
169
+ "package.json",
170
+ "pyproject.toml",
171
+ "Cargo.toml",
172
+ "go.mod",
173
+ ];
174
+ // Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
175
+ // termination root — fixes the jiti namespace race in issue #28.
176
+ const root = parseRoot(start);
177
+ let current = safeResolve(start, pathDep);
57
178
  // Walk up to find project root
58
179
  while (current !== root) {
59
180
  for (const marker of dirMarkers) {
60
- if (fs.existsSync(path.join(current, marker))) return current;
181
+ if (fs.existsSync(safeJoin(current, marker))) return current;
61
182
  }
62
183
  for (const marker of fileMarkers) {
63
- if (fs.existsSync(path.join(current, marker))) return current;
184
+ if (fs.existsSync(safeJoin(current, marker))) return current;
64
185
  }
65
- const parent = path.dirname(current);
186
+ const parent = safeDirname(current);
66
187
  if (parent === current) break;
67
188
  current = parent;
68
189
  }
69
190
  // Check root as fallback
70
- if (dirMarkers.some((m) => fs.existsSync(path.join(root, m)))) return root;
191
+ if (dirMarkers.some((m) => fs.existsSync(safeJoin(root, m)))) return root;
71
192
  return undefined;
72
193
  }
73
194
 
@@ -77,12 +198,12 @@ function findProjectRoot(start: string): string | undefined {
77
198
  */
78
199
  function computeCrewRoot(cwd: string): string {
79
200
  const repoRoot = findProjectRoot(cwd) ?? cwd;
80
- const crewDir = path.join(repoRoot, ".crew");
201
+ const crewDir = safeJoin(repoRoot, ".crew");
81
202
  // Keep existing .crew/ stable even when .pi/ exists for project config.
82
203
  if (fs.existsSync(crewDir)) return crewDir;
83
204
  // Legacy reuse: if .pi/ already exists, namespace under .pi/teams/
84
- const piDir = path.join(repoRoot, ".pi");
85
- return fs.existsSync(piDir) ? path.join(piDir, "teams") : crewDir;
205
+ const piDir = safeJoin(repoRoot, ".pi");
206
+ return fs.existsSync(piDir) ? safeJoin(piDir, "teams") : crewDir;
86
207
  }
87
208
 
88
209
  /**
@@ -99,12 +220,12 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
99
220
  // 1. Create directory structure
100
221
  const dirs = [
101
222
  crewRoot,
102
- path.join(crewRoot, "state", "runs"),
103
- path.join(crewRoot, "state", "subagents"),
104
- path.join(crewRoot, "artifacts"),
105
- path.join(crewRoot, "cache"),
106
- path.join(crewRoot, "graphs"),
107
- path.join(crewRoot, "audit"),
223
+ safeJoin(crewRoot, "state", "runs"),
224
+ safeJoin(crewRoot, "state", "subagents"),
225
+ safeJoin(crewRoot, "artifacts"),
226
+ safeJoin(crewRoot, "cache"),
227
+ safeJoin(crewRoot, "graphs"),
228
+ safeJoin(crewRoot, "audit"),
108
229
  ];
109
230
 
110
231
  for (const dir of dirs) {
@@ -115,10 +236,10 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
115
236
 
116
237
  // 2. Create .gitkeep placeholders in directories that should be tracked
117
238
  const placeholders = [
118
- path.join(crewRoot, "artifacts", ".gitkeep"),
119
- path.join(crewRoot, "cache", ".gitkeep"),
120
- path.join(crewRoot, "graphs", ".gitkeep"),
121
- path.join(crewRoot, "audit", ".gitkeep"),
239
+ safeJoin(crewRoot, "artifacts", ".gitkeep"),
240
+ safeJoin(crewRoot, "cache", ".gitkeep"),
241
+ safeJoin(crewRoot, "graphs", ".gitkeep"),
242
+ safeJoin(crewRoot, "audit", ".gitkeep"),
122
243
  ];
123
244
 
124
245
  for (const placeholder of placeholders) {
@@ -128,12 +249,24 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
128
249
  }
129
250
 
130
251
  // 3. Write README.md (always overwrite to keep it current)
131
- fs.writeFileSync(path.join(crewRoot, "README.md"), CREW_README, "utf-8");
252
+ fs.writeFileSync(safeJoin(crewRoot, "README.md"), CREW_README, "utf-8");
132
253
 
133
254
  // 4. Update .gitignore at project root
134
255
  const repoRoot = findProjectRoot(cwd);
135
256
  if (repoRoot) {
136
- const gitignorePath = path.join(repoRoot, ".gitignore");
257
+ const gitignorePath = safeJoin(repoRoot, ".gitignore");
137
258
  await updateGitignore(gitignorePath);
138
259
  }
139
- }
260
+ }
261
+
262
+ // Exported only for regression tests of issue #28.
263
+ // NOT part of the public API — the `__test__` prefix follows the project
264
+ // convention used in atomic-write.ts, state-store.ts, team-runner.ts, etc.
265
+ // See F-4 in the post-fix review for the convention rationale.
266
+ export const __test__internals = {
267
+ parseRoot,
268
+ safeJoin,
269
+ safeDirname,
270
+ safeResolve,
271
+ findProjectRoot,
272
+ };