@bli-cockpit/cli 0.2.236 → 0.2.238

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 (143) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +15 -11
  2. package/dist/adapters/attribution-core-score.js +111 -87
  3. package/dist/adapters/car-state.js +38 -35
  4. package/dist/adapters/claude-attribution-discovery.js +81 -56
  5. package/dist/adapters/claude-attribution-score.js +108 -94
  6. package/dist/adapters/claude-attribution-signals.js +52 -42
  7. package/dist/adapters/codex-attribution.js +42 -33
  8. package/dist/adapters/codex-session-signals.js +11 -9
  9. package/dist/adapters/git-state.js +56 -50
  10. package/dist/adapters/local-sources.js +52 -43
  11. package/dist/adapters/raw-evidence-git-diff.js +4 -2
  12. package/dist/adapters/raw-evidence-sanitize.js +15 -12
  13. package/dist/adapters/raw-evidence-transcript-file.js +32 -31
  14. package/dist/autostart-self-heal.js +18 -2
  15. package/dist/commands/agent-session-report.js +51 -59
  16. package/dist/commands/agent-setup-observed.js +30 -23
  17. package/dist/commands/agent-setup.js +96 -45
  18. package/dist/commands/autostart-heal.js +8 -2
  19. package/dist/commands/autostart-reading.js +3 -0
  20. package/dist/commands/backfill-evidence-outcomes.js +9 -6
  21. package/dist/commands/backfill-issues.js +3 -0
  22. package/dist/commands/backfill-scan.js +27 -22
  23. package/dist/commands/backfill-upload.js +69 -60
  24. package/dist/commands/backfill.js +26 -21
  25. package/dist/commands/brief-done.js +4 -1
  26. package/dist/commands/brief-edit.js +71 -46
  27. package/dist/commands/cal-detail.js +35 -0
  28. package/dist/commands/cal-overlay.js +106 -0
  29. package/dist/commands/cal.js +40 -23
  30. package/dist/commands/clean.js +4 -1
  31. package/dist/commands/docs-read.js +5 -2
  32. package/dist/commands/doctor-backfill.js +88 -71
  33. package/dist/commands/doctor-pipeline.js +59 -46
  34. package/dist/commands/doctor-usage-telemetry.js +2 -1
  35. package/dist/commands/heartbeat-post.js +116 -0
  36. package/dist/commands/heartbeat-token-lock.js +46 -33
  37. package/dist/commands/heartbeat.js +43 -125
  38. package/dist/commands/install-update.js +17 -15
  39. package/dist/commands/issue-show.js +27 -28
  40. package/dist/commands/issue-write.js +70 -23
  41. package/dist/commands/issue.js +68 -37
  42. package/dist/commands/jarvis-live-answer.js +99 -0
  43. package/dist/commands/jarvis-render.js +1 -94
  44. package/dist/commands/jarvis-trace.js +30 -22
  45. package/dist/commands/jarvis-turn-report.js +124 -0
  46. package/dist/commands/jarvis-turn.js +108 -182
  47. package/dist/commands/jarvis.js +26 -17
  48. package/dist/commands/local-args-collector-setup.js +82 -84
  49. package/dist/commands/local-args-collector-work.js +37 -38
  50. package/dist/commands/local-args-tower-admin.js +155 -142
  51. package/dist/commands/local-args-tower-cal.js +54 -48
  52. package/dist/commands/local-args-tower-careers.js +73 -54
  53. package/dist/commands/local-args-tower-docs-msg.js +56 -188
  54. package/dist/commands/local-args-tower-github.js +26 -20
  55. package/dist/commands/local-args-tower-hits.js +32 -20
  56. package/dist/commands/local-args-tower-manager.js +85 -4
  57. package/dist/commands/local-args-tower-memory.js +139 -117
  58. package/dist/commands/local-args-tower-msg.js +149 -0
  59. package/dist/commands/local-args-tower-notes.js +346 -0
  60. package/dist/commands/local-args-tower-pages.js +100 -378
  61. package/dist/commands/local-args-tower-settings.js +28 -29
  62. package/dist/commands/local-args-tower-work.js +52 -33
  63. package/dist/commands/local-args.js +80 -129
  64. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  65. package/dist/commands/local-help-commands-tower.js +11 -4
  66. package/dist/commands/local-help-commands.js +342 -341
  67. package/dist/commands/local-help.js +87 -83
  68. package/dist/commands/local-run-guards.js +100 -0
  69. package/dist/commands/local.js +137 -206
  70. package/dist/commands/login.js +13 -9
  71. package/dist/commands/logs.js +55 -38
  72. package/dist/commands/mail-attachments.js +44 -26
  73. package/dist/commands/mail.js +6 -4
  74. package/dist/commands/manager-render-home.js +208 -0
  75. package/dist/commands/manager-render.js +7 -3
  76. package/dist/commands/manager-requests.js +33 -0
  77. package/dist/commands/manager.js +39 -5
  78. package/dist/commands/memory-consolidation.js +18 -9
  79. package/dist/commands/memory-daemon-probe.js +37 -28
  80. package/dist/commands/memory-install-config.js +64 -60
  81. package/dist/commands/memory-install.js +21 -41
  82. package/dist/commands/memory-requeue.js +26 -19
  83. package/dist/commands/memory-rules.js +26 -18
  84. package/dist/commands/memory-search-print.js +26 -0
  85. package/dist/commands/memory-store.js +14 -30
  86. package/dist/commands/memory-verb-list.js +49 -48
  87. package/dist/commands/msg.js +51 -9
  88. package/dist/commands/notes-accounts.js +49 -32
  89. package/dist/commands/notes-sync-loop.js +115 -89
  90. package/dist/commands/notes-wisprflow-login.js +44 -23
  91. package/dist/commands/notes-writes.js +115 -100
  92. package/dist/commands/notes.js +33 -55
  93. package/dist/commands/onboard-flows.js +69 -51
  94. package/dist/commands/onboard.js +11 -9
  95. package/dist/commands/ops-render-memory.js +10 -7
  96. package/dist/commands/ops-render.js +33 -30
  97. package/dist/commands/ops.js +3 -0
  98. package/dist/commands/public-root.js +1 -1
  99. package/dist/commands/session-sync-outside-roots.js +40 -36
  100. package/dist/commands/session-sync-plan.js +14 -10
  101. package/dist/commands/session-sync-upload.js +24 -22
  102. package/dist/commands/session-sync.js +19 -8
  103. package/dist/commands/slack.js +25 -16
  104. package/dist/commands/sources-check.js +13 -7
  105. package/dist/commands/sources-discovery.js +25 -17
  106. package/dist/commands/sources-verdict.js +19 -18
  107. package/dist/commands/speed.js +23 -19
  108. package/dist/commands/start.js +20 -16
  109. package/dist/commands/sync-doctor.js +52 -48
  110. package/dist/commands/sync.js +22 -16
  111. package/dist/commands/usage-telemetry-toml.js +71 -45
  112. package/dist/commands/usage.js +91 -67
  113. package/dist/commands/workbook-render.js +10 -7
  114. package/dist/evidence-reconcile-client.js +75 -64
  115. package/dist/evidence-redelivery-plan.js +37 -34
  116. package/dist/evidence-redelivery.js +32 -29
  117. package/dist/evidence-transcripts-client.js +20 -20
  118. package/dist/evidence-upload-object.js +86 -89
  119. package/dist/evidence-upload-plan.js +94 -83
  120. package/dist/evidence-upload-terminal.js +45 -50
  121. package/dist/image-description-session.js +156 -159
  122. package/dist/local-state-pairing-code.js +39 -31
  123. package/dist/local-state-status.js +9 -2
  124. package/dist/onboarding-roots.js +5 -8
  125. package/dist/process-runner.js +8 -8
  126. package/dist/raw-evidence-gc.js +33 -38
  127. package/dist/raw-evidence-stuck-summary.js +7 -14
  128. package/dist/repo-identity.js +26 -23
  129. package/dist/scheduled-self-update.js +11 -15
  130. package/dist/second-install.js +3 -0
  131. package/dist/slim/codex-rules.js +23 -36
  132. package/dist/sync-lock.js +5 -15
  133. package/dist/tower-client.js +36 -53
  134. package/dist/tower-stream.js +7 -24
  135. package/dist/upload-agent-artifacts.js +58 -64
  136. package/dist/upload-envelope-build.js +60 -59
  137. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  138. package/dist/upload-evidence-delivery-summary.js +32 -34
  139. package/dist/upload-ingest-receipt.js +20 -23
  140. package/dist/upload-session-reports-queue.js +4 -1
  141. package/dist/upload-session-reports-wire.js +64 -68
  142. package/dist/upload-sync.js +171 -157
  143. package/package.json +2 -2
