@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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Cockpit watches how you and your AI agents work so Edward can coach from evidenc
4
4
 
5
5
  ## 90-second speedrun
6
6
 
7
- Run these three commands on the Mac that should be collected:
7
+ Run these three commands on the Mac or Windows PC that should be collected:
8
8
 
9
9
  ```bash
10
10
  $ npm install -g @bli-cockpit/cli@latest
@@ -12,7 +12,7 @@ added <packages> in <seconds>s
12
12
  ```
13
13
 
14
14
  ```bash
15
- $ cockpit onboard
15
+ $ cockpit do-everything
16
16
  Cockpit harvest onboarding
17
17
  Dashboard: https://bli-cockpit-dashboard.vercel.app
18
18
  Ticket: general ambient
@@ -45,14 +45,22 @@ The OTP proves you own an approved BLI mailbox. The JWT is used once to register
45
45
 
46
46
  ## Every command, what it does, and why it's called that
47
47
 
48
- On a blank Mac, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough to sign in, choose roots, and converge the machine. `onboard` remains the named setup subset, and the rest exist for recovery and maintenance.
48
+ On a blank Mac or Windows PC, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough to sign in, choose roots, and converge the machine. `onboard` remains the named setup subset, and the rest exist for recovery and maintenance.
49
+
50
+ The core collection contract is the same on Apple Silicon macOS and native
51
+ Windows: every eligible Codex and Claude session attributable to an approved
52
+ root is collected, redacted before ingest, and either uploaded with a durable
53
+ receipt or surfaced with an explicit ambiguity, skip, or retry reason. A green
54
+ machine must not hide silently missing sessions. This support claim remains
55
+ release-gated: the packed candidate must pass normal-user canaries on both host
56
+ families before its exact version is promoted to npm `latest`.
49
57
 
50
58
  | Command | What it does | Why it exists / why this name |
51
59
  |---|---|---|
52
- | `cockpit onboard` | The everything-command: signs you in (email code), registers this Mac, starts capture, uploads once, installs the 15-min background sync, and prints proof you're live. | You are boarding the crew. Run it once per machine; rerunning is always safe. |
53
- | `cockpit do-everything` / `cockpit fix` | Converges a Mac from blank or already-onboarded state: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. Interactive first runs prompt for email OTP and collection roots; headless/`--json` runs explain and exit instead of blocking. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
60
+ | `cockpit onboard` | The named setup subset: signs you in (email code), registers this machine, starts capture, uploads once, completes all-history Codex and Claude backfill for saved approved roots, installs the 15-min background sync, and only then prints proof you're live. | You are boarding the crew. `do-everything` calls this flow when setup is missing; rerunning it directly is safe. Partial or failed backfill blocks readiness with an exact retry reason. |
61
+ | `cockpit do-everything` / `cockpit fix` | Converges a Mac or Windows PC from blank or already-onboarded state: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. Interactive first runs prompt for email OTP and collection roots; headless/`--json` runs explain and exit instead of blocking. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
54
62
  | `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
55
- | `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this Mac). | One-time catch-up so your past work counts too. "Backfill" = fill in the back-catalog. |
63
+ | `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this machine). Discovery defaults to depth 3 and 50 repos; raise `--max-depth` or `--max-repos` when the returned retry command says a cap was reached. | One-time catch-up so your past work counts too. "Backfill" = fill in the back-catalog. |
56
64
  | `cockpit sync` | Captures and uploads once, right now. This is what the background agent runs every 15 min — you almost never type it yourself. | Named for what it does: synchronize local session files up to the dashboard. |
57
65
  | `cockpit analyze` | Runs a fresh sync, then queues batch analysis of your latest uploaded sessions. It returns immediately; status and results appear in My Work. | One command when you want newly finished work uploaded and judged now. |
58
66
  | `cockpit start --ticket <id>` | Tags your CURRENT work with a Linear ticket so sessions attribute to it. `--clear-ticket` returns to general capture. | "Start (working on) X." Agents usually run this for you per the agent rules. |
