@bli-cockpit/cli 0.2.5 → 0.2.7

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.
@@ -4,8 +4,8 @@ import { readFileSync } from "node:fs";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
8
- import { normalizeCollectionRoots } from "./root-normalization.js";
7
+ import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
8
+ import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
9
9
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
10
10
  import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
11
11
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -139,6 +139,20 @@ export async function logoutLocalCollector(options = {}) {
139
139
  return { removed, session_file: paths.session_file };
140
140
  }
141
141
  export async function startLocalWorkContext(options = {}) {
142
+ return writeLocalWorkContext(options);
143
+ }
144
+ /**
145
+ * Starts a context for a transcript-attributed sync target that does not exist
146
+ * in the current git inventory, such as an approved wrapper folder or a
147
+ * deleted repo reconstructed from transcript provenance. This internal path
148
+ * validates the exact target identity before writing any context state.
149
+ */
150
+ export async function startLocalWorkContextForAttributedTarget(options, attributedIdentity) {
151
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
152
+ const identity = await validateAttributedTargetIdentity(repoRoot, attributedIdentity);
153
+ return writeLocalWorkContext(options, identity);
154
+ }
155
+ async function writeLocalWorkContext(options, attributedIdentity) {
142
156
  if (options.activeTicketId && options.clearTicket) {
143
157
  throw new Error("--ticket and --clear-ticket cannot be combined.");
144
158
  }
@@ -152,8 +166,11 @@ export async function startLocalWorkContext(options = {}) {
152
166
  operatorId: options.operatorId,
153
167
  sessionId: options.sessionId,
154
168
  });
155
- const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
156
- const branch = options.branch ?? identity.branch;
169
+ const identity = attributedIdentity ??
170
+ (await resolveIdentityOrFallback(repoRoot, options.branch));
171
+ const branch = attributedIdentity
172
+ ? attributedIdentity.branch
173
+ : (options.branch ?? identity.branch);
157
174
  const sessionId = options.sessionId ??
158
175
  (session.session_state === "missing" ? `local-${crypto.randomUUID()}` : session.session_id);
159
176
  const operatorId = options.operatorId ?? session.operator_id;
@@ -225,6 +242,55 @@ export async function startLocalWorkContext(options = {}) {
225
242
  await writeJsonFile(paths.active_work_context_file, context);
226
243
  return context;
227
244
  }
