@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
@@ -19,95 +19,18 @@ import { CLAUDE_SESSION_MAX_FILE_BYTES, } from "./claude-attribution-types.js";
19
19
  * work and must not inflate this session's numbers.
20
20
  */
21
21
  export async function attributeOneSession(session, worktrees, collectionRoots) {
22
- const fileName = path.basename(session.mainFile);
23
22
  const factsAcc = newSessionFactsAccumulator("claude_code");
24
- const base = {
25
- file_path: session.mainFile,
26
- file_name: fileName,
27
- claude_session_id: sessionIdFromFileName(fileName) ?? shortHash(session.mainFile),
28
- cwd_basename: null,
29
- cwd_hash: null,
30
- session_file_mtime: new Date(session.mainMtimeMs).toISOString(),
31
- session_file_mtime_ms: session.mainMtimeMs,
32
- session_recency_ms: session.recencyMs,
33
- byte_size: session.mainByteSize,
34
- content_hash_sha256: null,
35
- main_file_oversized: false,
36
- oversized_lines_skipped: 0,
37
- sidecars_capped: session.sidecarsCapped,
38
- sidecar_files: [],
39
- session_facts: null,
40
- };
23
+ const base = createSessionBase(session);
41
24
  if (session.mainByteSize === 0) {
42
25
  return skippedResult(base, "empty_file");
43
26
  }
44
- let signals;
45
- const oversized = session.mainByteSize > CLAUDE_SESSION_MAX_FILE_BYTES;
46
- if (oversized) {
47
- // D7: stream the oversized main so metadata signals remain available. The
48
- // bytes themselves are not uploaded until the collection ceiling changes.
49
- let streamed;
50
- try {
51
- streamed = await streamMainSignals(session.mainFile, factsAcc);
52
- }
53
- catch (error) {
54
- // A read race on one oversized main must not abort the whole scan; honor
55
- // the per-file skip contract. Per session, so worth a line each time:
56
- // this is a whole session nobody will ever coach on (BLI-3238).
57
- console.error("[claude-attribution] oversized session could not be streamed, skipping it", JSON.stringify({
58
- reason: "file_read_failed",
59
- stage: "stream_oversized_main",
60
- byte_size: session.mainByteSize,
61
- ...describeError(error),
62
- }));
63
- return skippedResult(base, "file_read_failed");
64
- }
65
- signals = streamed.signals;
66
- observeSessionFactsFile(factsAcc, {
67
- bytes: streamed.byteSize,
68
- lines: streamed.signals.line_count,
69
- });
70
- base.content_hash_sha256 = streamed.contentHash;
71
- base.byte_size = streamed.byteSize;
72
- base.main_file_oversized = true;
73
- base.oversized_lines_skipped = streamed.oversizedLinesSkipped;
74
- }
75
- else {
76
- let raw;
77
- try {
78
- raw = await fs.readFile(session.mainFile);
79
- }
80
- catch (error) {
81
- console.error("[claude-attribution] session file unreadable, skipping it", JSON.stringify({
82
- reason: "file_read_failed",
83
- stage: "read_main",
84
- byte_size: session.mainByteSize,
85
- ...describeError(error),
86
- }));
87
- return skippedResult(base, "file_read_failed");
88
- }
89
- const content = raw.toString("utf8");
90
- base.content_hash_sha256 = sha256(raw);
91
- base.byte_size = raw.byteLength;
92
- if (content.trim() === "") {
93
- return skippedResult(base, "empty_file");
94
- }
95
- signals = extractClaudeSessionSignals(content, factsAcc);
96
- observeSessionFactsFile(factsAcc, {
97
- bytes: raw.byteLength,
98
- lines: signals.line_count,
99
- });
100
- }
101
- const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
102
- if (metaSessionId) {
103
- base.claude_session_id = metaSessionId;
104
- }
105
- const primaryCwd = signals.cwds[0] ?? null;
106
- if (primaryCwd) {
107
- base.local_cwd = primaryCwd;
108
- base.cwd_basename = (path.win32.isAbsolute(primaryCwd) ? path.win32 : path.posix).basename(primaryCwd) || null;
109
- base.cwd_hash = shortHash(primaryCwd);
110
- }
27
+ const read = session.mainByteSize > CLAUDE_SESSION_MAX_FILE_BYTES
28
+ ? await streamOversizedMain(session, base, factsAcc)
29
+ : await readMainSignals(session, base, factsAcc);
30
+ if (typeof read === "string")
31
+ return skippedResult(base, read);
32
+ const signals = read;
33
+ applySessionMetadata(base, signals);
111
34
  if (signals.line_count === 0 || signals.parse_error_count === signals.line_count) {
112
35
  return {
113
36
  ...base,
@@ -131,15 +54,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
131
54
  extraSignals.push("main_file_oversized");
132
55
  if (isSchemaDriftSuspected(signals))
133
56
  extraSignals.push("schema_drift_suspected");
134
- const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
135
- ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree, factsAcc)
136
- : session.sidecars.map((sidecar) => ({
137
- local_path: sidecar.local_path,
138
- file_name: sidecar.file_name,
139
- byte_size: sidecar.byteSize,
140
- content_hash_sha256: null,
141
- skipped_reason: null,
142
- }));
57
+ const sidecarFiles = await collectSessionSidecars(session, outcome, factsAcc);
143
58
  return {
144
59
  ...base,
145
60
  session_facts: finalizeSessionFacts(factsAcc),
@@ -152,6 +67,105 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
152
67
  sidecar_files: sidecarFiles,
153
68
  };
154
69
  }
70
+ function createSessionBase(session) {
71
+ const fileName = path.basename(session.mainFile);
72
+ return {
73
+ file_path: session.mainFile,
74
+ file_name: fileName,
75
+ claude_session_id: sessionIdFromFileName(fileName) ?? shortHash(session.mainFile),
76
+ cwd_basename: null,
77
+ cwd_hash: null,
78
+ session_file_mtime: new Date(session.mainMtimeMs).toISOString(),
79
+ session_file_mtime_ms: session.mainMtimeMs,
80
+ session_recency_ms: session.recencyMs,
81
+ byte_size: session.mainByteSize,
82
+ content_hash_sha256: null,
83
+ main_file_oversized: false,
84
+ oversized_lines_skipped: 0,
85
+ sidecars_capped: session.sidecarsCapped,
86
+ sidecar_files: [],
87
+ session_facts: null,
88
+ };
89
+ }
90
+ async function streamOversizedMain(session, base, factsAcc) {
91
+ // D7: stream the oversized main so metadata signals remain available. The
92
+ // bytes themselves are not uploaded until the collection ceiling changes.
93
+ let streamed;
94
+ try {
95
+ streamed = await streamMainSignals(session.mainFile, factsAcc);
96
+ }
97
+ catch (error) {
98
+ // A read race on one oversized main must not abort the whole scan; honor
99
+ // the per-file skip contract. Per session, so worth a line each time:
100
+ // this is a whole session nobody will ever coach on (BLI-3238).
101
+ console.error("[claude-attribution] oversized session could not be streamed, skipping it", JSON.stringify({
102
+ reason: "file_read_failed",
103
+ stage: "stream_oversized_main",
104
+ byte_size: session.mainByteSize,
105
+ ...describeError(error),
106
+ }));
107
+ return "file_read_failed";
108
+ }
109
+ observeSessionFactsFile(factsAcc, {
110
+ bytes: streamed.byteSize,
111
+ lines: streamed.signals.line_count,
112
+ });
113
+ base.content_hash_sha256 = streamed.contentHash;
114
+ base.byte_size = streamed.byteSize;
115
+ base.main_file_oversized = true;
116
+ base.oversized_lines_skipped = streamed.oversizedLinesSkipped;
117
+ return streamed.signals;
118
+ }
119
+ async function readMainSignals(session, base, factsAcc) {
120
+ let raw;
121
+ try {
122
+ raw = await fs.readFile(session.mainFile);
123
+ }
124
+ catch (error) {
125
+ console.error("[claude-attribution] session file unreadable, skipping it", JSON.stringify({
126
+ reason: "file_read_failed",
127
+ stage: "read_main",
128
+ byte_size: session.mainByteSize,
129
+ ...describeError(error),
130
+ }));
131
+ return "file_read_failed";
132
+ }
133
+ const content = raw.toString("utf8");
134
+ base.content_hash_sha256 = sha256(raw);
135
+ base.byte_size = raw.byteLength;
136
+ if (content.trim() === "") {
137
+ return "empty_file";
138
+ }
139
+ const signals = extractClaudeSessionSignals(content, factsAcc);
140
+ observeSessionFactsFile(factsAcc, {
141
+ bytes: raw.byteLength,
142
+ lines: signals.line_count,
143
+ });
144
+ return signals;
145
+ }
146
+ function applySessionMetadata(base, signals) {
147
+ const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
148
+ if (metaSessionId) {
149
+ base.claude_session_id = metaSessionId;
150
+ }
151
+ const primaryCwd = signals.cwds[0] ?? null;
152
+ if (primaryCwd) {
153
+ base.local_cwd = primaryCwd;
154
+ base.cwd_basename = (path.win32.isAbsolute(primaryCwd) ? path.win32 : path.posix).basename(primaryCwd) || null;
155
+ base.cwd_hash = shortHash(primaryCwd);
156
+ }
157
+ }
158
+ async function collectSessionSidecars(session, outcome, factsAcc) {
159
+ return isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
160
+ ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree, factsAcc)
161
+ : session.sidecars.map((sidecar) => ({
162
+ local_path: sidecar.local_path,
163
+ file_name: sidecar.file_name,
164
+ byte_size: sidecar.byteSize,
165
+ content_hash_sha256: null,
166
+ skipped_reason: null,
167
+ }));
168
+ }
155
169
  /**
156
170
  * Validates and content-addresses an attributed session's sidecars. Each
157
171
  * sidecar's envelope cwd must resolve within the attributed worktree root, or
@@ -75,20 +75,21 @@ function createSignalAccumulator(facts) {
75
75
  contentRecordsWithEnvelope += 1;
76
76
  }
77
77
  },
78
- finalize() {
79
- return {
80
- session_ids: [...sessionIds],
81
- cwds: [...cwds],
82
- branches: [...branches],
83
- repository_urls: [...repositoryUrls],
84
- line_count: lineCount,
85
- parse_error_count: parseErrorCount,
86
- content_record_count: contentRecordCount,
87
- envelope_field_hit_rate: contentRecordCount === 0
88
- ? 1
89
- : contentRecordsWithEnvelope / contentRecordCount,
90
- };
91
- },
78
+ finalize: () => finalizeSignals(sessionIds, cwds, branches, repositoryUrls, lineCount, parseErrorCount, contentRecordCount, contentRecordsWithEnvelope),
79
+ };
80
+ }
81
+ function finalizeSignals(sessionIds, cwds, branches, repositoryUrls, lineCount, parseErrorCount, contentRecordCount, contentRecordsWithEnvelope) {
82
+ return {
83
+ session_ids: [...sessionIds],
84
+ cwds: [...cwds],
85
+ branches: [...branches],
86
+ repository_urls: [...repositoryUrls],
87
+ line_count: lineCount,
88
+ parse_error_count: parseErrorCount,
89
+ content_record_count: contentRecordCount,
90
+ envelope_field_hit_rate: contentRecordCount === 0
91
+ ? 1
92
+ : contentRecordsWithEnvelope / contentRecordCount,
92
93
  };
93
94
  }
94
95
  export function isSchemaDriftSuspected(signals) {
@@ -106,8 +107,40 @@ export function isSchemaDriftSuspected(signals) {
106
107
  export async function streamMainSignals(filePath, facts) {
107
108
  const accumulator = createSignalAccumulator(facts);
108
109
  const hash = crypto.createHash("sha256");
109
- let oversizedLinesSkipped = 0;
110
110
  let byteSize = 0;
111
+ const lines = bufferSignalLines(accumulator);
112
+ const { consumeText } = lines;
113
+ // StringDecoder buffers an incomplete multibyte char across chunk boundaries
114
+ // so a UTF-8 character split by the read boundary never decodes to a fragment
115
+ // that could weaken the secret guard or corrupt a cwd signal. The content
116
+ // hash is over raw bytes (below) and is unaffected either way.
117
+ const decoder = new StringDecoder("utf8");
118
+ await new Promise((resolve, reject) => {
119
+ const stream = createReadStream(filePath);
120
+ stream.on("data", (chunk) => {
121
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
122
+ hash.update(buffer);
123
+ byteSize += buffer.byteLength;
124
+ consumeText(decoder.write(buffer));
125
+ });
126
+ stream.on("end", () => {
127
+ const tail = decoder.end();
128
+ if (tail)
129
+ consumeText(tail);
130
+ lines.finish();
131
+ resolve();
132
+ });
133
+ stream.on("error", reject);
134
+ });
135
+ return {
136
+ signals: accumulator.finalize(),
137
+ oversizedLinesSkipped: lines.skippedCount(),
138
+ contentHash: hash.digest("hex"),
139
+ byteSize,
140
+ };
141
+ }
142
+ function bufferSignalLines(accumulator) {
143
+ let oversizedLinesSkipped = 0;
111
144
  let pending = "";
112
145
  let pendingTruncated = false;
113
146
  const flushCompletedLine = (lineText) => {
@@ -123,11 +156,6 @@ export async function streamMainSignals(filePath, facts) {
123
156
  pending = "";
124
157
  accumulator.processLine(full);
125
158
  };
126
- // StringDecoder buffers an incomplete multibyte char across chunk boundaries
127
- // so a UTF-8 character split by the read boundary never decodes to a fragment
128
- // that could weaken the secret guard or corrupt a cwd signal. The content
129
- // hash is over raw bytes (below) and is unaffected either way.
130
- const decoder = new StringDecoder("utf8");
131
159
  const consumeText = (text) => {
132
160
  let remaining = text;
133
161
  let newlineIndex = remaining.indexOf("\n");
@@ -146,29 +174,11 @@ export async function streamMainSignals(filePath, facts) {
146
174
  pending += remaining;
147
175
  }
148
176
  };
149
- await new Promise((resolve, reject) => {
150
- const stream = createReadStream(filePath);
151
- stream.on("data", (chunk) => {
152
- const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
153
- hash.update(buffer);
154
- byteSize += buffer.byteLength;
155
- consumeText(decoder.write(buffer));
156
- });
157
- stream.on("end", () => {
158
- const tail = decoder.end();
159
- if (tail)
160
- consumeText(tail);
161
- if (pending.length > 0 || pendingTruncated)
162
- flushCompletedLine("");
163
- resolve();
164
- });
165
- stream.on("error", reject);
166
- });
167
177
  return {
168
- signals: accumulator.finalize(),
169
- oversizedLinesSkipped,
170
- contentHash: hash.digest("hex"),
171
- byteSize,
178
+ consumeText, flushCompletedLine,
179
+ finish: () => { if (pending.length > 0 || pendingTruncated)
180
+ flushCompletedLine(""); },
181
+ skippedCount: () => oversizedLinesSkipped
172
182
  };
173
183
  }
174
184
  function normalizePrRepository(repository) {
@@ -120,25 +120,28 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
120
120
  }
121
121
  }
122
122
  }
123
+ logDiscoveryFailures(directoryReadFailedCount, statFailedCount, out.length, firstDirectoryFailure, firstStatFailure);
124
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
125
+ return {
126
+ files: out,
127
+ directoryReadFailedCount,
128
+ statFailedCount,
129
+ secretPathSkippedCount,
130
+ };
131
+ }
132
+ function logDiscoveryFailures(directoryReadFailedCount, statFailedCount, foundFileCount, firstDirectoryFailure, firstStatFailure) {
123
133
  if (directoryReadFailedCount > 0 || statFailedCount > 0) {
124
134
  console.error("[codex-attribution] Codex sessions were invisible to the walk", JSON.stringify({
125
135
  reason: "session_discovery_incomplete",
126
136
  directory_read_failed_count: directoryReadFailedCount,
127
137
  stat_failed_count: statFailedCount,
128
- found_file_count: out.length,
138
+ found_file_count: foundFileCount,
129
139
  ...(firstDirectoryFailure
130
140
  ? { first_directory_failure: firstDirectoryFailure }
131
141
  : {}),
132
142
  ...(firstStatFailure ? { first_stat_failure: firstStatFailure } : {}),
133
143
  }));
134
144
  }
135
- out.sort((a, b) => b.mtimeMs - a.mtimeMs);
136
- return {
137
- files: out,
138
- directoryReadFailedCount,
139
- statFailedCount,
140
- secretPathSkippedCount,
141
- };
142
145
  }
143
146
  function isMissingPathError(error) {
144
147
  return (error instanceof Error &&
@@ -162,20 +165,7 @@ function dedupePaths(values) {
162
165
  return out;
163
166
  }
164
167
  async function attributeOneSession(file, worktrees, collectionRoots) {
165
- const fileName = path.basename(file.file);
166
- const base = {
167
- file_path: file.file,
168
- file_name: fileName,
169
- codex_session_id: sessionIdFromFileName(fileName) ?? shortHash(file.file),
170
- session_id_source: sessionIdFromFileName(fileName) ? "file_name" : "path_hash",
171
- cwd_basename: null,
172
- cwd_hash: null,
173
- session_file_mtime: new Date(file.mtimeMs).toISOString(),
174
- session_file_mtime_ms: file.mtimeMs,
175
- byte_size: file.byteSize,
176
- content_hash_sha256: null,
177
- session_facts: null,
178
- };
168
+ const base = createSessionBase(file);
179
169
  let read;
180
170
  try {
181
171
  read = await readCodexMetadataSignals(file.file);
@@ -195,17 +185,7 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
195
185
  base.byte_size = read.byteSize;
196
186
  base.session_facts = read.facts;
197
187
  const signals = read.signals;
198
- const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
199
- if (metaSessionId) {
200
- base.codex_session_id = metaSessionId;
201
- base.session_id_source = "session_meta";
202
- }
203
- const primaryCwd = signals.cwds[0] ?? null;
204
- if (primaryCwd) {
205
- base.local_cwd = primaryCwd;
206
- base.cwd_basename = (path.win32.isAbsolute(primaryCwd) ? path.win32 : path.posix).basename(primaryCwd) || null;
207
- base.cwd_hash = shortHash(primaryCwd);
208
- }
188
+ applySessionMetadata(base, signals);
209
189
  if (signals.line_count === 0 || signals.parse_error_count === signals.line_count) {
210
190
  return {
211
191
  ...base,
@@ -226,6 +206,35 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
226
206
  }, worktrees, { collectionRoots, pathExists: existsSync });
227
207
  return { ...base, ...outcome };
228
208
  }
209
+ function createSessionBase(file) {
210
+ const fileName = path.basename(file.file);
211
+ return {
212
+ file_path: file.file,
213
+ file_name: fileName,
214
+ codex_session_id: sessionIdFromFileName(fileName) ?? shortHash(file.file),
215
+ session_id_source: sessionIdFromFileName(fileName) ? "file_name" : "path_hash",
216
+ cwd_basename: null,
217
+ cwd_hash: null,
218
+ session_file_mtime: new Date(file.mtimeMs).toISOString(),
219
+ session_file_mtime_ms: file.mtimeMs,
220
+ byte_size: file.byteSize,
221
+ content_hash_sha256: null,
222
+ session_facts: null,
223
+ };
224
+ }
225
+ function applySessionMetadata(base, signals) {
226
+ const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
227
+ if (metaSessionId) {
228
+ base.codex_session_id = metaSessionId;
229
+ base.session_id_source = "session_meta";
230
+ }
231
+ const primaryCwd = signals.cwds[0] ?? null;
232
+ if (primaryCwd) {
233
+ base.local_cwd = primaryCwd;
234
+ base.cwd_basename = (path.win32.isAbsolute(primaryCwd) ? path.win32 : path.posix).basename(primaryCwd) || null;
235
+ base.cwd_hash = shortHash(primaryCwd);
236
+ }
237
+ }
229
238
  function skippedResult(base, reason) {
230
239
  return {
231
240
  ...base,
@@ -142,15 +142,17 @@ function absorbCodexSessionLine(state, line) {
142
142
  }
143
143
  else if (type === "turn_context") {
144
144
  addString(state.cwds, payloadRecord["cwd"]);
145
- const roots = payloadRecord["workspace_roots"];
146
- if (Array.isArray(roots)) {
147
- for (const root of roots) {
148
- if (typeof root === "string") {
149
- addString(state.workspaceRoots, root);
150
- }
151
- else if (root && typeof root === "object") {
152
- addString(state.workspaceRoots, root["path"]);
153
- }
145
+ absorbWorkspaceRoots(state, payloadRecord["workspace_roots"]);
146
+ }
147
+ }
148
+ function absorbWorkspaceRoots(state, roots) {
149
+ if (Array.isArray(roots)) {
150
+ for (const root of roots) {
151
+ if (typeof root === "string") {
152
+ addString(state.workspaceRoots, root);
153
+ }
154
+ else if (root && typeof root === "object") {
155
+ addString(state.workspaceRoots, root["path"]);
154
156
  }
155
157
  }
156
158
  }
@@ -6,56 +6,7 @@ const execFileAsync = promisify(execFile);
6
6
  export async function collectGitState(context, runner = defaultGitCommandRunner()) {
7
7
  const startedAt = context.now.toISOString();
8
8
  try {
9
- const repoRoot = (await runner.runGit(["rev-parse", "--show-toplevel"], context.repoRoot, 2_000)).trim() ||
10
- context.repoRoot;
11
- const branch = (await runner.runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot, 2_000)).trim() ||
12
- context.branch;
13
- const porcelain = await runner.runGit(["status", "--porcelain=v1"], repoRoot, 2_000);
14
- const unstagedNumstat = await runner.runGit(["diff", "--numstat"], repoRoot, 2_000);
15
- const stagedNumstat = await runner.runGit(["diff", "--cached", "--numstat"], repoRoot, 2_000);
16
- const fileStats = [...parseNumstat(unstagedNumstat), ...parseNumstat(stagedNumstat)];
17
- const addedLines = fileStats.reduce((sum, file) => sum + file.added_lines, 0);
18
- const deletedLines = fileStats.reduce((sum, file) => sum + file.deleted_lines, 0);
19
- const changedPaths = [
20
- ...new Set([
21
- ...parsePorcelainPaths(porcelain),
22
- ...fileStats.map((file) => file.path),
23
- ]),
24
- ].sort();
25
- const ticketIdFromBranch = parseTicketIdFromBranch(branch);
26
- const ticketBindingCandidates = ticketIdFromBranch
27
- ? [
28
- {
29
- ticket_id: ticketIdFromBranch,
30
- binding_source: "branch_name",
31
- confidence: 0.85,
32
- evidence_labels: [`branch:${branch}`],
33
- },
34
- ]
35
- : [];
36
- const facts = {
37
- repo_root: repoRoot,
38
- branch,
39
- changed_file_count: changedPaths.length,
40
- added_lines: addedLines,
41
- deleted_lines: deletedLines,
42
- changed_paths: changedPaths,
43
- ticket_id_from_branch: ticketIdFromBranch,
44
- };
45
- const scan = makeGitScan({
46
- context,
47
- startedAt,
48
- status: "ok",
49
- branch,
50
- facts,
51
- ticketBindingCandidates,
52
- diagnosticLabels: [
53
- `changed_files:${facts.changed_file_count}`,
54
- `added_lines:${facts.added_lines}`,
55
- `deleted_lines:${facts.deleted_lines}`,
56
- ],
57
- });
58
- return { scan, facts };
9
+ return await collectAvailableGitState(context, runner, startedAt);
59
10
  }
60
11
  catch (error) {
61
12
  const scan = makeGitScan({
@@ -72,6 +23,61 @@ export async function collectGitState(context, runner = defaultGitCommandRunner(
72
23
  return { scan, facts: { git_unavailable: true } };
73
24
  }
74
25
  }
26
+ async function collectAvailableGitState(context, runner, startedAt) {
27
+ const repoRoot = (await runner.runGit(["rev-parse", "--show-toplevel"], context.repoRoot, 2_000)).trim() ||
28
+ context.repoRoot;
29
+ const branch = (await runner.runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot, 2_000)).trim() ||
30
+ context.branch;
31
+ const porcelain = await runner.runGit(["status", "--porcelain=v1"], repoRoot, 2_000);
32
+ const unstagedNumstat = await runner.runGit(["diff", "--numstat"], repoRoot, 2_000);
33
+ const stagedNumstat = await runner.runGit(["diff", "--cached", "--numstat"], repoRoot, 2_000);
34
+ const fileStats = [...parseNumstat(unstagedNumstat), ...parseNumstat(stagedNumstat)];
35
+ const addedLines = fileStats.reduce((sum, file) => sum + file.added_lines, 0);
36
+ const deletedLines = fileStats.reduce((sum, file) => sum + file.deleted_lines, 0);
37
+ const changedPaths = [
38
+ ...new Set([
39
+ ...parsePorcelainPaths(porcelain),
40
+ ...fileStats.map((file) => file.path),
41
+ ]),
42
+ ].sort();
43
+ const ticketIdFromBranch = parseTicketIdFromBranch(branch);
44
+ const ticketBindingCandidates = bindBranchTicket(ticketIdFromBranch, branch);
45
+ const facts = {
46
+ repo_root: repoRoot,
47
+ branch,
48
+ changed_file_count: changedPaths.length,
49
+ added_lines: addedLines,
50
+ deleted_lines: deletedLines,
51
+ changed_paths: changedPaths,
52
+ ticket_id_from_branch: ticketIdFromBranch,
53
+ };
54
+ const scan = makeGitScan({
55
+ context,
56
+ startedAt,
57
+ status: "ok",
58
+ branch,
59
+ facts,
60
+ ticketBindingCandidates,
61
+ diagnosticLabels: [
62
+ `changed_files:${facts.changed_file_count}`,
63
+ `added_lines:${facts.added_lines}`,
64
+ `deleted_lines:${facts.deleted_lines}`,
65
+ ],
66
+ });
67
+ return { scan, facts };
68
+ }
69
+ function bindBranchTicket(ticketIdFromBranch, branch) {
70
+ return ticketIdFromBranch
71
+ ? [
72
+ {
73
+ ticket_id: ticketIdFromBranch,
74
+ binding_source: "branch_name",
75
+ confidence: 0.85,
76
+ evidence_labels: [`branch:${branch}`],
77
+ },
78
+ ]
79
+ : [];
80
+ }
75
81
  export function parseNumstat(raw) {
76
82
  return raw
77
83
  .split("\n")