@@ -61,7 +69,7 @@ On a blank Mac, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough t
61
69
  | `cockpit update` / `cockpit upgrade` | Updates the CLI itself from npm, then reruns onboard. Two names, one command — aliases because half of people guess each. | Self-update; run when Edward announces a new version. |
62
70
  | `cockpit install` | Writes local config only (step 1 of onboard, standalone). | Plumbing; exists so onboard's pieces are individually runnable. You'll likely never type it. |
63
71
  | `cockpit sessions` | Read-only: lists your observed sessions and how each attributed. | Debugging "why isn't my session showing up?" without uploading anything. |
64
- | `cockpit autostart install\|uninstall\|status` | Manages the macOS launchd agent behind the 15-min background sync. | Direct control of the background piece when you need to stop/inspect it. |
72
+ | `cockpit autostart install\|uninstall\|status` | Manages the macOS launchd agent or per-user Windows Task Scheduler task behind the 15-min background sync. | Direct control of the background piece when you need to stop/inspect it. |
65
73
  | `cockpit agent-rules install\|uninstall\|status` | Manages the Cockpit ticket-binding text block inside `~/.codex/AGENTS.md` / `~/.claude/CLAUDE.md`. | It's literally "the rules your agents read." Passive markdown — never an executable hook. |
66
74
  | `cockpit serve` | Runs a tiny local HTTP status server. | Niche: lets other tools ask the collector how it's doing. |
67
75
  | `cockpit logout`, `cockpit release`, `cockpit admin *` | `release` publishes the CLI (maintainer, repo-gated); `admin` (bootstrap/invite) is absent from the public build entirely. | Maintainer surface — interns never touch these. |
@@ -94,6 +102,10 @@ cockpit onboard --no-auth
94
102
 
95
103
  `cockpit update` installs the latest public CLI and reruns onboarding checks against saved roots. `cockpit upgrade` is the same command.
96
104
 
105
+ `cockpit --version`, `cockpit -V`, and `cockpit version` print the installed
106
+ public package version. Windows autostart details and verification steps are in
107
+ [`docs/runbooks/cockpit-windows-task-scheduler-sync.md`](../../docs/runbooks/cockpit-windows-task-scheduler-sync.md).
108
+
97
109
  ## Analyze latest work
98
110
 