245
+ async function validateAttributedTargetIdentity(requestedRepoRoot, identity) {
246
+ const canonicalRoot = await stableWorktreeRoot(requestedRepoRoot);
247
+ const identityRoot = await stableWorktreeRoot(identity.repo_root);
248
+ const identityRequestedPath = await stableWorktreeRoot(identity.requested_path);
249
+ if (!isSamePath(canonicalRoot, identityRoot) ||
250
+ !isSamePath(canonicalRoot, identityRequestedPath)) {
251
+ throw new Error("Attributed target identity paths do not match the requested repo root.");
252
+ }
253
+ const expectedWorktreeFingerprint = stableWorktreeFingerprint(canonicalRoot);
254
+ if (identity.worktree_fingerprint !== expectedWorktreeFingerprint) {
255
+ throw new Error("Attributed target worktree fingerprint does not match its repo root.");
256
+ }
257
+ const expectedWorktreeLabel = path.basename(canonicalRoot) || "workspace";
258
+ if (identity.worktree_label !== expectedWorktreeLabel) {
259
+ throw new Error("Attributed target worktree label does not match its repo root.");
260
+ }
261
+ if (identity.repo_origin_url) {
262
+ const normalizedOrigin = normalizeGitOrigin(identity.repo_origin_url);
263
+ if (normalizedOrigin !== identity.repo_origin_url) {
264
+ throw new Error("Attributed target repo origin is not normalized.");
265
+ }
266
+ if (identity.repo_fingerprint !== repoFingerprintFromOrigin(normalizedOrigin)) {
267
+ throw new Error("Attributed target repo fingerprint does not match its origin.");
268
+ }
269
+ if (identity.repo_label !== repoLabelFromOrigin(normalizedOrigin)) {
270
+ throw new Error("Attributed target repo label does not match its origin.");
271
+ }
272
+ if (identity.worktree_is_primary) {
273
+ throw new Error("Origin-derived attributed targets cannot claim a primary live worktree.");
274
+ }
275
+ }
276
+ else {
277
+ if (identity.repo_fingerprint !==
278
+ repoFingerprintFromLocalRoot(canonicalRoot)) {
279
+ throw new Error("Attributed target repo fingerprint does not match its local root.");
280
+ }
281
+ if (identity.repo_label !== expectedWorktreeLabel) {
282
+ throw new Error("Attributed target repo label does not match its local root.");
283
+ }
284
+ if (!identity.worktree_is_primary) {
285
+ throw new Error("Local attributed targets must use their primary local identity.");
286
+ }
287
+ }
288
+ return {
289
+ ...identity,
290
+ requested_path: canonicalRoot,
291
+ repo_root: canonicalRoot,
292
+ };
293
+ }
228
294
  export async function inspectLocalCollectorStatus(options = {}) {
229
295
  const now = options.now ?? new Date();
230
296
  const homeDir = options.homeDir ?? os.homedir();
@@ -405,7 +471,7 @@ async function resolveIdentityOrFallback(repoRoot, branchOverride) {
405
471
  return branchOverride ? { ...identity, branch: branchOverride } : identity;
406
472
  }
407
473
  const repoLabel = path.basename(resolvedRoot) || "workspace";
408
- const repoFingerprint = `repo-${sha256(`local:${resolvedRoot}`).slice(0, 24)}`;
474
+ const repoFingerprint = repoFingerprintFromLocalRoot(resolvedRoot);
409
475
  const worktreeFingerprint = stableWorktreeFingerprint(resolvedRoot);
410
476
  const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
411
477
  return {
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import { realpathSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { normalizeCollectionRoots } from "./root-normalization.js";
5
+ import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
6
6
  export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
7
7
  export function homeRootConsentPrompt(homeDirInput) {
8
8
  const homeDir = path.resolve(homeDirInput ?? os.homedir());
@@ -85,33 +85,53 @@ export function normalizeRoots(roots) {
85
85
  return normalizeRootsDetailed(roots).roots;
86
86
  }
87
87
  export function normalizeRootsDetailed(roots, options = {}) {
88
+ const pathApi = options.pathApi ?? path;
88
89
  const seen = new Set();
89
90
  const candidates = [];
90
91
  const rejected = [];
91
- const homeDir = path.resolve(options.homeDir ?? os.homedir());
92
+ const homeDir = pathApi.resolve(options.homeDir ?? os.homedir());
93
+ const canonicalHomeDir = canonicalPath(homeDir, pathApi, options.realpath);
92
94
  for (const root of roots) {
93
95
  const trimmed = root.trim();
94
96
  if (!trimmed)
95
97
  continue;
96
98
  for (const candidate of splitRootInput(trimmed)) {
97
- const resolved = path.resolve(candidate);
98
- if (resolved === path.parse(resolved).root) {
99
+ const expanded = expandHomePath(candidate, homeDir, pathApi);
100
+ const resolved = pathApi.resolve(expanded);
101
+ const canonicalResolved = canonicalPath(resolved, pathApi, options.realpath);
102
+ if (isSamePath(resolved, pathApi.parse(resolved).root, pathApi) ||
103
+ isSamePath(canonicalResolved, pathApi.parse(canonicalResolved).root, pathApi)) {
99
104
  rejected.push({ input: candidate, reason: "fs_root" });
100
105
  continue;
101
106
  }
102
- if (resolved === homeDir) {
107
+ if (isSamePath(resolved, homeDir, pathApi) ||
108
+ isSamePath(canonicalResolved, canonicalHomeDir, pathApi)) {
103
109
  if (!options.allowHomeRoot) {
104
110
  rejected.push({ input: candidate, reason: "home_dir" });
105
111
  continue;
106
112
  }
107
113
  }
108
- if (seen.has(resolved))
114
+ const seenKey = pathApi.sep === "\\"
115
+ ? canonicalResolved.toLowerCase()
116
+ : canonicalResolved;
117
+ if (seen.has(seenKey))
109
118
  continue;
110
- seen.add(resolved);
119
+ seen.add(seenKey);
111
120
  candidates.push(resolved);
112
121
  }
113
122
  }
114
- return { roots: normalizeCollectionRoots(candidates), rejected };
123
+ return {
124
+ roots: normalizeCollectionRoots(candidates, pathApi),
125
+ rejected,
126
+ };
127
+ }
128
+ export function expandHomePath(value, homeDir, pathApi = path) {
129
+ if (value === "~")
130
+ return pathApi.resolve(homeDir);
131
+ if (!/^~[\\/]/u.test(value))
132
+ return value;
133
+ const parts = value.slice(2).split(/[\\/]+/u).filter(Boolean);
134
+ return pathApi.resolve(homeDir, ...parts);
115
135
  }
116
136
  function splitRootInput(value) {
117
137
  return value
@@ -241,7 +261,7 @@ async function promptForDeclinedHomeRoot(options, homeDir) {
241
261
  throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE}`);
242
262
  }
243
263
  function resolveRootInputPath(input, homeDir, cwd) {
244
- const expanded = input === "~" || input.startsWith("~/")
264
+ const expanded = input === "~" || /^~[\\/]/u.test(input)
245
265
  ? path.join(homeDir, input.slice(2))
246
266
  : input;
247
267
  return path.resolve(cwd, expanded);
@@ -257,17 +277,17 @@ function resolvedRoots(options, roots, source, confirmed) {
257
277
  // the home-root guard on the decline path (e.g. `~/home-link -> /Users/me`).
258
278
  // Non-existent paths (or realpath errors) fall back to the lexical resolve so
259
279
  // behavior is unchanged for the retry branch and for tests using fake homedirs.
260
- function canonicalPath(input) {
261
- const resolved = path.resolve(input);
280
+ function canonicalPath(input, pathApi = path, realpath = realpathSync) {
281
+ const resolved = pathApi.resolve(input);
262
282
  try {
263
- return realpathSync(resolved);
283
+ return pathApi.resolve(realpath(resolved));
264
284
  }
265
285
  catch {
266
286
  return resolved;
267
287
  }
268
288
  }
269
289
  function isHomeRoot(root, homeDir) {
270
- return canonicalPath(root) === canonicalPath(homeDir ?? os.homedir());
290
+ return isSamePath(canonicalPath(root), canonicalPath(homeDir ?? os.homedir()));
271
291
  }
272
292
  function homeRootTutorial(homeDirInput) {
273
293
  const homeDir = path.resolve(homeDirInput ?? os.homedir());
@@ -305,12 +325,17 @@ export function missingCollectionRootMessage(options = {}) {
305
325
  " 3) Stop and rerun with a flag: cockpit onboard --workspace ~/BLI",
306
326
  ].join("\n");
307
327
  }
308
- function likelyBliRootFromCwd(cwd) {
309
- const parts = cwd.split(path.sep).filter(Boolean);
310
- const index = parts.lastIndexOf("BLI");
328
+ export function likelyBliRootFromCwd(cwd, pathApi = path) {
329
+ const root = pathApi.parse(cwd).root;
330
+ const parts = pathApi
331
+ .relative(root, cwd)
332
+ .split(pathApi.sep)
333
+ .filter(Boolean);
334
+ const comparableParts = pathApi.sep === "\\" ? parts.map((part) => part.toLowerCase()) : parts;
335
+ const index = comparableParts.lastIndexOf(pathApi.sep === "\\" ? "bli" : "BLI");
311
336
  if (index < 0)
312
337
  return null;
313
- return `${path.sep}${parts.slice(0, index + 1).join(path.sep)}`;
338
+ return pathApi.resolve(root, ...parts.slice(0, index + 1));
314
339
  }
315
340
  async function directoryExists(dir) {
316
341
  return fs.stat(dir).then((stats) => stats.isDirectory(), () => false);
@@ -0,0 +1,103 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ const WINDOWS_CMD_SHIMS = new Set(["cockpit", "npm", "npx"]);
3
+ const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/gu;
4
+ /**
5
+ * Resolves commands that npm installs as `.cmd` shims through ComSpec on
6
+ * Windows. Arguments are escaped before they enter cmd.exe so workspace paths
7
+ * cannot turn into extra shell commands.
8
+ */
9
+ export function commandInvocation(command, args, options = {}) {
10
+ const platform = options.platform ?? process.platform;
11
+ const env = options.env ?? process.env;
12
+ if (platform !== "win32" || !isWindowsCmdShim(command)) {
13
+ return { command, args, windowsVerbatimArguments: false };
14
+ }
15
+ const comspec = env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
16
+ return {
17
+ command: comspec,
18
+ args: ["/d", "/s", "/c", `"${windowsCmdShimLine(command, args)}"`],
19
+ windowsVerbatimArguments: true,
20
+ };
21
+ }
22
+ /** A fully escaped command line suitable for cmd.exe's `/c` payload. */
23
+ export function windowsCmdShimLine(command, args) {
24
+ const shim = windowsShimName(command);
25
+ return [escapeWindowsCommand(shim), ...args.map(escapeWindowsCmdArgument)].join(" ");
26
+ }
27
+ export function createCapturedExecRunner(options = {}) {
28
+ return (command, args, runOptions) => new Promise((resolve) => {
29
+ const env = runOptions?.env ?? options.env ?? process.env;
30
+ const invocation = commandInvocation(command, args, {
31
+ platform: options.platform,
32
+ env,
33
+ });
34
+ execFile(invocation.command, invocation.args, {
35
+ encoding: "utf8",
36
+ env,
37
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
38
+ }, (error, stdout, stderr) => {
39
+ const code = error == null
40
+ ? 0
41
+ : typeof error.code === "number"
42
+ ? error.code
43
+ : 1;
44
+ resolve({
45
+ code,
46
+ stdout: stdout ?? "",
47
+ stderr: stderr || error?.message || "",
48
+ });
49
+ });
50
+ });
51
+ }
52
+ export function createInteractiveExecRunner(options = {}) {
53
+ return (command, args, runOptions) => new Promise((resolve) => {
54
+ const env = runOptions?.env ?? options.env ?? process.env;
55
+ const invocation = commandInvocation(command, args, {
56
+ platform: options.platform,
57
+ env,
58
+ });
59
+ const child = spawn(invocation.command, invocation.args, {
60
+ stdio: "inherit",
61
+ env,
62
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
63
+ });
64
+ let settled = false;
65
+ child.on("error", (error) => {
66
+ if (settled)
67
+ return;
68
+ settled = true;
69
+ resolve({ code: 1, stdout: "", stderr: error.message });
70
+ });
71
+ child.on("close", (code) => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ resolve({ code: code ?? 1, stdout: "", stderr: "" });
76
+ });
77
+ });
78
+ }
79
+ function isWindowsCmdShim(command) {
80
+ const normalized = command.replace(/\\/gu, "/");
81
+ const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
82
+ return (basename.endsWith(".cmd") ||
83
+ basename.endsWith(".bat") ||
84
+ WINDOWS_CMD_SHIMS.has(basename));
85
+ }
86
+ function windowsShimName(command) {
87
+ return /\.(?:cmd|bat)$/iu.test(command) ? command : `${command}.cmd`;
88
+ }
89
+ function escapeWindowsCommand(value) {
90
+ return value.replace(CMD_META_CHARS, "^$1");
91
+ }
92
+ /**
93
+ * Mirrors the escaping used by mature Windows process runners for `.cmd`
94
+ * shims. Meta characters are escaped twice because cmd.exe parses once and the
95
+ * npm-generated shim parses again.
96
+ */
97
+ function escapeWindowsCmdArgument(value) {
98
+ let escaped = value.replace(/(?=(\\+?)?)\1"/gu, '$1$1\\"');
99
+ escaped = escaped.replace(/(?=(\\+?)?)\1$/gu, "$1$1");
100
+ escaped = `"${escaped}"`;
101
+ escaped = escaped.replace(CMD_META_CHARS, "^$1");
102
+ return escaped.replace(CMD_META_CHARS, "^$1");
103
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Historical backfill and attribution diagnostics may retain any deterministic
3
+ * fallback that produced a worktree identity. Ambiguous and unattributed
4
+ * sessions still need a separate quarantine lane so collection does not invent
5
+ * repo provenance.
6
+ */
7
+ export function isRawEvidenceUploadableAttributionState(state) {
8
+ return state === "attributed" || state === "attributed_fallback";
9
+ }
10
+ const LIVE_WORKTREE_FALLBACK_REASONS = new Set([
11
+ "known_repo_primary_fallback",
12
+ "single_repo_folder_fallback",
13
+ "multi_repo_folder_workspace",
14
+ "transcript_origin_fallback",
15
+ ]);
16
+ /**
17
+ * Continuous sync transports exact attribution plus the explicit fallback
18
+ * identities it knows how to validate and materialize as sync targets. Future
19
+ * fallback reasons stay fail-closed until their provenance contract is added.
20
+ */
21
+ export function isLiveRawEvidenceSyncAttribution(state, reason) {
22
+ return (state === "attributed" ||
23
+ (state === "attributed_fallback" &&
24
+ LIVE_WORKTREE_FALLBACK_REASONS.has(reason)));
25
+ }
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { containsPath, isCodexWorktreePath } from "./root-normalization.js";
6
7
  const execFileAsync = promisify(execFile);
7
8
  const SKIPPED_DIR_NAMES = new Set([
8
9
  ".cache",
@@ -51,19 +52,33 @@ export async function resolveRepoWorktreeIdentity(repoRoot) {
51
52
  };
52
53
  }
53
54
  export async function discoverGitWorktrees(root, options = {}) {
55
+ return (await discoverGitWorktreesWithStatus(root, options)).worktrees;
56
+ }
57
+ /**
58
+ * Discovers worktrees while retaining whether traversal limits could have
59
+ * hidden additional repositories. Callers that make collection-completeness
60
+ * claims must use this result instead of treating a truncated list as whole.
61
+ */
62
+ export async function discoverGitWorktreesWithStatus(root, options = {}) {
54
63
  const resolvedRoot = path.resolve(root);
55
- const maxWorktrees = options.maxWorktrees ?? 50;
64
+ const allowedRoots = [await stableWorktreeRoot(resolvedRoot)];
65
+ const maxWorktrees = Math.max(1, options.maxWorktrees ?? 50);
56
66
  const direct = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
57
67
  if (direct)
58
- return expandLinkedWorktrees([direct], maxWorktrees);
68
+ return expandLinkedWorktrees([direct], maxWorktrees, allowedRoots);
59
69
  if (await hasGitMarker(resolvedRoot)) {
60
- return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees);
70
+ return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees, allowedRoots);
61
71
  }
62
72
  const maxDepth = options.maxDepth ?? 2;
63
73
  const discovered = new Map();
64
74
  const stack = [{ dir: resolvedRoot, depth: 0 }];
65
75
  const visited = new Set();
66
- while (stack.length > 0 && discovered.size < maxWorktrees) {
76
+ const incompleteReasons = new Set();
77
+ while (stack.length > 0) {
78
+ if (discovered.size >= maxWorktrees) {
79
+ incompleteReasons.add("max_worktrees_reached");
80
+ break;
81
+ }
67
82
  const current = stack.shift();
68
83
  if (!current)
69
84
  continue;
@@ -77,16 +92,85 @@ export async function discoverGitWorktrees(root, options = {}) {
77
92
  discovered.set(identity.worktree_fingerprint, identity);
78
93
  continue;
79
94
  }
80
- if (current.depth >= maxDepth)
81
- continue;
82
95
  const entries = await fs.readdir(current.dir, { withFileTypes: true }).catch(() => []);
96
+ if (current.depth >= maxDepth) {
97
+ if (entries.some((entry) => entry.isDirectory() && !shouldSkipDirectory(entry.name))) {
98
+ incompleteReasons.add("max_depth_reached");
99
+ }
100
+ continue;
101
+ }
83
102
  for (const entry of entries) {
84
103
  if (!entry.isDirectory() || shouldSkipDirectory(entry.name))
85
104
  continue;
86
105
  stack.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
87
106
  }
88
107
  }
89
- return expandLinkedWorktrees([...discovered.values()].sort(compareIdentity), maxWorktrees);
108
+ const expanded = await expandLinkedWorktrees([...discovered.values()].sort(compareIdentity), maxWorktrees, allowedRoots);
109
+ for (const reason of expanded.incomplete_reasons) {
110
+ incompleteReasons.add(reason);
111
+ }
112
+ return {
113
+ worktrees: expanded.worktrees,
114
+ complete: incompleteReasons.size === 0,
115
+ incomplete_reasons: [...incompleteReasons].sort(),
116
+ };
117
+ }
118
+ /**
119
+ * Discovers and deduplicates git worktrees across the exact roots supplied by
120
+ * the caller. An approved root with no current git checkout intentionally
121
+ * contributes zero candidates: transcript attribution may still reconstruct a
122
+ * deleted-repo identity, while discovery itself never invents a broader or
123
+ * unrelated root.
124
+ */
125
+ export async function discoverGitWorktreesInRoots(roots, options = {}) {
126
+ return (await discoverGitWorktreesInRootsWithStatus(roots, options)).worktrees;
127
+ }
128
+ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {}) {
129
+ const maxWorktrees = Math.max(1, options.maxWorktrees ?? 50);
130
+ const discovered = new Map();
131
+ const incompleteReasons = new Set();
132
+ for (const root of roots) {
133
+ const result = await discoverGitWorktreesWithStatus(root, {
134
+ ...options,
135
+ maxWorktrees,
136
+ });
137
+ for (const reason of result.incomplete_reasons) {
138
+ incompleteReasons.add(reason);
139
+ }
140
+ for (const worktree of result.worktrees) {
141
+ if (discovered.size >= maxWorktrees) {
142
+ if (!discovered.has(worktree.worktree_fingerprint)) {
143
+ incompleteReasons.add("max_worktrees_reached");
144
+ }
145
+ continue;
146
+ }
147
+ discovered.set(worktree.worktree_fingerprint, worktree);
148
+ }
149
+ }
150
+ return {
151
+ worktrees: [...discovered.values()].sort(compareIdentity),
152
+ complete: incompleteReasons.size === 0,
153
+ incomplete_reasons: [...incompleteReasons].sort(),
154
+ };
155
+ }
156
+ /**
157
+ * Canonicalizes existing collection roots through the filesystem so transcript
158
+ * paths and consent roots use the same spelling (for example `/private/var`
159
+ * versus the `/var` symlink on macOS). Missing roots remain resolved as typed.
160
+ */
161
+ export async function canonicalizeCollectionRootPaths(roots) {
162
+ const canonical = await Promise.all(roots.map(stableWorktreeRoot));
163
+ return [...new Set(canonical)];
164
+ }
165
+ /**
166
+ * Returns the operator-entered resolved paths plus filesystem-canonical aliases.
167
+ * Both are needed for absent child repos because the child itself cannot be
168
+ * realpathed after deletion, while its transcript may retain either spelling.
169
+ */
170
+ export async function collectionRootPathAliases(roots) {
171
+ const resolved = roots.map((root) => path.resolve(root));
172
+ const canonical = await canonicalizeCollectionRootPaths(resolved);
173
+ return [...new Set([...resolved, ...canonical])];
90
174
  }
91
175
  /**
92
176
  * Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
@@ -99,28 +183,60 @@ export async function discoverGitWorktrees(root, options = {}) {
99
183
  * deepest-root tie-break (attribution-core D6) then attributes nested-worktree
100
184
  * sessions to the correct linked worktree. Benefits Codex sessions identically.
101
185
  */
102
- async function expandLinkedWorktrees(identities, maxWorktrees) {
186
+ async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
103
187
  const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
104
- const seenRoots = new Set(identities.map((identity) => path.resolve(identity.repo_root)));
105
- for (const identity of identities) {
106
- if (byFingerprint.size >= maxWorktrees)
107
- break;
188
+ const seenRoots = new Set(identities.map((identity) => localPathKey(identity.repo_root)));
189
+ let maxWorktreesReached = identities.length > maxWorktrees;
190
+ for (const identity of identities.slice(0, maxWorktrees)) {
108
191
  // One `git worktree list` from any worktree returns every worktree of that
109
192
  // repo, so a single call per already-discovered repo covers its linked set.
110
193
  for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
111
- if (byFingerprint.size >= maxWorktrees)
112
- break;
113
194
  const resolved = path.resolve(worktreePath);
114
- if (seenRoots.has(resolved))
195
+ const rootKey = localPathKey(resolved);
196
+ if (seenRoots.has(rootKey))
115
197
  continue;
116
- seenRoots.add(resolved);
198
+ seenRoots.add(rootKey);
117
199
  const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
118
- if (linked && !byFingerprint.has(linked.worktree_fingerprint)) {
119
- byFingerprint.set(linked.worktree_fingerprint, linked);
200
+ if (!linked ||
201
+ byFingerprint.has(linked.worktree_fingerprint) ||
202
+ !isLinkedWorktreeWithinCollectionScope(linked, identities, allowedRoots)) {
203
+ continue;
204
+ }
205
+ if (byFingerprint.size >= maxWorktrees) {
206
+ maxWorktreesReached = true;
207
+ break;
120
208
  }
209
+ byFingerprint.set(linked.worktree_fingerprint, linked);
121
210
  }
122
211
  }
123
- return [...byFingerprint.values()].sort(compareIdentity);
212
+ const worktrees = [...byFingerprint.values()]
213
+ .sort(compareIdentity)
214
+ .slice(0, maxWorktrees);
215
+ const incompleteReasons = maxWorktreesReached
216
+ ? ["max_worktrees_reached"]
217
+ : [];
218
+ return {
219
+ worktrees,
220
+ complete: incompleteReasons.length === 0,
221
+ incomplete_reasons: incompleteReasons,
222
+ };
223
+ }
224
+ function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
225
+ if (allowedRoots.some((root) => containsPath(root, linked.repo_root))) {
226
+ return true;
227
+ }
228
+ // Codex isolation worktrees live under ~/.codex/worktrees, outside the
229
+ // approved workspace parent. They remain in scope only when Git proves they
230
+ // belong to a clone discovered inside an approved root. Arbitrary sibling or
231
+ // personal linked worktrees do not inherit that consent.
232
+ if (!isCodexWorktreePath(linked.repo_root))
233
+ return false;
234
+ return discoveredFromApprovedRoots.some((identity) => identity.repo_fingerprint === linked.repo_fingerprint &&
235
+ allowedRoots.some((root) => containsPath(root, identity.repo_root)));
236
+ }
237
+ function localPathKey(value) {
238
+ const resolved = path.resolve(value);
239
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
124
240
  }
125
241
  async function listLinkedWorktreePaths(repoRoot) {
126
242
  const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
@@ -158,11 +274,11 @@ export async function stableWorktreeRoot(repoRoot) {
158
274
  const resolvedRoot = path.resolve(repoRoot);
159
275
  return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
160
276
  }
161
- export function stableWorktreeFingerprint(repoRoot) {
162
- return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot)}`).slice(0, 24)}`;
277
+ export function stableWorktreeFingerprint(repoRoot, pathApi = path) {
278
+ return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot, pathApi)}`).slice(0, 24)}`;
163
279
  }
164
- export function repoFingerprintFromLocalRoot(root) {
165
- return `repo-${sha256(`local:${path.resolve(root)}`).slice(0, 24)}`;
280
+ export function repoFingerprintFromLocalRoot(root, pathApi = path) {
281
+ return `repo-${sha256(`local:${normalizeFingerprintPath(root, pathApi)}`).slice(0, 24)}`;
166
282
  }
167
283
  export function repoFingerprintFromOrigin(origin) {
168
284
  const normalizedOrigin = normalizeGitOrigin(origin);
@@ -251,9 +367,9 @@ async function runGit(args, cwd) {
251
367
  });
252
368
  return stdout;
253
369
  }
254
- function normalizeFingerprintPath(repoRoot) {
255
- const resolved = path.resolve(repoRoot);
256
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
370
+ function normalizeFingerprintPath(repoRoot, pathApi = path) {
371
+ const resolved = pathApi.resolve(repoRoot);
372
+ return pathApi.sep === "\\" ? resolved.toLowerCase() : resolved;
257
373
  }
258
374
  function sha256(value) {
259
375
  return crypto.createHash("sha256").update(value, "utf8").digest("hex");
@@ -1,12 +1,13 @@
1
1
  import path from "node:path";
2
- export function collapseAncestorRoots(roots) {
2
+ export function collapseAncestorRoots(roots, pathApi = path) {
3
3
  const collapsed = [];
4
4
  for (const root of roots) {
5
- const resolved = path.resolve(root);
6
- if (collapsed.some((existing) => containsPath(existing, resolved)))
5
+ const resolved = pathApi.resolve(root);
6
+ if (collapsed.some((existing) => containsPath(existing, resolved, pathApi))) {
7
7
  continue;
8
+ }
8
9
  for (let index = collapsed.length - 1; index >= 0; index -= 1) {
9
- if (containsPath(resolved, collapsed[index])) {
10
+ if (containsPath(resolved, collapsed[index], pathApi)) {
10
11
  collapsed.splice(index, 1);
11
12
  }
12
13
  }
@@ -14,21 +15,33 @@ export function collapseAncestorRoots(roots) {
14
15
  }
15
16
  return collapsed;
16
17
  }
17
- export function normalizeCollectionRoots(roots) {
18
- const collapsed = collapseAncestorRoots(roots);
19
- if (!collapsed.some(isBliWorkspaceRoot))
18
+ export function normalizeCollectionRoots(roots, pathApi = path) {
19
+ const collapsed = collapseAncestorRoots(roots, pathApi);
20
+ if (!collapsed.some((root) => isBliWorkspaceRoot(root, pathApi))) {
20
21
  return collapsed;
21
- return collapsed.filter((root) => !isCodexWorktreePath(root));
22
+ }
23
+ return collapsed.filter((root) => !isCodexWorktreePath(root, pathApi));
22
24
  }
23
- function containsPath(parent, candidate) {
24
- const relative = path.relative(parent, candidate);
25
+ export function containsPath(parent, candidate, pathApi = path) {
26
+ const relative = pathApi.relative(parent, candidate);
25
27
  return (relative === "" ||
26
- (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)));
28
+ (!!relative &&
29
+ relative !== ".." &&
30
+ !relative.startsWith(`..${pathApi.sep}`) &&
31
+ !pathApi.isAbsolute(relative)));
32
+ }
33
+ export function isSamePath(left, right, pathApi = path) {
34
+ return pathApi.relative(left, right) === "";
35
+ }
36
+ function comparableSegment(value, pathApi) {
37
+ return pathApi.sep === "\\" ? value.toLowerCase() : value;
27
38
  }
28
- function isBliWorkspaceRoot(root) {
29
- return path.basename(root) === "BLI";
39
+ function isBliWorkspaceRoot(root, pathApi) {
40
+ return comparableSegment(pathApi.basename(root), pathApi) ===
41
+ comparableSegment("BLI", pathApi);
30
42
  }
31
- function isCodexWorktreePath(root) {
32
- const parts = root.split(path.sep).filter(Boolean);
33
- return parts.some((part, index) => part === ".codex" && parts[index + 1] === "worktrees");
43
+ export function isCodexWorktreePath(root, pathApi = path) {
44
+ const parts = root.split(pathApi.sep).filter(Boolean);
45
+ return parts.some((part, index) => comparableSegment(part, pathApi) === ".codex" &&
46
+ comparableSegment(parts[index + 1] ?? "", pathApi) === "worktrees");
34
47
  }