@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.
@@ -7,6 +7,11 @@ export async function runCockpitCli(argv, io) {
7
7
  return 0;
8
8
  }
9
9
 
10
+ if (command === "--version" || command === "-V" || command === "version") {
11
+ writeLine(io?.stdout ?? process.stdout, "0.2.7");
12
+ return 0;
13
+ }
14
+
10
15
  if (rootCommandNames.has(command)) {
11
16
  return runLocalCockpitCli(argv, io);
12
17
  }
@@ -31,7 +36,7 @@ function cockpitHelp() {
31
36
  "Agent setup: `cockpit onboard` refreshes AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace ~/BLI` for repair.",
32
37
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
33
38
  "Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
34
- "Maintainer release path: merge to main first, then run `cockpit release --dry-run` and `cockpit release` from a clean main checkout.",
39
+ "Maintainer release path: merge to main, publish to `next` with `cockpit release`, canary Windows plus Apple Silicon, then deliberately promote that exact version to `latest`.",
35
40
  ].join("\n");
36
41
  }
37
42
 
@@ -5,24 +5,112 @@
5
5
  // Behavior-preserving extraction: moved verbatim from local.ts, no logic change.
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
- import { getCollectorRuntimePaths, startLocalWorkContext, readLocalCollectorConfig } from "../local-state.js";
8
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "../local-state.js";
9
9
  import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, } from "@bli-cockpit/telemetry-core";