99
111
  ```bash
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { realpathSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, } from "../repo-identity.js";
4
5
  /**
@@ -24,6 +25,12 @@ export const ATTRIBUTED_FALLBACK_MAX_SCORE = 0.39;
24
25
  export const SESSION_FILE_UUID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
25
26
  export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
26
27
  const originLabel = options.originLabel ?? "origin_url_match";
28
+ const collectionRoots = collectionRootComparisonAliases(options.collectionRoots ?? []);
29
+ const recordedPaths = [...signals.cwds, ...signals.workspaceRoots];
30
+ const approvedRecordedPaths = recordedPathsWithinCollectionRoots(recordedPaths, collectionRoots);
31
+ const scopedWorktrees = worktreesWithinCollectionScope(worktrees, collectionRoots);
32
+ const cwdComparisonPaths = pathComparisonAliases(signals.cwds);
33
+ const workspaceRootComparisonPaths = pathComparisonAliases(signals.workspaceRoots);
27
34
  const hasAnySignal = signals.cwds.length > 0 ||
28
35
  signals.workspaceRoots.length > 0 ||
29
36
  signals.originUrls.length > 0 ||
@@ -38,9 +45,9 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
38
45
  // nested package that is itself a worktree). Crediting every containing root
39
46
  // leaves the session ambiguous forever. Instead, only the DEEPEST containing
40
47
  // root earns the path score, which is also the most specific (correct) match.
41
- const cwdWinners = deepestContainers(signals.cwds, worktrees);
42
- const workspaceRootWinners = deepestContainers(signals.workspaceRoots, worktrees);
43
- const scored = worktrees.map((worktree) => {
48
+ const cwdWinners = deepestContainers(cwdComparisonPaths, scopedWorktrees);
49
+ const workspaceRootWinners = deepestContainers(workspaceRootComparisonPaths, scopedWorktrees);
50
+ const scored = scopedWorktrees.map((worktree) => {
44
51
  const matched = [];
45
52
  let score = 0;
46
53
  let pathScore = 0;
@@ -73,21 +80,23 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
73
80
  const best = scored[0];
74
81
  const secondBestScore = scored[1]?.score ?? 0;
75
82
  if (!best || best.score === 0) {
76
- const recordedPaths = [...signals.cwds, ...signals.workspaceRoots];
77
- if (repoPathAbsentFromDisk(recordedPaths, options.collectionRoots ?? [])) {
83
+ if (approvedRecordedPaths.length > 0) {
78
84
  const folderFallback = fallbackToFolderWorkspaceForContainerCwd({
79
85
  signals,
80
- worktrees,
86
+ worktrees: scopedWorktrees,
87
+ approvedRecordedPaths,
81
88
  });
82
89
  if (folderFallback)
83
90
  return folderFallback;
84
91
  const transcriptFallback = fallbackToTranscriptOriginForDeletedRepo({
85
92
  originLabel,
86
93
  signals,
94
+ collectionRoots,
95
+ pathExists: options.pathExists,
87
96
  });
88
97
  if (transcriptFallback)
89
98
  return transcriptFallback;
90
- return skipped(terminalReasonForRecordedPaths(recordedPaths, options.pathExists));
99
+ return skipped(terminalReasonForRecordedPaths(approvedRecordedPaths, options.pathExists));
91
100
  }
92
101
  const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
93
102
  ? "cwd_outside_scanned_worktrees"
@@ -109,11 +118,16 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
109
118
  };
110
119
  }
111
120
  if (best.pathScore === 0) {
121
+ if (collectionRoots.length > 0 && approvedRecordedPaths.length === 0) {
122
+ return unattributed(recordedPaths.length > 0
123
+ ? "cwd_outside_scanned_worktrees"
124
+ : "no_matching_worktree_signals");
125
+ }
112
126
  const fallback = fallbackToKnownRepoPrimaryWorkContext({
113
127
  originLabel,
114
128
  scored,
115
129
  signals,
116
- worktrees,
130
+ worktrees: scopedWorktrees,
117
131
  });
118
132
  if (fallback)
119
133
  return fallback;
@@ -157,10 +171,9 @@ function skipped(reason) {
157
171
  * root.
158
172
  */
159
173
  function fallbackToFolderWorkspaceForContainerCwd(options) {
160
- const recordedPaths = [...options.signals.cwds, ...options.signals.workspaceRoots];
161
174
  let bestFolder = null;
162
- for (const recordedPath of recordedPaths) {
163
- const resolvedPath = path.resolve(recordedPath);
175
+ for (const recordedPath of pathComparisonAliases(options.approvedRecordedPaths)) {
176
+ const resolvedPath = resolveAttributionPath(recordedPath);
164
177
  const containedWorktrees = options.worktrees.filter((worktree) => isPathStrictlyWithin(worktree.repo_root, resolvedPath));
165
178
  if (containedWorktrees.length === 0)
166
179
  continue;
@@ -185,7 +198,7 @@ function fallbackToFolderWorkspaceForContainerCwd(options) {
185
198
  worktree: primary,
186
199
  };
187
200
  }
188
- const repoLabel = path.basename(bestFolder.resolvedPath);
201
+ const repoLabel = basenameForAttributionPath(bestFolder.resolvedPath);
189
202
  return {
190
203
  state: "attributed_fallback",
191
204
  reason: "multi_repo_folder_workspace",
@@ -213,7 +226,7 @@ function fallbackToKnownRepoPrimaryWorkContext(options) {
213
226
  return null;
214
227
  const primaryByClone = originCandidates.filter((worktree) => worktree.worktree_is_primary);
215
228
  const cloneCandidates = primaryByClone.length > 0 ? primaryByClone : originCandidates;
216
- const cloneRoots = new Set(cloneCandidates.map((worktree) => path.resolve(worktree.repo_root)));
229
+ const cloneRoots = new Set(cloneCandidates.map((worktree) => normalizedAttributionPathKey(worktree.repo_root)));
217
230
  if (cloneRoots.size > 1) {
218
231
  return {
219
232
  state: "ambiguous",
@@ -262,10 +275,26 @@ function fallbackToTranscriptOriginForDeletedRepo(options) {
262
275
  return null;
263
276
  if (origins.length > 1)
264
277
  return skipped("multiple_transcript_origins");
265
- const recordedPath = options.signals.workspaceRoots[0] ?? options.signals.cwds[0] ?? null;
278
+ if (!options.pathExists)
279
+ return null;
280
+ // Prefer an explicitly recorded workspace root, but only after proving that
281
+ // exact selected path remains inside the operator-approved boundary. A
282
+ // different cwd being approved must never authorize an outside workspace
283
+ // root (or vice versa).
284
+ const recordedPath = [...options.signals.workspaceRoots, ...options.signals.cwds].find((value) => {
285
+ if (!options.collectionRoots.some((root) => isPathWithin(value, root))) {
286
+ return false;
287
+ }
288
+ try {
289
+ return !options.pathExists?.(value);
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }) ?? null;
266
295
  if (!recordedPath)
267
296
  return null;
268
- const resolvedPath = path.resolve(recordedPath);
297
+ const resolvedPath = resolveAttributionPath(recordedPath);
269
298
  const origin = origins[0] ?? "";
270
299
  return {
271
300
  state: "attributed_fallback",
@@ -281,7 +310,7 @@ function fallbackToTranscriptOriginForDeletedRepo(options) {
281
310
  repo_origin_url: origin,
282
311
  branch: options.signals.branches[0] ?? "unknown",
283
312
  head_sha: options.signals.headShas[0] ?? null,
284
- worktree_label: path.basename(resolvedPath),
313
+ worktree_label: basenameForAttributionPath(resolvedPath),
285
314
  worktree_fingerprint: stableWorktreeFingerprint(resolvedPath),
286
315
  worktree_is_primary: false,
287
316
  },
@@ -318,7 +347,7 @@ function deepestContainers(signalValues, worktrees) {
318
347
  for (const worktree of worktrees) {
319
348
  if (!isPathWithin(value, worktree.repo_root))
320
349
  continue;
321
- const rootLength = path.resolve(worktree.repo_root).length;
350
+ const rootLength = normalizedAttributionPathKey(worktree.repo_root).length;
322
351
  if (rootLength > bestRootLength) {
323
352
  best = worktree;
324
353
  bestRootLength = rootLength;
@@ -330,30 +359,122 @@ function deepestContainers(signalValues, worktrees) {
330
359
  return winners;
331
360
  }
332
361
  export function isPathWithin(candidate, root) {
333
- const normalizedCandidate = path.resolve(candidate);
334
- const normalizedRoot = path.resolve(root);
335
- return (normalizedCandidate === normalizedRoot ||
336
- normalizedCandidate.startsWith(normalizedRoot + path.sep));
362
+ return pathContainment(candidate, root, false);
337
363
  }
338
364
  function isPathStrictlyWithin(candidate, root) {
339
- const normalizedCandidate = path.resolve(candidate);
340
- const normalizedRoot = path.resolve(root);
341
- return (normalizedCandidate !== normalizedRoot &&
342
- normalizedCandidate.startsWith(normalizedRoot + path.sep));
365
+ return pathContainment(candidate, root, true);
343
366
  }
344
- function repoPathAbsentFromDisk(paths, collectionRoots) {
367
+ function recordedPathsWithinCollectionRoots(paths, collectionRoots) {
345
368
  if (paths.length === 0 || collectionRoots.length === 0)
346
- return false;
347
- const roots = collectionRoots.map((root) => path.resolve(root));
348
- return paths.some((value) => roots.some((root) => isPathWithin(value, root)));
369
+ return [];
370
+ return paths.filter((value) => collectionRoots.some((root) => isPathWithin(value, root)));
371
+ }
372
+ function worktreesWithinCollectionScope(worktrees, collectionRoots) {
373
+ if (collectionRoots.length === 0)
374
+ return worktrees;
375
+ const directlyApproved = new Set(worktrees
376
+ .filter((worktree) => collectionRoots.some((root) => isPathWithin(worktree.repo_root, root)))
377
+ .map((worktree) => worktree.worktree_fingerprint));
378
+ const approvedRepoFingerprints = new Set(worktrees
379
+ .filter((worktree) => directlyApproved.has(worktree.worktree_fingerprint))
380
+ .map((worktree) => worktree.repo_fingerprint));
381
+ return worktrees.filter((worktree) => directlyApproved.has(worktree.worktree_fingerprint) ||
382
+ (isCodexManagedWorktreePath(worktree.repo_root) &&
383
+ approvedRepoFingerprints.has(worktree.repo_fingerprint)));
384
+ }
385
+ function isCodexManagedWorktreePath(value) {
386
+ const pathApi = attributionPathApi(value);
387
+ const windowsPath = isWindowsAbsolutePath(value);
388
+ const parts = pathApi.resolve(value).split(pathApi.sep).filter(Boolean);
389
+ return parts.some((part, index) => {
390
+ const codex = windowsPath ? part.toLowerCase() : part;
391
+ const nextPart = parts[index + 1] ?? "";
392
+ const worktrees = windowsPath ? nextPart.toLowerCase() : nextPart;
393
+ return codex === ".codex" && worktrees === "worktrees";
394
+ });
349
395
  }
350
396
  function sortWorktreesForFolderFallback(worktrees) {
351
397
  return [...worktrees].sort((a, b) => Number(b.worktree_is_primary) - Number(a.worktree_is_primary) ||
352
- path.resolve(a.repo_root).localeCompare(path.resolve(b.repo_root)) ||
398
+ normalizedAttributionPathKey(a.repo_root).localeCompare(normalizedAttributionPathKey(b.repo_root)) ||
353
399
  a.worktree_fingerprint.localeCompare(b.worktree_fingerprint));
354
400
  }
355
401
  function pathDepth(value) {
356
- return path.resolve(value).split(path.sep).filter(Boolean).length;
402
+ const pathApi = attributionPathApi(value);
403
+ return pathApi.resolve(value).split(pathApi.sep).filter(Boolean).length;
404
+ }
405
+ /**
406
+ * Transcript paths describe the host that produced the session, which may not
407
+ * be the host currently running tests or replaying evidence. Detect Windows
408
+ * drive/UNC paths from the values themselves so attribution keeps win32
409
+ * separator and case rules on macOS/Linux too.
410
+ */
411
+ function pathContainment(candidate, root, strict) {
412
+ const candidateIsWindows = isWindowsAbsolutePath(candidate);
413
+ const rootIsWindows = isWindowsAbsolutePath(root);
414
+ if (candidateIsWindows !== rootIsWindows)
415
+ return false;
416
+ const pathApi = candidateIsWindows ? path.win32 : path.posix;
417
+ const normalizedCandidate = pathApi.resolve(candidate);
418
+ const normalizedRoot = pathApi.resolve(root);
419
+ return normalizedPathContainment(normalizedCandidate, normalizedRoot, pathApi, strict);
420
+ }
421
+ function normalizedPathContainment(candidate, root, pathApi, strict) {
422
+ const relative = pathApi.relative(root, candidate);
423
+ if (relative === "")
424
+ return !strict;
425
+ return (relative !== ".." &&
426
+ !relative.startsWith(`..${pathApi.sep}`) &&
427
+ !pathApi.isAbsolute(relative));
428
+ }
429
+ function collectionRootComparisonAliases(roots) {
430
+ return pathComparisonAliases(roots);
431
+ }
432
+ function pathComparisonAliases(values) {
433
+ const aliases = [];
434
+ const seen = new Set();
435
+ for (const value of values) {
436
+ for (const candidate of [
437
+ resolveAttributionPath(value),
438
+ canonicalExistingAttributionPath(value),
439
+ ]) {
440
+ if (!candidate)
441
+ continue;
442
+ const key = normalizedAttributionPathKey(candidate);
443
+ if (seen.has(key))
444
+ continue;
445
+ seen.add(key);
446
+ aliases.push(candidate);
447
+ }
448
+ }
449
+ return aliases;
450
+ }
451
+ function canonicalExistingAttributionPath(value) {
452
+ const windowsPath = isWindowsAbsolutePath(value);
453
+ if (windowsPath !== (process.platform === "win32"))
454
+ return null;
455
+ try {
456
+ return attributionPathApi(value).resolve(realpathSync(value));
457
+ }
458
+ catch {
459
+ return null;
460
+ }
461
+ }
462
+ function attributionPathApi(value) {
463
+ return isWindowsAbsolutePath(value) ? path.win32 : path.posix;
464
+ }
465
+ function resolveAttributionPath(value) {
466
+ return attributionPathApi(value).resolve(value);
467
+ }
468
+ function basenameForAttributionPath(value) {
469
+ return attributionPathApi(value).basename(value);
470
+ }
471
+ function normalizedAttributionPathKey(value) {
472
+ const resolved = resolveAttributionPath(value);
473
+ return isWindowsAbsolutePath(value) ? resolved.toLowerCase() : resolved;
474
+ }
475
+ function isWindowsAbsolutePath(value) {
476
+ return (/^[A-Za-z]:[\\/]/.test(value) ||
477
+ /^[\\/]{2}[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/.test(value));
357
478
  }
358
479
  export function clampScore(value) {
359
480
  return Math.min(1, Math.max(0, Number(value.toFixed(4))));
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
4
4
  import { createReadStream, existsSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { StringDecoder } from "node:string_decoder";
7
+ import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
7
8
  import { SESSION_FILE_UUID_PATTERN, isPathWithin, sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
8
9
  /**
9
10
  * Deterministic Claude Code session JSONL -> repo/worktree attribution.
@@ -92,11 +93,11 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
92
93
  try {
93
94
  projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
94
95
  }
95
- catch {
96
+ catch (error) {
96
97
  return {
97
98
  sessions,
98
99
  projectDirsSkipped,
99
- projectDirReadFailedCount: 1,
100
+ projectDirReadFailedCount: isMissingPathError(error) ? 0 : 1,
100
101
  sessionStatFailedCount,
101
102
  sidecarDirReadFailedCount,
102
103
  sidecarStatFailedCount,
@@ -118,8 +119,9 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
118
119
  try {
119
120
  sessionEntries = await fs.readdir(projectDir, { withFileTypes: true });
120
121
  }
121
- catch {
122
- projectDirReadFailedCount += 1;
122
+ catch (error) {
123
+ if (!isMissingPathError(error))
124
+ projectDirReadFailedCount += 1;
123
125
  continue;
124
126
  }
125
127
  for (const sessionEntry of sessionEntries) {
@@ -169,11 +171,11 @@ async function discoverSidecars(subagentsDir) {
169
171
  try {
170
172
  entries = await fs.readdir(subagentsDir, { withFileTypes: true });
171
173
  }
172
- catch {
174
+ catch (error) {
173
175
  return {
174
176
  sidecars: [],
175
177
  sidecarsCapped: 0,
176
- dirReadFailedCount: 1,
178
+ dirReadFailedCount: isMissingPathError(error) ? 0 : 1,
177
179
  statFailedCount: 0,
178
180
  };
179
181
  }
@@ -212,6 +214,11 @@ async function discoverSidecars(subagentsDir) {
212
214
  statFailedCount,
213
215
  };
214
216
  }
217
+ function isMissingPathError(error) {
218
+ return (error instanceof Error &&
219
+ "code" in error &&
220
+ error.code === "ENOENT");
221
+ }
215
222
  async function attributeOneSession(session, worktrees, collectionRoots) {
216
223
  const fileName = path.basename(session.mainFile);
217
224
  const base = {
@@ -310,7 +317,7 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
310
317
  extraSignals.push("main_file_oversized");
311
318
  if (isSchemaDriftSuspected(signals))
312
319
  extraSignals.push("schema_drift_suspected");
313
- const sidecarFiles = outcome.state === "attributed" && outcome.worktree
320
+ const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state) && outcome.worktree
314
321
  ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree)
315
322
  : session.sidecars.map((sidecar) => ({
316
323
  local_path: sidecar.local_path,
@@ -87,8 +87,9 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
87
87
  try {
88
88
  entries = await fs.readdir(current, { withFileTypes: true });
89
89
  }
90
- catch {
91
- directoryReadFailedCount += 1;
90
+ catch (error) {
91
+ if (!isMissingPathError(error))
92
+ directoryReadFailedCount += 1;
92
93
  continue;
93
94
  }
94
95
  for (const entry of entries) {
@@ -133,6 +134,11 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
133
134
  secretPathSkippedCount,
134
135
  };
135
136
  }
137
+ function isMissingPathError(error) {
138
+ return (error instanceof Error &&
139
+ "code" in error &&
140
+ error.code === "ENOENT");
141
+ }
136
142
  function codexSessionDirsFromOptions(options) {
137
143
  const dirs = options.sessionsDirs ?? (options.sessionsDir ? [options.sessionsDir] : []);
138
144
  return dedupePaths(dirs);
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { makeSourceAdapterIdentity, } from "./common.js";
8
8
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
9
9
  import { defaultCodexSessionDirs, } from "./codex-attribution.js";
10
+ import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
10
11
  const DEFAULT_SINCE_MINUTES = 24 * 60;
11
12
  const DEFAULT_SESSION_LIMIT = 50;
12
13
  const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
@@ -14,8 +15,8 @@ const GIT_DIFF_TIMEOUT_MS = 3_000;
14
15
  export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
15
16
  export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
16
17
  // Per-sync upload budgets enforced at COLLECTION time (D7b). A single marathon
17
- // transcript can now approach 512 MiB, so 2 GiB leaves room for several files
18
- // without starving the sync; overflow still defers and converges next run.
18
+ // transcript can approach the 256 MiB wire cap, so 2 GiB leaves room for
19
+ // several files without starving the sync; overflow still defers and converges.
19
20
  export const RAW_EVIDENCE_DEFAULT_BYTE_BUDGET = 2 * 1024 * 1024 * 1024;
20
21
  export const RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET = 300;
21
22
  const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
@@ -72,6 +73,12 @@ export async function collectRawEvidencePack(context, options) {
72
73
  limit: GIT_DIFF_TIMEOUT_MS,
73
74
  applied: false,
74
75
  },
76
+ {
77
+ source: "codex_jsonl",
78
+ cap_type: "max_file_bytes",
79
+ limit: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
80
+ applied: false,
81
+ },
75
82
  {
76
83
  source: "claude_jsonl",
77
84
  cap_type: "max_file_bytes",
@@ -98,6 +105,8 @@ export async function collectRawEvidencePack(context, options) {
98
105
  recordAttributionCompleteness(collection, {
99
106
  codex: options.codexAttributionScan,
100
107
  claude: options.claudeAttributionScan,
108
+ selectedCodexPaths: new Set(options.codexSessionFiles?.map((file) => file.local_path) ?? []),
109
+ selectedClaudePaths: new Set(options.claudeSessionFiles?.map((file) => file.local_path) ?? []),
101
110
  });
102
111
  if (options.includeCodexJsonl !== false) {
103
112
  await collectCodexJsonlFiles(collection, {
@@ -292,12 +301,14 @@ function makeRawEvidenceScan(options) {
292
301
  });
293
302
  }
294
303
  function recordAttributionCompleteness(collection, scans) {
295
- if (scans.codex)
296
- recordCodexAttributionCompleteness(collection, scans.codex);
297
- if (scans.claude)
298
- recordClaudeAttributionCompleteness(collection, scans.claude);
304
+ if (scans.codex) {
305
+ recordCodexAttributionCompleteness(collection, scans.codex, scans.selectedCodexPaths);
306
+ }
307
+ if (scans.claude) {
308
+ recordClaudeAttributionCompleteness(collection, scans.claude, scans.selectedClaudePaths);
309
+ }
299
310
  }
300
- function recordCodexAttributionCompleteness(collection, scan) {
311
+ function recordCodexAttributionCompleteness(collection, scan, selectedPaths) {
301
312
  recordScanned(collection, "codex_attribution", scan.scanned_file_count);
302
313
  collection.caps.push({
303
314
  source: "codex_attribution",
@@ -316,9 +327,9 @@ function recordCodexAttributionCompleteness(collection, scan) {
316
327
  recordSkipCount(collection, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
317
328
  recordSkipCount(collection, "codex_attribution", "file_stat_failed", scan.stat_failed_count);
318
329
  recordSkipCount(collection, "codex_attribution", "secret_like_directory", scan.secret_path_skipped_count);
319
- recordAttributionResultSkips(collection, "codex_attribution", scan.results);
330
+ recordAttributionResultSkips(collection, "codex_attribution", scan.results, selectedPaths);
320
331
  }
321
- function recordClaudeAttributionCompleteness(collection, scan) {
332
+ function recordClaudeAttributionCompleteness(collection, scan, selectedPaths) {
322
333
  recordScanned(collection, "claude_attribution", scan.scanned_session_count);
323
334
  if (scan.disabled_reason) {
324
335
  recordSkipCount(collection, "claude_attribution", scan.disabled_reason, 1);
@@ -361,7 +372,7 @@ function recordClaudeAttributionCompleteness(collection, scan) {
361
372
  recordSkipCount(collection, "claude_attribution", "sidecar_stat_failed", scan.sidecar_stat_failed_count);
362
373
  recordSkipCount(collection, "claude_attribution", "sidecar_limit_overflow", scan.counts.sidecars_capped);
363
374
  recordTruncationCount(collection, "claude_attribution", "oversized_jsonl_line", scan.counts.oversized_lines_skipped, { max_bytes: scan.max_line_buffer_bytes });
364
- recordAttributionResultSkips(collection, "claude_attribution", scan.results);
375
+ recordAttributionResultSkips(collection, "claude_attribution", scan.results, selectedPaths);
365
376
  for (const result of scan.results) {
366
377
  for (const sidecar of result.sidecar_files) {
367
378
  if (!sidecar.skipped_reason)
@@ -370,10 +381,17 @@ function recordClaudeAttributionCompleteness(collection, scan) {
370
381
  }
371
382
  }
372
383
  }
373
- function recordAttributionResultSkips(collection, source, results) {
384
+ function recordAttributionResultSkips(collection, source, results, selectedPaths) {
374
385
  for (const result of results) {
375
- if (result.state === "attributed")
386
+ // A selected fallback is being collected by this pass. A live-sync-safe
387
+ // fallback is also not a skip when another worktree pack owns its upload.
388
+ if (result.state === "attributed" ||
389
+ (result.state === "attributed_fallback" &&
390
+ (selectedPaths.has(result.file_path) ||
391
+ (result.worktree !== null &&
392
+ isLiveRawEvidenceSyncAttribution(result.state, result.reason))))) {
376
393
  continue;
394
+ }
377
395
  const reason = result.state === "skipped"
378
396
  ? result.reason
379
397
  : `attribution_${result.state}:${result.reason}`;
@@ -409,6 +427,9 @@ async function collectCodexJsonlFiles(collection, options) {
409
427
  kind: "codex_jsonl",
410
428
  sessionId: codexSessionId,
411
429
  mediaType: "application/jsonl",
430
+ // Guard before read/toString: Codex files can exceed the buffered commit
431
+ // ceiling just like Claude mains.
432
+ maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
412
433
  redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
413
434
  contentAddress: (hash16) => `codex/${safeKeySegment(codexSessionId)}/${hash16}.jsonl`,
414
435
  });