@@ -7,7 +7,52 @@ import { collectRawEvidencePack, } from "./raw-evidence.js";
7
7
  import { generateDumbRiskFlags } from "./risk-flags.js";
8
8
  import { resolveTicketBinding, } from "./ticket-binding.js";
9
9
  export async function runLocalSourceCollectors(options) {
10
- const context = {
10
+ const context = makeCollectionContext(options);
11
+ const git = await collectGitState(context);
12
+ const car = await collectCarState(context);
13
+ const rawEvidence = await collectConfiguredRawEvidence(context, options);
14
+ const unavailableScans = makeUnconfiguredSourceScans(context);
15
+ // claude_jsonl availability (B.4): "ok" when ~/.claude/projects exists, else
16
+ // skipped with the reason it could not be read — absent, permission-denied,
17
+ // not a directory, or unreadable (BLI-3483) — so an operator sees whether
18
+ // the empty claude funnel is normal or a wall they can take down. Collection
19
+ // itself is gated separately by collect_claude_jsonl in the sync
20
+ // orchestrator.
21
+ const claudeJsonlScan = await makeClaudeJsonlScan(context, options.claudeProjectsDir);
22
+ const candidates = collectBindingCandidates([
23
+ git.scan,
24
+ car.scan,
25
+ rawEvidence.scan,
26
+ claudeJsonlScan,
27
+ ...unavailableScans,
28
+ ]);
29
+ const binding = resolveTicketBinding({
30
+ activeWorkContext: options.activeWorkContext,
31
+ candidates,
32
+ });
33
+ const gitFacts = isGitStateFacts(git.facts) ? git.facts : null;
34
+ const carFacts = isCarStateFacts(car.facts) ? car.facts : null;
35
+ const rawEvidenceFacts = isRawEvidenceFacts(rawEvidence.facts)
36
+ ? rawEvidence.facts
37
+ : null;
38
+ const riskFlags = generateDumbRiskFlags({
39
+ context,
40
+ git: gitFacts,
41
+ binding,
42
+ });
43
+ return {
44
+ scans: [git.scan, car.scan, rawEvidence.scan, claudeJsonlScan, ...unavailableScans],
45
+ facts: {
46
+ git: gitFacts,
47
+ car: carFacts,
48
+ raw_evidence: rawEvidenceFacts,
49
+ },
50
+ binding,
51
+ risk_flags: riskFlags,
52
+ };
53
+ }
54
+ function makeCollectionContext(options) {
55
+ return {
11
56
  repoRoot: options.repoRoot,
12
57
  repoLabel: options.activeWorkContext?.repo_label,
13
58
  worktreeLabel: options.activeWorkContext?.worktree_label,
@@ -19,9 +64,9 @@ export async function runLocalSourceCollectors(options) {
19
64
  activeTicketId: options.activeWorkContext?.active_ticket_id ?? null,
20
65
  now: options.now ?? new Date(),
21
66
  };
22
- const git = await collectGitState(context);
23
- const car = await collectCarState(context);
24
- const rawEvidence = options.rawEvidenceStateDir
67
+ }
68
+ async function collectConfiguredRawEvidence(context, options) {
69
+ return options.rawEvidenceStateDir
25
70
  ? await collectRawEvidencePack(context, {
26
71
  stateDir: options.rawEvidenceStateDir,
27
72
  repoRoot: options.repoRoot,
@@ -46,51 +91,15 @@ export async function runLocalSourceCollectors(options) {
46
91
  scan: makeUnavailableScan(context, "codex_jsonl", "codex-jsonl", "raw_evidence_state_dir_not_configured"),
47
92
  facts: null,
48
93
  };
49
- const unavailableScans = [
94
+ }
95
+ function makeUnconfiguredSourceScans(context) {
96
+ return [
50
97
  makeUnavailableScan(context, "codex_otel", "codex-otel", "codex_otel_export_not_configured"),
51
98
  makeUnavailableScan(context, "github_state", "github-state", "github_auth_not_configured"),
52
99
  makeUnavailableScan(context, "linear_state", "linear-state", "linear_auth_not_configured"),
53
100
  makeUnavailableScan(context, "mcp_local", "mcp-local", "mcp_activity_not_configured"),
54
101
  makeUnavailableScan(context, "claude_hooks", "claude-hooks", "claude_hooks_not_present"),
55
102
  ];
56
- // claude_jsonl availability (B.4): "ok" when ~/.claude/projects exists, else
57
- // skipped with the reason it could not be read — absent, permission-denied,
58
- // not a directory, or unreadable (BLI-3483) — so an operator sees whether
59
- // the empty claude funnel is normal or a wall they can take down. Collection
60
- // itself is gated separately by collect_claude_jsonl in the sync
61
- // orchestrator.
62
- const claudeJsonlScan = await makeClaudeJsonlScan(context, options.claudeProjectsDir);
63
- const candidates = collectBindingCandidates([
64
- git.scan,
65
- car.scan,
66
- rawEvidence.scan,
67
- claudeJsonlScan,
68
- ...unavailableScans,
69
- ]);
70
- const binding = resolveTicketBinding({
71
- activeWorkContext: options.activeWorkContext,
72
- candidates,
73
- });
74
- const gitFacts = isGitStateFacts(git.facts) ? git.facts : null;
75
- const carFacts = isCarStateFacts(car.facts) ? car.facts : null;
76
- const rawEvidenceFacts = isRawEvidenceFacts(rawEvidence.facts)
77
- ? rawEvidence.facts
78
- : null;
79
- const riskFlags = generateDumbRiskFlags({
80
- context,
81
- git: gitFacts,
82
- binding,
83
- });
84
- return {
85
- scans: [git.scan, car.scan, rawEvidence.scan, claudeJsonlScan, ...unavailableScans],
86
- facts: {
87
- git: gitFacts,
88
- car: carFacts,
89
- raw_evidence: rawEvidenceFacts,
90
- },
91
- binding,
92
- risk_flags: riskFlags,
93
- };
94
103
  }
95
104
  function collectBindingCandidates(scans) {
96
105
  return scans.flatMap((scan) => scan.ticket_binding_candidates);
@@ -79,6 +79,9 @@ export async function runGitDiff(args, repoRoot) {
79
79
  if (!(await repoRootExists(repoRoot))) {
80
80
  throw new RepoRootMissingError();
81
81
  }
82
+ return captureGitDiff(args, repoRoot);
83
+ }
84
+ function captureGitDiff(args, repoRoot) {
82
85
  return new Promise((resolve, reject) => {
83
86
  const child = spawn("git", [...args, ...SECRET_EXCLUDING_PATHSPEC], {
84
87
  cwd: repoRoot,
@@ -119,8 +122,7 @@ export async function runGitDiff(args, repoRoot) {
119
122
  });
120
123
  child.on("close", (code, signal) => {
121
124
  clearTimeout(timeout);
122
- // A cap that fired killed the child on purpose, so SIGTERM and a
123
- // non-zero code are both success here, not failure.
125
+ // Caps deliberately kill the child; SIGTERM and non-zero codes then count as success.
124
126
  if (code === 0 || truncated || signal === "SIGTERM") {
125
127
  resolve({
126
128
  stdout: Buffer.concat(stdoutChunks).toString("utf8"),
@@ -56,18 +56,7 @@ export function sanitizeTextEvidenceForUpload(options) {
56
56
  };
57
57
  }
58
58
  catch (error) {
59
- // The redactor itself crashed. The stub below is the safe answer — a
60
- // placeholder goes up instead of unredacted bytes — but it is also
61
- // indistinguishable downstream from a file that was legitimately masked,
62
- // so a systematic redactor bug would look like a machine that simply
63
- // writes a lot of secrets. Never the text, never the field values: name,
64
- // code and size only (BLI-3238).
65
- console.error("[raw-evidence-sanitize] redaction crashed; uploading a stub instead of the content", JSON.stringify({
66
- reason: "redaction_crashed",
67
- byte_size: originalBytes.byteLength,
68
- redacted_field_count: options.redactedFields.length,
69
- ...describeError(error),
70
- }));
59
+ logRedactionCrash(error, originalBytes.byteLength, options.redactedFields.length);
71
60
  return stubForCrashedRedaction({
72
61
  text: options.text,
73
62
  originalBytes,
@@ -75,6 +64,20 @@ export function sanitizeTextEvidenceForUpload(options) {
75
64
  });
76
65
  }
77
66
  }
67
+ function logRedactionCrash(error, byteSize, redactedFieldCount) {
68
+ // The redactor itself crashed. The stub below is the safe answer — a
69
+ // placeholder goes up instead of unredacted bytes — but it is also
70
+ // indistinguishable downstream from a file that was legitimately masked,
71
+ // so a systematic redactor bug would look like a machine that simply
72
+ // writes a lot of secrets. Never the text, never the field values: name,
73
+ // code and size only (BLI-3238).
74
+ console.error("[raw-evidence-sanitize] redaction crashed; uploading a stub instead of the content", JSON.stringify({
75
+ reason: "redaction_crashed",
76
+ byte_size: byteSize,
77
+ redacted_field_count: redactedFieldCount,
78
+ ...describeError(error),
79
+ }));
80
+ }
78
81
  /**
79
82
  * The name itself (`.env.production`, `id_rsa`) is the signal, so nothing in
80
83
  * this file is trusted: whatever the redactor left behind is re-checked, and a
@@ -33,14 +33,7 @@ import { admitEvidence, stageEvidenceBytes, } from "./raw-evidence-collection-st
33
33
  */
34
34
  export async function collectOneEvidenceFile(collection, options) {
35
35
  const names = nameEvidenceFile(options);
36
- const skip = (reason) => {
37
- collection.skipped.push({
38
- kind: options.kind,
39
- label: names.evidenceLabel,
40
- reason,
41
- });
42
- return false;
43
- };
36
+ const skip = (reason) => recordEvidenceSkip(collection, options.kind, names.evidenceLabel, reason);
44
37
  // Delivery backoff is checked BEFORE the file is read. An object whose commit
45
38
  // has failed repeatedly costs nothing at all this cycle — no read, no hash,
46
39
  // no copy, no request — and the hold is a named, retryable gap so a held
@@ -58,29 +51,7 @@ export async function collectOneEvidenceFile(collection, options) {
58
51
  markCapApplied(collection, options.kind, "max_file_bytes");
59
52
  return skip("file_too_large");
60
53
  }
61
- // BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
62
- // base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
63
- const imagesPrepared = await prepareEmbeddedImages(collection, {
64
- bytes: read.bytes,
65
- kind: options.kind,
66
- sessionId: options.sessionId,
67
- });
68
- const sanitized = maskSecretsInTranscript(collection, {
69
- raw: imagesPrepared,
70
- kind: options.kind,
71
- evidenceLabel: names.evidenceLabel,
72
- secretLikeFileName: names.secretLikeFileName,
73
- });
74
- const contentHash = sha256(sanitized.bytes);
75
- // BLI-11102: a session the server named a redaction stub is re-offered even
76
- // when these bytes are already durable, because the stub is not a delivery.
77
- const stubClaim = stubReplacementClaim({
78
- kind: options.kind,
79
- stubPointerId: collection.stubReoffers.get(options.sessionId),
80
- completenessLabel: sanitized.completenessLabel,
81
- byteSize: sanitized.bytes.byteLength,
82
- alreadyDurable: collection.skipContentHashes.has(contentHash),
83
- });
54
+ const { sanitized, contentHash, stubClaim } = await prepareTranscriptForUpload(collection, options, names, read.bytes);
84
55
  if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
85
56
  collection.reused.push({
86
57
  kind: options.kind,
@@ -104,6 +75,36 @@ export async function collectOneEvidenceFile(collection, options) {
104
75
  });
105
76
  return true;
106
77
  }
78
+ function recordEvidenceSkip(collection, kind, label, reason) {
79
+ collection.skipped.push({ kind, label, reason });
80
+ return false;
81
+ }
82
+ async function prepareTranscriptForUpload(collection, options, names, bytes) {
83
+ // BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
84
+ // base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
85
+ const imagesPrepared = await prepareEmbeddedImages(collection, {
86
+ bytes,
87
+ kind: options.kind,
88
+ sessionId: options.sessionId,
89
+ });
90
+ const sanitized = maskSecretsInTranscript(collection, {
91
+ raw: imagesPrepared,
92
+ kind: options.kind,
93
+ evidenceLabel: names.evidenceLabel,
94
+ secretLikeFileName: names.secretLikeFileName,
95
+ });
96
+ const contentHash = sha256(sanitized.bytes);
97
+ // BLI-11102: a session the server named a redaction stub is re-offered even
98
+ // when these bytes are already durable, because the stub is not a delivery.
99
+ const stubClaim = stubReplacementClaim({
100
+ kind: options.kind,
101
+ stubPointerId: collection.stubReoffers.get(options.sessionId),
102
+ completenessLabel: sanitized.completenessLabel,
103
+ byteSize: sanitized.bytes.byteLength,
104
+ alreadyDurable: collection.skipContentHashes.has(contentHash),
105
+ });
106
+ return { sanitized, contentHash, stubClaim };
107
+ }
107
108
  /**
108
109
  * The transcript bytes after the image step. Unchanged (the very same buffer) unless this sync has
109
110
  * an image session AND the switch is `describe_and_drop` (images become markers where a description
@@ -16,6 +16,17 @@ export async function runAutostartSelfHeal(paths, options) {
16
16
  return null;
17
17
  if (options.repoRoots.length === 0)
18
18
  return null;
19
+ const problem = await readAutostartProblem(paths, options, platform);
20
+ if (!problem)
21
+ return null;
22
+ const guarded = await prepareRepairAttempt(paths, options, platform, problem.detail);
23
+ if (guarded)
24
+ return guarded;
25
+ if (platform === "darwin")
26
+ return scheduleDarwinHeal(options, problem.detail);
27
+ return repairWindowsAutostart(options, platform, problem.absent, problem.detail);
28
+ }
29
+ async function readAutostartProblem(paths, options, platform) {
19
30
  const status = await autostartStatus({
20
31
  homeDir: options.homeDir,
21
32
  repoRoot: options.repoRoots[0],
@@ -56,6 +67,10 @@ export async function runAutostartSelfHeal(paths, options) {
56
67
  ]
57
68
  .join("; ")
58
69
  .slice(0, DETAIL_MAX_CHARS) || "registration not loaded";
70
+ return { absent, detail: problem };
71
+ }
72
+ async function prepareRepairAttempt(paths, options, platform, problem) {
73
+ const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
59
74
  // Two installs fighting over one registration re-register each other every
60
75
  // tick. Naming that is the repair; rewriting the plist would BE the flapping.
61
76
  const secondInstall = await detectSecondCockpitInstall({
@@ -85,8 +100,9 @@ export async function runAutostartSelfHeal(paths, options) {
85
100
  }
86
101
  await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
87
102
  await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
88
- if (platform === "darwin")
89
- return scheduleDarwinHeal(options, problem);
103
+ return null;
104
+ }
105
+ async function repairWindowsAutostart(options, platform, absent, problem) {
90
106
  const repaired = await installAutostartAgent({
91
107
  homeDir: options.homeDir,
92
108
  repoRoot: options.repoRoots[0],
@@ -172,49 +172,34 @@ function sessionReportEntry(result, context) {
172
172
  : {}),
173
173
  ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
174
174
  ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
175
- ...(upload
176
- ? {
177
- raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
178
- upload_state: upload.upload_state,
179
- // A failed upload names itself; a successful one has nothing to
180
- // explain.
181
- ...(upload.upload_state === "upload_failed"
182
- ? {
183
- upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED,
184
- }
185
- : {}),
186
- }
187
- : priorDurablePointer
188
- ? {
189
- raw_evidence_pointer_id: priorDurablePointer,
190
- upload_state: "reused_existing",
191
- }
192
- : isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
193
- ? {
194
- upload_state: "not_uploaded",
195
- // BLI-2107: `not_uploaded` used to be the branch of last
196
- // resort, recording that nothing happened and never why. It
197
- // now always carries a cause, even when the cause is that we
198
- // have none — a session labelled NO_UPLOAD_ATTEMPT_RECORDED
199
- // is a path that still needs instrumenting, and saying so is
200
- // the point.
201
- upload_reason: context.noUploadReasonByKey.get(key) ??
202
- (context.anySyncIncomplete
203
- ? "sync_incomplete_this_pass"
204
- : NO_UPLOAD_ATTEMPT_RECORDED),
205
- }
206
- : {
207
- // BLI-3272: the upload policy refused this session's
208
- // attribution state. That refusal used to spread `{}` here, so
209
- // the row landed with upload_state NULL and upload_reason NULL
210
- // — a withhold that named nothing, on 1,256 production
211
- // sessions. It is a decision like any other and it says so.
212
- // Any reason the pipeline did record still wins: it is the more
213
- // specific answer.
214
- upload_state: "not_uploaded",
215
- upload_reason: context.noUploadReasonByKey.get(key) ??
216
- notUploadableAttributionStateReason(result.reason),
217
- }),
175
+ ...describeSessionUpload(result, context, key, upload, priorDurablePointer),
176
+ };
177
+ }
178
+ function describeSessionUpload(result, context, key, upload, priorDurablePointer) {
179
+ if (upload) {
180
+ return {
181
+ raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
182
+ upload_state: upload.upload_state,
183
+ ...(upload.upload_state === "upload_failed"
184
+ ? { upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED }
185
+ : {}),
186
+ };
187
+ }
188
+ if (priorDurablePointer) {
189
+ return { raw_evidence_pointer_id: priorDurablePointer, upload_state: "reused_existing" };
190
+ }
191
+ if (isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)) {
192
+ // No upload attempt still needs a reason, even when no more specific cause was recorded.
193
+ return {
194
+ upload_state: "not_uploaded",
195
+ upload_reason: context.noUploadReasonByKey.get(key) ??
196
+ (context.anySyncIncomplete ? "sync_incomplete_this_pass" : NO_UPLOAD_ATTEMPT_RECORDED),
197
+ };
198
+ }
199
+ // The attribution policy's refusal is an outcome; preserve any more specific recorded reason.
200
+ return {
201
+ upload_state: "not_uploaded",
202
+ upload_reason: context.noUploadReasonByKey.get(key) ?? notUploadableAttributionStateReason(result.reason),
218
203
  };
219
204
  }
220
205
  /**
@@ -245,22 +230,8 @@ export function buildAgentSessionReport(options) {
245
230
  * damping counts, and the two budget totals.
246
231
  */
247
232
  export function buildAgentSessionSummary(options) {
248
- const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
249
- const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null));
250
- const sidecarsCollected = attributedClaude.reduce((total, result) => total +
251
- result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
252
- const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
253
- result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
254
- const codex = {
255
- scanned: options.codexAttribution.scanned_file_count,
256
- attributed: options.codexAttribution.counts.attributed,
257
- attributed_fallback: options.codexAttribution.counts.attributed_fallback,
258
- ambiguous: options.codexAttribution.counts.ambiguous,
259
- unattributed: options.codexAttribution.counts.unattributed,
260
- skipped: options.codexAttribution.counts.skipped,
261
- stale: options.codexStaleCount,
262
- read_failures: codexAttributionReadFailureCount(options.codexAttribution),
263
- };
233
+ const { sidecarOutcomes, sidecarsCollected, sidecarsSkipped } = countClaudeSidecars(options);
234
+ const codex = makeCodexFunnel(options);
264
235
  const claude = {
265
236
  scanned: options.claudeAttribution.scanned_session_count,
266
237
  attributed: options.claudeAttribution.counts.attributed,
@@ -298,4 +269,25 @@ export function buildAgentSessionSummary(options) {
298
269
  files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
299
270
  files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
300
271
  };
272
+ }
273
+ function countClaudeSidecars(options) {
274
+ const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
275
+ const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null));
276
+ const sidecarsCollected = attributedClaude.reduce((total, result) => total +
277
+ result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
278
+ const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
279
+ result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
280
+ return { sidecarOutcomes, sidecarsCollected, sidecarsSkipped };
281
+ }
282
+ function makeCodexFunnel(options) {
283
+ return {
284
+ scanned: options.codexAttribution.scanned_file_count,
285
+ attributed: options.codexAttribution.counts.attributed,
286
+ attributed_fallback: options.codexAttribution.counts.attributed_fallback,
287
+ ambiguous: options.codexAttribution.counts.ambiguous,
288
+ unattributed: options.codexAttribution.counts.unattributed,
289
+ skipped: options.codexAttribution.counts.skipped,
290
+ stale: options.codexStaleCount,
291
+ read_failures: codexAttributionReadFailureCount(options.codexAttribution),
292
+ };
301
293
  }
@@ -31,10 +31,6 @@ function top(counts) {
31
31
  export function observeTranscripts(tool, samples) {
32
32
  const models = new Map(), efforts = new Map();
33
33
  let version = null;
34
- const bump = (map, value) => {
35
- if (typeof value === "string" && value && !value.startsWith("<"))
36
- map.set(value, (map.get(value) ?? 0) + 1);
37
- };
38
34
  for (const sample of samples)
39
35
  for (const line of sample.split("\n")) {
40
36
  if (!line.startsWith("{"))
@@ -46,25 +42,33 @@ export function observeTranscripts(tool, samples) {
46
42
  catch {
47
43
  continue;
48
44
  } // a chunk edge can cut a line
49
- const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : {};
50
- if (tool === "claude") {
51
- if (typeof entry.version === "string")
52
- version = newerVersion(version, entry.version);
53
- const message = entry.message && typeof entry.message === "object" ? entry.message : {};
54
- if (entry.type === "assistant")
55
- bump(models, message.model);
56
- }
57
- else if (entry.type === "session_meta") {
58
- if (typeof payload.cli_version === "string")
59
- version = newerVersion(version, payload.cli_version);
60
- }
61
- else if (entry.type === "turn_context") {
62
- bump(models, payload.model);
63
- bump(efforts, payload.effort);
64
- }
45
+ version = observeTranscriptEntry(tool, entry, version, models, efforts);
65
46
  }
66
47
  return { version, model: top(models), effort: top(efforts), files: samples.length };
67
48
  }
49
+ function observeTranscriptEntry(tool, entry, version, models, efforts) {
50
+ const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : {};
51
+ if (tool === "claude") {
52
+ if (typeof entry.version === "string")
53
+ version = newerVersion(version, entry.version);
54
+ const message = entry.message && typeof entry.message === "object" ? entry.message : {};
55
+ if (entry.type === "assistant")
56
+ countObservedValue(models, message.model);
57
+ }
58
+ else if (entry.type === "session_meta") {
59
+ if (typeof payload.cli_version === "string")
60
+ version = newerVersion(version, payload.cli_version);
61
+ }
62
+ else if (entry.type === "turn_context") {
63
+ countObservedValue(models, payload.model);
64
+ countObservedValue(efforts, payload.effort);
65
+ }
66
+ return version;
67
+ }
68
+ function countObservedValue(map, value) {
69
+ if (typeof value === "string" && value && !value.startsWith("<"))
70
+ map.set(value, (map.get(value) ?? 0) + 1);
71
+ }
68
72
  async function sampleFile(file) {
69
73
  let handle;
70
74
  try {
@@ -110,14 +114,17 @@ async function newestCodexFiles(home) {
110
114
  for (const year of await newestFirst(root))
111
115
  for (const month of await newestFirst(path.join(root, year)))
112
116
  for (const day of await newestFirst(path.join(root, year, month))) {
113
- for (const name of await newestFirst(path.join(root, year, month, day)))
114
- if (name.endsWith(".jsonl") && files.length < TRANSCRIPT_FILES)
115
- files.push(path.join(root, year, month, day, name));
117
+ await collectCodexDayFiles(path.join(root, year, month, day), files);
116
118
  if (files.length >= TRANSCRIPT_FILES)
117
119
  return files;
118
120
  }
119
121
  return files;
120
122
  }
123
+ async function collectCodexDayFiles(directory, files) {
124
+ for (const name of await (await entries(directory)).sort().reverse())
125
+ if (name.endsWith(".jsonl") && files.length < TRANSCRIPT_FILES)
126
+ files.push(path.join(directory, name));
127
+ }
121
128
  /** Text samples of the newest transcripts of one tool in the last 30 days. Never throws. */
122
129
  export async function readTranscriptSamples(tool, home, now = Date.now()) {
123
130
  try {