10
- import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
10
+ import { flushPendingCodexSessionReports, LocalUploadBlockedError, queueCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
11
11
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
12
12
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
13
13
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
14
14
  import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
15
15
  import { normalizeCollectionRoots } from "../root-normalization.js";
16
+ import { clearSourceRetryFailure, readLocalUploadSpoolState, recordSourceRetryFailure, } from "../spool/local-spool.js";
17
+ import { isLiveRawEvidenceSyncAttribution, } from "../raw-evidence-attribution-policy.js";
16
18
  const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
17
19
  const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
18
20
  const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
21
+ /**
22
+ * Live sync remains reason-allowlisted even though historical backfill accepts
23
+ * every deterministic fallback state.
24
+ */
25
+ export function isLiveSyncCollectableAttributionState(state, reason) {
26
+ return isLiveRawEvidenceSyncAttribution(state, reason);
27
+ }
28
+ function cursorHasUndurableCollectableSession(cursor) {
29
+ return Object.values(cursor.sessions).some((entry) => liveSyncCursorEntryRequiresRetry(entry));
30
+ }
31
+ export function liveSyncCursorEntryRequiresRetry(entry) {
32
+ return (!entry.uploaded_object_key &&
33
+ (isLiveSyncCollectableAttributionState(entry.state, entry.reason) ||
34
+ entry.reason === "repo_not_on_disk"));
35
+ }
36
+ function allLocalHistorySinceMinutes(now) {
37
+ // A cutoff just before the Unix epoch is effectively unbounded for Codex and
38
+ // Claude session files while keeping date arithmetic finite and portable.
39
+ return Math.ceil(now.getTime() / 60_000) + 24 * 60;
40
+ }
41
+ function codexAttributionReadFailureCount(scan) {
42
+ return scan.directory_read_failed_count + scan.stat_failed_count;
43
+ }
44
+ function claudeAttributionReadFailureCount(scan) {
45
+ return (scan.project_dir_read_failed_count +
46
+ scan.session_stat_failed_count +
47
+ scan.sidecar_dir_read_failed_count +
48
+ scan.sidecar_stat_failed_count);
49
+ }
50
+ export function sourceScanRetryReason(source, scan) {
51
+ const reasons = new Set();
52
+ const readFailureCount = source === "codex"
53
+ ? codexAttributionReadFailureCount(scan)
54
+ : claudeAttributionReadFailureCount(scan);
55
+ if (readFailureCount > 0) {
56
+ reasons.add(`${source}_session_store_read_failed`);
57
+ }
58
+ if (scan.results.some((result) => result.reason === "repo_not_on_disk")) {
59
+ reasons.add("repo_not_on_disk");
60
+ }
61
+ return reasons.size > 0 ? [...reasons].sort().join(",") : null;
62
+ }
63
+ async function reconcileSourceScanRetry(options) {
64
+ if (options.reason) {
65
+ await recordSourceRetryFailure(options.paths, {
66
+ source: options.source,
67
+ reason: options.reason,
68
+ attemptedAt: options.attemptedAt,
69
+ });
70
+ return;
71
+ }
72
+ if (options.pendingBefore) {
73
+ await clearSourceRetryFailure(options.paths, options.source, options.attemptedAt);
74
+ }
75
+ }
76
+ export function matchesLiveSyncWorktree(result, target) {
77
+ return (isLiveRawEvidenceSyncAttribution(result.state, result.reason) &&
78
+ result.worktree !== null &&
79
+ liveSyncTargetKey(result.worktree) === liveSyncTargetKey(target));
80
+ }
81
+ function liveSyncTargetKey(worktree) {
82
+ return `${worktree.repo_fingerprint}:${worktree.worktree_fingerprint}`;
83
+ }
84
+ /**
85
+ * Folder and deleted-repo fallbacks can intentionally synthesize a worktree
86
+ * identity that is not present in git discovery. Include those identities as
87
+ * sync targets so a wrapper-root session is not observed and then stranded.
88
+ */
89
+ export function liveSyncTargetWorktrees(discovered, results) {
90
+ const targets = [...discovered];
91
+ const seen = new Set(discovered.map(liveSyncTargetKey));
92
+ for (const result of results) {
93
+ const targetKey = result.worktree
94
+ ? liveSyncTargetKey(result.worktree)
95
+ : null;
96
+ if (!isLiveSyncCollectableAttributionState(result.state, result.reason) ||
97
+ !result.worktree ||
98
+ !targetKey ||
99
+ seen.has(targetKey)) {
100
+ continue;
101
+ }
102
+ seen.add(targetKey);
103
+ targets.push(result.worktree);
104
+ }
105
+ return targets;
106
+ }
19
107
  /**
20
108
  * Shared dual-source sync orchestration for single-repo and parent-folder
21
109
  * modes. Codex AND Claude Code sessions are scanned and attributed once across
22
- * every discovered worktree; each worktree syncs with only its own attributed
23
- * transcripts (codex + claude main + sidecars), and the
24
- * ambiguous/unattributed/skipped remainder is reported with reason labels and a
25
- * `source` discriminator instead of being duplicated into every repo or
110
+ * every discovered worktree; each worktree syncs its exact and deterministic
111
+ * fallback-attributed transcripts (Codex + Claude main + sidecars). The
112
+ * ambiguous/unattributed/skipped remainder is reported with reason labels and
113
+ * a `source` discriminator instead of being duplicated into every repo or
26
114
  * silently dropped. The session row's upload state maps ONLY from the main-file
27
115
  * outcome (D3); sidecar outcomes aggregate into CLI counts.
28
116
  */
@@ -32,36 +120,103 @@ export async function runAttributedWorktreeSync(options) {
32
120
  const paths = getCollectorRuntimePaths(options.homeDir);
33
121
  const config = await readLocalCollectorConfig(paths).catch(() => null);
34
122
  const claudeEnabled = config?.collect_claude_jsonl !== false;
35
- const collectionRoots = fallbackCollectionRoots(config?.default_repo_paths ?? [], options.worktrees);
36
- const codexAttribution = await scanAndAttributeCodexSessions({
123
+ const collectionRoots = normalizeCollectionRoots(options.collectionRoots ?? config?.default_repo_paths ?? []);
124
+ const [codexCursorBefore, claudeCursorBefore, uploadSpool] = await Promise.all([
125
+ readRawEvidenceCursor(paths).catch(() => emptyRawEvidenceCursorState()),
126
+ claudeEnabled
127
+ ? readRawEvidenceCursor(paths, {
128
+ filename: CLAUDE_CURSOR_FILENAME,
129
+ }).catch(() => emptyRawEvidenceCursorState())
130
+ : Promise.resolve(emptyRawEvidenceCursorState()),
131
+ readLocalUploadSpoolState(paths).catch(() => null),
132
+ ]);
133
+ const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
134
+ const codexSourceRetryPending = Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === "codex"));
135
+ const claudeSourceRetryPending = Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === "claude_code"));
136
+ const codexRetryPending = legacyRetryPending ||
137
+ codexSourceRetryPending ||
138
+ Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes("codex"))) ||
139
+ cursorHasUndurableCollectableSession(codexCursorBefore);
140
+ const claudeRetryPending = claudeEnabled &&
141
+ (legacyRetryPending ||
142
+ claudeSourceRetryPending ||
143
+ Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes("claude_code"))) ||
144
+ cursorHasUndurableCollectableSession(claudeCursorBefore));
145
+ const allHistorySinceMinutes = allLocalHistorySinceMinutes(now);
146
+ let codexAttribution = await scanAndAttributeCodexSessions({
37
147
  sessionsDirs: defaultCodexSessionDirs(homeDir),
38
148
  worktrees: options.worktrees,
39
149
  now,
40
- sinceMinutes: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
150
+ sinceMinutes: codexRetryPending
151
+ ? allHistorySinceMinutes
152
+ : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
41
153
  limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
42
154
  collectionRoots,
43
155
  });
156
+ if (codexAttribution.session_limit_applied) {
157
+ codexAttribution = await scanAndAttributeCodexSessions({
158
+ sessionsDirs: defaultCodexSessionDirs(homeDir),
159
+ worktrees: options.worktrees,
160
+ now,
161
+ sinceMinutes: codexRetryPending
162
+ ? allHistorySinceMinutes
163
+ : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
164
+ limit: codexAttribution.discovered_file_count,
165
+ collectionRoots,
166
+ });
167
+ }
44
168
  // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
45
169
  // days so the first sync captures retroactive history instead of only 24h.
46
170
  const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
47
171
  const firstRunBackfill = claudeEnabled && !claudeCursorExists;
48
- const claudeAttribution = claudeEnabled
172
+ let claudeAttribution = claudeEnabled
49
173
  ? await scanAndAttributeClaudeSessions({
50
174
  projectsDir: path.join(homeDir, ".claude", "projects"),
51
175
  worktrees: options.worktrees,
52
176
  now,
53
177
  collectionRoots,
54
- sinceMinutes: firstRunBackfill
55
- ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
56
- : undefined,
178
+ sinceMinutes: claudeRetryPending
179
+ ? allHistorySinceMinutes
180
+ : firstRunBackfill
181
+ ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
182
+ : undefined,
57
183
  })
58
184
  : emptyClaudeScan();
59
- // Read the Claude cursor up front: damping decisions need prior upload state.
60
- const claudeCursorBefore = claudeEnabled
61
- ? await readRawEvidenceCursor(paths, {
62
- filename: CLAUDE_CURSOR_FILENAME,
63
- }).catch(() => emptyRawEvidenceCursorState())
64
- : emptyRawEvidenceCursorState();
185
+ if (claudeAttribution.session_limit_applied) {
186
+ claudeAttribution = await scanAndAttributeClaudeSessions({
187
+ projectsDir: path.join(homeDir, ".claude", "projects"),
188
+ worktrees: options.worktrees,
189
+ now,
190
+ collectionRoots,
191
+ sinceMinutes: claudeRetryPending
192
+ ? allHistorySinceMinutes
193
+ : firstRunBackfill
194
+ ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
195
+ : undefined,
196
+ limit: claudeAttribution.discovered_session_count,
197
+ });
198
+ }
199
+ await reconcileSourceScanRetry({
200
+ paths,
201
+ source: "codex",
202
+ attemptedAt: now.toISOString(),
203
+ pendingBefore: codexSourceRetryPending,
204
+ reason: sourceScanRetryReason("codex", codexAttribution),
205
+ });
206
+ if (claudeEnabled) {
207
+ await reconcileSourceScanRetry({
208
+ paths,
209
+ source: "claude_code",
210
+ attemptedAt: now.toISOString(),
211
+ pendingBefore: claudeSourceRetryPending,
212
+ reason: sourceScanRetryReason("claude_code", claudeAttribution),
213
+ });
214
+ }
215
+ const syncWorktrees = liveSyncTargetWorktrees(options.worktrees, [
216
+ ...codexAttribution.results,
217
+ ...claudeAttribution.results,
218
+ ]);
219
+ const discoveredWorktreeKeys = new Set(options.worktrees.map(liveSyncTargetKey));
65
220
  // sessionId -> prior durable pointer for damped sessions (drives the
66
221
  // growth_damped count + the skip_main decision).
67
222
  const dampedClaudePointers = new Map();
@@ -83,22 +238,27 @@ export async function runAttributedWorktreeSync(options) {
83
238
  };
84
239
  const outcomes = [];
85
240
  let ok = true;
86
- for (const worktree of options.worktrees) {
241
+ for (const worktree of syncWorktrees) {
242
+ const attributedSyntheticTarget = discoveredWorktreeKeys.has(liveSyncTargetKey(worktree))
243
+ ? null
244
+ : worktree;
245
+ const contextOptions = {
246
+ homeDir: options.homeDir,
247
+ repoRoot: worktree.repo_root,
248
+ activeTicketId: options.activeTicketId,
249
+ operatorId: options.operatorId,
250
+ sessionId: options.sessionId,
251
+ ...(attributedSyntheticTarget ? {} : { branch: options.branch }),
252
+ };
253
+ const ensureContext = () => attributedSyntheticTarget
254
+ ? startLocalWorkContextForAttributedTarget(contextOptions, attributedSyntheticTarget)
255
+ : startLocalWorkContext(contextOptions);
87
256
  let context = null;
88
- if (options.startContexts) {
89
- context = await startLocalWorkContext({
90
- homeDir: options.homeDir,
91
- repoRoot: worktree.repo_root,
92
- branch: options.branch,
93
- activeTicketId: options.activeTicketId,
94
- operatorId: options.operatorId,
95
- sessionId: options.sessionId,
96
- });
257
+ if (options.startContexts || attributedSyntheticTarget) {
258
+ context = await ensureContext();
97
259
  }
98
260
  const claudeSessionFiles = claudeAttribution.results
99
- .filter((result) => result.state === "attributed" &&
100
- result.worktree?.worktree_fingerprint ===
101
- worktree.worktree_fingerprint)
261
+ .filter((result) => matchesLiveSyncWorktree(result, worktree))
102
262
  .map((result) => {
103
263
  const damped = !result.main_file_oversized &&
104
264
  shouldDampClaudeMain(result, claudeCursorBefore, now);
@@ -120,11 +280,9 @@ export async function runAttributedWorktreeSync(options) {
120
280
  homeDir: options.homeDir,
121
281
  repoRoot: worktree.repo_root,
122
282
  dashboardUrl: options.dashboardUrl,
123
- worktreeInventory: worktreeInventoryForRepo(worktree, options.worktrees),
283
+ worktreeInventory: worktreeInventoryForRepo(worktree, syncWorktrees),
124
284
  codexSessionFiles: codexAttribution.results
125
- .filter((result) => result.state === "attributed" &&
126
- result.worktree?.worktree_fingerprint ===
127
- worktree.worktree_fingerprint)
285
+ .filter((result) => matchesLiveSyncWorktree(result, worktree))
128
286
  .map((result) => ({
129
287
  local_path: result.file_path,
130
288
  codex_session_id: result.codex_session_id,
@@ -146,11 +304,7 @@ export async function runAttributedWorktreeSync(options) {
146
304
  // `cockpit start` by hand.
147
305
  if (error instanceof LocalUploadBlockedError &&
148
306
  error.blocker === "missing_context") {
149
- context = await startLocalWorkContext({
150
- homeDir: options.homeDir,
151
- repoRoot: worktree.repo_root,
152
- branch: options.branch,
153
- });
307
+ context = await ensureContext();
154
308
  sync = await syncLocalAmbientEnvelope(syncOptions);
155
309
  }
156
310
  else {
@@ -167,6 +321,33 @@ export async function runAttributedWorktreeSync(options) {
167
321
  now,
168
322
  claudePriorDurablePointers,
169
323
  });
324
+ // Persist the metadata-only report before advancing either source cursor.
325
+ // If the process exits after this point, the spool keeps an exact retry copy.
326
+ // If queueing itself fails, the cursors remain at their prior retryable
327
+ // positions instead of aging an unreported session out of the live window.
328
+ const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
329
+ const hadPendingSessionReports = (uploadSpool?.pending_session_reports.length ?? 0) > 0;
330
+ let queuedCurrentSessionReport = false;
331
+ if (firstUploaded && sessions.length > 0) {
332
+ const queued = await queueCodexSessionReport({
333
+ homeDir: options.homeDir,
334
+ dashboardUrl: firstUploaded.sync.dashboard_url,
335
+ generatedAt: now.toISOString(),
336
+ workContextId: firstUploaded.sync.work_context_id,
337
+ repoLabel: firstUploaded.worktree.repo_label,
338
+ branch: firstUploaded.worktree.branch,
339
+ repoFingerprint: firstUploaded.worktree.repo_fingerprint,
340
+ repoOriginUrl: firstUploaded.worktree.repo_origin_url,
341
+ worktreeLabel: firstUploaded.worktree.worktree_label,
342
+ worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
343
+ worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
344
+ sessions,
345
+ });
346
+ queuedCurrentSessionReport = Boolean(queued);
347
+ }
348
+ const reportRequired = sessions.length > 0 ||
349
+ hadPendingSessionReports ||
350
+ queuedCurrentSessionReport;
170
351
  // The sessions cursor is an optimization; a broken local state dir must not
171
352
  // turn already-completed syncs into a CLI crash.
172
353
  let codexStaleCount = 0;
@@ -180,6 +361,9 @@ export async function runAttributedWorktreeSync(options) {
180
361
  source: "codex",
181
362
  sessionIdOf: (result) => result.codex_session_id,
182
363
  now,
364
+ priorCursor: codexCursorBefore,
365
+ terminalizeMissingUndurable: codexRetryPending &&
366
+ codexAttributionReadFailureCount(codexAttribution) === 0,
183
367
  });
184
368
  codexCursor.updated_at = now.toISOString();
185
369
  await writeRawEvidenceCursor(paths, codexCursor);
@@ -197,6 +381,8 @@ export async function runAttributedWorktreeSync(options) {
197
381
  sessionIdOf: (result) => result.claude_session_id,
198
382
  now,
199
383
  priorCursor: claudeCursorBefore,
384
+ terminalizeMissingUndurable: claudeRetryPending &&
385
+ claudeAttributionReadFailureCount(claudeAttribution) === 0,
200
386
  });
201
387
  claudeCursorBefore.updated_at = now.toISOString();
202
388
  await writeRawEvidenceCursor(paths, claudeCursorBefore, {
@@ -208,13 +394,9 @@ export async function runAttributedWorktreeSync(options) {
208
394
  // Best-effort: a broken Claude cursor must not fail the sync.
209
395
  }
210
396
  }
211
- const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
212
- const report = firstUploaded
213
- ? await postCodexSessionReport({
397
+ const report = hadPendingSessionReports || queuedCurrentSessionReport
398
+ ? await flushPendingCodexSessionReports({
214
399
  homeDir: options.homeDir,
215
- repoRoot: firstUploaded.worktree.repo_root,
216
- dashboardUrl: options.dashboardUrl,
217
- sessions,
218
400
  fetch: options.fetchImpl,
219
401
  now,
220
402
  })
@@ -232,6 +414,19 @@ export async function runAttributedWorktreeSync(options) {
232
414
  growthDamped: dampedClaudePointers.size,
233
415
  report,
234
416
  });
417
+ ok =
418
+ ok &&
419
+ !codexAttribution.session_limit_applied &&
420
+ !claudeAttribution.session_limit_applied &&
421
+ outcomes.every(({ sync }) => sync.raw_evidence_failed_count === 0 &&
422
+ !sync.raw_evidence_retry_required &&
423
+ sync.raw_evidence_deferred_byte_budget === 0 &&
424
+ sync.raw_evidence_deferred_object_budget === 0) &&
425
+ codexAttributionReadFailureCount(codexAttribution) === 0 &&
426
+ claudeAttributionReadFailureCount(claudeAttribution) === 0 &&
427
+ sourceScanRetryReason("codex", codexAttribution) === null &&
428
+ sourceScanRetryReason("claude_code", claudeAttribution) === null &&
429
+ (!reportRequired || report.posted);
235
430
  return { ok, outcomes, codexAttribution, claudeAttribution, summary };
236
431
  }
237
432
  export const ATTRIBUTION_STATE_RANK = CODEX_SESSION_ATTRIBUTION_STATE_RANK;
@@ -374,6 +569,20 @@ function buildAgentSessionReport(options) {
374
569
  */
375
570
  function recordSourceObservations(options) {
376
571
  const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
572
+ if (options.terminalizeMissingUndurable) {
573
+ for (const [sessionId, entry] of Object.entries(options.cursor.sessions)) {
574
+ if (seen.has(sessionId) ||
575
+ !liveSyncCursorEntryRequiresRetry(entry)) {
576
+ continue;
577
+ }
578
+ options.cursor.sessions[sessionId] = {
579
+ ...entry,
580
+ state: "skipped",
581
+ reason: "retry_source_missing",
582
+ last_seen_at: options.now.toISOString(),
583
+ };
584
+ }
585
+ }
377
586
  const stale = countStaleSessions(options.cursor, seen);
378
587
  for (const result of options.results) {
379
588
  const sessionId = options.sessionIdOf(result);
@@ -436,7 +645,7 @@ function shouldDampClaudeMain(result, cursor, now) {
436
645
  }
437
646
  function buildAgentSessionSummary(options) {
438
647
  const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
439
- const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
648
+ const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason));
440
649
  const sidecarsCollected = attributedClaude.reduce((total, result) => total +
441
650
  result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
442
651
  const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
@@ -449,6 +658,7 @@ function buildAgentSessionSummary(options) {
449
658
  unattributed: options.codexAttribution.counts.unattributed,
450
659
  skipped: options.codexAttribution.counts.skipped,
451
660
  stale: options.codexStaleCount,
661
+ read_failures: codexAttributionReadFailureCount(options.codexAttribution),
452
662
  };
453
663
  const claude = {
454
664
  scanned: options.claudeAttribution.scanned_session_count,
@@ -458,6 +668,7 @@ function buildAgentSessionSummary(options) {
458
668
  unattributed: options.claudeAttribution.counts.unattributed,
459
669
  skipped: options.claudeAttribution.counts.skipped,
460
670
  stale: options.claudeStaleCount,
671
+ read_failures: claudeAttributionReadFailureCount(options.claudeAttribution),
461
672
  sidecars_collected: sidecarsCollected,
462
673
  sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
463
674
  upload.upload_state === "reused_existing").length,
@@ -534,12 +745,6 @@ function worktreeInventoryForRepo(current, worktrees) {
534
745
  branch: worktree.branch,
535
746
  }));
536
747
  }
537
- function fallbackCollectionRoots(savedRoots, worktrees) {
538
- return normalizeCollectionRoots([
539
- ...savedRoots,
540
- ...worktrees.map((worktree) => worktree.requested_path || worktree.repo_root),
541
- ]);
542
- }
543
748
  async function fileExists(filePath) {
544
749
  const { stat } = await import("node:fs/promises");
545
750
  return stat(filePath).then(() => true, () => false);