@bli-cockpit/cli 0.1.20 → 0.1.22

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
@@ -14,24 +14,24 @@ device pairing.
14
14
  Run this from the repo where work will happen:
15
15
 
16
16
  ```bash
17
- COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
18
17
  npm install -g @bli-cockpit/cli@latest
19
- cockpit onboard \
20
- --email <APPROVED_EMAIL> \
21
- --device-name "$COCKPIT_DEVICE_NAME" \
22
- --workspace "$PWD"
18
+ cockpit onboard --workspace "$PWD"
19
+ ```
20
+
21
+ Interactive onboarding prompts for the dashboard email. Enter the approved
22
+ intern/operator email. For reused laptops, VMs, or headless/agent-run setup,
23
+ pass the email explicitly:
24
+
25
+ ```bash
26
+ cockpit onboard --email <APPROVED_EMAIL> --workspace "$PWD"
23
27
  ```
24
28
 
25
29
  If a work folder contains multiple repos, run the same command from the parent:
26
30
 
27
31
  ```bash
28
32
  cd ~/Downloads/bluepearl-workspace
29
- COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
30
33
  npm install -g @bli-cockpit/cli@latest
31
- cockpit onboard \
32
- --email <APPROVED_EMAIL> \
33
- --device-name "$COCKPIT_DEVICE_NAME" \
34
- --workspace "$PWD"
34
+ cockpit onboard --workspace "$PWD"
35
35
  ```
36
36
 
37
37
  The CLI defaults to the production dashboard. Normal intern/operator setup,
@@ -50,6 +50,11 @@ to install or replace it. The managed guidance is scoped to the workspace path
50
50
  used for onboarding, so agents should ignore it in private chats or unrelated
51
51
  repos.
52
52
 
53
+ For intern machines, accept the onboarding prompt to keep Cockpit syncing in the
54
+ background. It installs the launchd autostart agent so Cockpit syncs at login
55
+ and every 15 minutes. If skipped, run
56
+ `cockpit autostart install --workspace "$PWD"` later.
57
+
53
58
  ```bash
54
59
  # Repair/manual path, or headless/json onboarding where Cockpit cannot prompt.
55
60
  cockpit agent-rules install --workspace "$PWD"
@@ -79,33 +84,40 @@ What happens:
79
84
  1. npm installs or updates the public `@bli-cockpit/cli` package.
80
85
  2. `cockpit onboard` writes local user config.
81
86
  3. Cockpit prints a dashboard pairing URL and code.
82
- 4. Admin approves the pending request from Ambient -> Collector approvals, or
83
- pastes the printed code there. The signed-in intern can still use the
84
- printed URL.
87
+ 4. Admin approves the persistent email access request from Ambient -> Collector
88
+ approvals. Pair codes still expire, but the access request remains. If the
89
+ terminal is still polling, approval pairs it immediately; if not, the intern
90
+ reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace "$PWD"` and the
91
+ stored approval auto-pairs without another Admin action. Admin can still
92
+ paste the printed code there as a rescue path. The signed-in intern can still
93
+ use the printed URL. If the claimed email is not an app user yet, a super
94
+ admin approval creates the user, adds team membership, and sends the
95
+ Supabase invite flow automatically.
85
96
  5. The CLI starts general ambient capture, uploads private raw evidence objects
86
97
  when present (chunked and resumable, with identical content acknowledged
87
98
  instead of re-uploaded), then uploads one safe metadata/ref envelope.
88
99
  6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
89
100
 
90
- `--device-name` is just a readable label in Cockpit. It can be
91
- `"Savina MacBook"`, `"Box VM 42"`, or the auto-filled macOS computer name.
101
+ `--device-name` is optional and only a readable label in Cockpit. Pass it only
102
+ when you want a specific label such as `"Savina MacBook"` or `"Box VM 42"`.
92
103
  On reused laptops or VMs, keep `--email <APPROVED_EMAIL>` in the command.
93
104
  `cockpit onboard` skips pairing only when the existing valid session belongs to
94
105
  that same email and dashboard URL (the production default unless overridden); a
95
106
  different email or dashboard forces a new approval.
96
107
 
97
- Before the command runs, an Admin must create or approve the email in the
98
- private dashboard. Default launch path is email + temporary password, handed
99
- over out of band. Magic link remains available as fallback.
100
- For temporary-password accounts, the intern signs in directly; there is no
101
- separate invite acceptance step.
108
+ Before the command runs, an Admin does not need to open Supabase or pre-create
109
+ ordinary interns in Settings -> Team. Approval of the claimed email in Ambient
110
+ creates the missing app user and stores the durable access approval. Settings
111
+ -> Team remains available for manual pre-provisioning, including
112
+ temporary-password launch. For temporary-password accounts, the intern signs in
113
+ directly; there is no separate invite acceptance step.
102
114
 
103
115
  When ticket work starts later:
104
116
 
105
117
  ```bash
106
118
  cockpit start \
107
119
  --ticket <ticket-id> \
108
- --repo "$PWD"
120
+ --workspace "$PWD"
109
121
 
110
122
  cockpit sync \
111
123
  --workspace "$PWD" \
@@ -54,8 +54,19 @@ export async function scanAndAttributeClaudeSessions(options) {
54
54
  const countState = (state) => results.filter((result) => result.state === state).length;
55
55
  return {
56
56
  results,
57
+ discovered_session_count: discovery.sessions.length,
57
58
  scanned_session_count: sessions.length,
59
+ since_minutes: sinceMinutes,
60
+ session_limit: limit,
61
+ session_limit_applied: discovery.sessions.length > limit,
62
+ max_file_bytes: CLAUDE_SESSION_MAX_FILE_BYTES,
63
+ max_sidecar_files: CLAUDE_SESSION_MAX_SIDECAR_FILES,
64
+ max_line_buffer_bytes: MAX_LINE_BUFFER_BYTES,
58
65
  project_dirs_skipped: discovery.projectDirsSkipped,
66
+ project_dir_read_failed_count: discovery.projectDirReadFailedCount,
67
+ session_stat_failed_count: discovery.sessionStatFailedCount,
68
+ sidecar_dir_read_failed_count: discovery.sidecarDirReadFailedCount,
69
+ sidecar_stat_failed_count: discovery.sidecarStatFailedCount,
59
70
  counts: {
60
71
  attributed: countState("attributed"),
61
72
  ambiguous: countState("ambiguous"),
@@ -72,12 +83,23 @@ export async function scanAndAttributeClaudeSessions(options) {
72
83
  async function discoverClaudeSessions(projectsDir, cutoffMs) {
73
84
  const sessions = [];
74
85
  let projectDirsSkipped = 0;
86
+ let projectDirReadFailedCount = 0;
87
+ let sessionStatFailedCount = 0;
88
+ let sidecarDirReadFailedCount = 0;
89
+ let sidecarStatFailedCount = 0;
75
90
  let projectEntries;
76
91
  try {
77
92
  projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
78
93
  }
79
94
  catch {
80
- return { sessions, projectDirsSkipped };
95
+ return {
96
+ sessions,
97
+ projectDirsSkipped,
98
+ projectDirReadFailedCount: 1,
99
+ sessionStatFailedCount,
100
+ sidecarDirReadFailedCount,
101
+ sidecarStatFailedCount,
102
+ };
81
103
  }
82
104
  for (const projectEntry of projectEntries) {
83
105
  if (!projectEntry.isDirectory())
@@ -96,6 +118,7 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
96
118
  sessionEntries = await fs.readdir(projectDir, { withFileTypes: true });
97
119
  }
98
120
  catch {
121
+ projectDirReadFailedCount += 1;
99
122
  continue;
100
123
  }
101
124
  for (const sessionEntry of sessionEntries) {
@@ -109,10 +132,14 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
109
132
  mainStat = await fs.stat(mainFile);
110
133
  }
111
134
  catch {
135
+ sessionStatFailedCount += 1;
112
136
  continue;
113
137
  }
114
138
  const sessionUuid = sessionEntry.name.replace(/\.jsonl$/i, "");
115
- const { sidecars, sidecarsCapped } = await discoverSidecars(path.join(projectDir, sessionUuid, "subagents"));
139
+ const sidecarDiscovery = await discoverSidecars(path.join(projectDir, sessionUuid, "subagents"));
140
+ const { sidecars, sidecarsCapped } = sidecarDiscovery;
141
+ sidecarDirReadFailedCount += sidecarDiscovery.dirReadFailedCount;
142
+ sidecarStatFailedCount += sidecarDiscovery.statFailedCount;
116
143
  const recencyMs = Math.max(mainStat.mtimeMs, ...sidecars.map((sidecar) => sidecar.mtimeMs));
117
144
  // D10: a session is recent if its main file OR any sidecar is in window.
118
145
  if (recencyMs < cutoffMs)
@@ -127,7 +154,14 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
127
154
  });
128
155
  }
129
156
  }
130
- return { sessions, projectDirsSkipped };
157
+ return {
158
+ sessions,
159
+ projectDirsSkipped,
160
+ projectDirReadFailedCount,
161
+ sessionStatFailedCount,
162
+ sidecarDirReadFailedCount,
163
+ sidecarStatFailedCount,
164
+ };
131
165
  }
132
166
  async function discoverSidecars(subagentsDir) {
133
167
  let entries;
@@ -135,9 +169,15 @@ async function discoverSidecars(subagentsDir) {
135
169
  entries = await fs.readdir(subagentsDir, { withFileTypes: true });
136
170
  }
137
171
  catch {
138
- return { sidecars: [], sidecarsCapped: 0 };
172
+ return {
173
+ sidecars: [],
174
+ sidecarsCapped: 0,
175
+ dirReadFailedCount: 1,
176
+ statFailedCount: 0,
177
+ };
139
178
  }
140
179
  const discovered = [];
180
+ let statFailedCount = 0;
141
181
  for (const entry of entries) {
142
182
  // Only agent transcripts. `*.meta.json` carry operator-authored
143
183
  // descriptions and are never harvested (D3).
@@ -152,6 +192,7 @@ async function discoverSidecars(subagentsDir) {
152
192
  stat = await fs.stat(local_path);
153
193
  }
154
194
  catch {
195
+ statFailedCount += 1;
155
196
  continue;
156
197
  }
157
198
  discovered.push({
@@ -166,6 +207,8 @@ async function discoverSidecars(subagentsDir) {
166
207
  return {
167
208
  sidecars: discovered.slice(0, CLAUDE_SESSION_MAX_SIDECAR_FILES),
168
209
  sidecarsCapped: capped,
210
+ dirReadFailedCount: 0,
211
+ statFailedCount,
169
212
  };
170
213
  }
171
214
  async function attributeOneSession(session, worktrees) {
@@ -23,14 +23,23 @@ export async function scanAndAttributeCodexSessions(options) {
23
23
  const sinceMinutes = options.sinceMinutes ?? CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES;
24
24
  const limit = options.limit ?? CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT;
25
25
  const cutoffMs = options.now.getTime() - sinceMinutes * 60 * 1000;
26
- const files = (await walkCodexJsonlFiles(options.sessionsDir, cutoffMs)).slice(0, limit);
26
+ const discovery = await discoverCodexJsonlFiles(options.sessionsDir, cutoffMs);
27
+ const files = discovery.files.slice(0, limit);
27
28
  const results = [];
28
29
  for (const file of files) {
29
30
  results.push(await attributeOneSession(file, options.worktrees));
30
31
  }
31
32
  return {
32
33
  results,
34
+ discovered_file_count: discovery.files.length,
33
35
  scanned_file_count: files.length,
36
+ since_minutes: sinceMinutes,
37
+ session_limit: limit,
38
+ session_limit_applied: discovery.files.length > limit,
39
+ max_file_bytes: CODEX_SESSION_MAX_FILE_BYTES,
40
+ directory_read_failed_count: discovery.directoryReadFailedCount,
41
+ stat_failed_count: discovery.statFailedCount,
42
+ secret_path_skipped_count: discovery.secretPathSkippedCount,
34
43
  counts: {
35
44
  attributed: results.filter((entry) => entry.state === "attributed").length,
36
45
  ambiguous: results.filter((entry) => entry.state === "ambiguous").length,
@@ -41,25 +50,40 @@ export async function scanAndAttributeCodexSessions(options) {
41
50
  };
42
51
  }
43
52
  export async function walkCodexJsonlFiles(dir, cutoffMs) {
53
+ return (await discoverCodexJsonlFiles(dir, cutoffMs)).files;
54
+ }
55
+ async function discoverCodexJsonlFiles(dir, cutoffMs) {
44
56
  const out = [];
57
+ let directoryReadFailedCount = 0;
58
+ let statFailedCount = 0;
59
+ let secretPathSkippedCount = 0;
45
60
  const stack = [dir];
46
61
  while (stack.length > 0) {
47
62
  const current = stack.pop();
48
- if (!current || isSecretLikePath(current))
63
+ if (!current)
64
+ continue;
65
+ if (isSecretLikePath(current)) {
66
+ secretPathSkippedCount += 1;
49
67
  continue;
68
+ }
50
69
  let entries;
51
70
  try {
52
71
  entries = await fs.readdir(current, { withFileTypes: true });
53
72
  }
54
73
  catch {
74
+ directoryReadFailedCount += 1;
55
75
  continue;
56
76
  }
57
77
  for (const entry of entries) {
58
78
  const full = path.join(current, entry.name);
59
79
  if (entry.isDirectory()) {
60
80
  // Never descend into secret-like directories.
61
- if (!isSecretLikePath(full))
81
+ if (isSecretLikePath(full)) {
82
+ secretPathSkippedCount += 1;
83
+ }
84
+ else {
62
85
  stack.push(full);
86
+ }
63
87
  continue;
64
88
  }
65
89
  // Secret-like file NAMES stay in the list so attribution can record a
@@ -67,14 +91,26 @@ export async function walkCodexJsonlFiles(dir, cutoffMs) {
67
91
  // instead of silently dropping the session.
68
92
  if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
69
93
  continue;
70
- const stat = await fs.stat(full);
94
+ let stat;
95
+ try {
96
+ stat = await fs.stat(full);
97
+ }
98
+ catch {
99
+ statFailedCount += 1;
100
+ continue;
101
+ }
71
102
  if (stat.mtimeMs >= cutoffMs) {
72
103
  out.push({ file: full, mtimeMs: stat.mtimeMs, byteSize: stat.size });
73
104
  }
74
105
  }
75
106
  }
76
107
  out.sort((a, b) => b.mtimeMs - a.mtimeMs);
77
- return out;
108
+ return {
109
+ files: out,
110
+ directoryReadFailedCount,
111
+ statFailedCount,
112
+ secretPathSkippedCount,
113
+ };
78
114
  }
79
115
  async function attributeOneSession(file, worktrees) {
80
116
  const fileName = path.basename(file.file);
@@ -28,7 +28,9 @@ export async function runLocalSourceCollectors(options) {
28
28
  includeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
29
29
  includeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
30
30
  codexSessionFiles: options.rawEvidenceCodexSessionFiles,
31
+ codexAttributionScan: options.rawEvidenceCodexAttributionScan,
31
32
  claudeSessionFiles: options.rawEvidenceClaudeSessionFiles,
33
+ claudeAttributionScan: options.rawEvidenceClaudeAttributionScan,
32
34
  skipContentHashes: options.rawEvidenceSkipContentHashes,
33
35
  byteBudget: options.rawEvidenceByteBudget,
34
36
  objectBudget: options.rawEvidenceObjectBudget,
@@ -28,6 +28,7 @@ export async function collectRawEvidencePack(context, options) {
28
28
  const entries = [];
29
29
  const skipped = [];
30
30
  const truncated = [];
31
+ const failed = [];
31
32
  const reused = [];
32
33
  const sinceMinutes = options.sinceMinutes ?? DEFAULT_SINCE_MINUTES;
33
34
  const sessionLimit = options.sessionLimit ?? DEFAULT_SESSION_LIMIT;
@@ -40,6 +41,7 @@ export async function collectRawEvidencePack(context, options) {
40
41
  entries,
41
42
  skipped,
42
43
  truncated,
44
+ failed,
43
45
  reused,
44
46
  scanned: new Map(),
45
47
  caps: [
@@ -92,6 +94,10 @@ export async function collectRawEvidencePack(context, options) {
92
94
  try {
93
95
  await ensurePrivateDir(evidenceDir);
94
96
  await ensurePrivateDir(filesDir);
97
+ recordAttributionCompleteness(collection, {
98
+ codex: options.codexAttributionScan,
99
+ claude: options.claudeAttributionScan,
100
+ });
95
101
  if (options.includeCodexJsonl !== false) {
96
102
  await collectCodexJsonlFiles(collection, {
97
103
  codexSessionFiles: options.codexSessionFiles,
@@ -104,8 +110,8 @@ export async function collectRawEvidencePack(context, options) {
104
110
  await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
105
111
  }
106
112
  await collectGitDiffFiles(collection, options.repoRoot);
107
- const deferredByteBudgetCount = skipped.filter((entry) => entry.reason === "deferred_byte_budget").length;
108
- const deferredObjectBudgetCount = skipped.filter((entry) => entry.reason === "deferred_object_budget").length;
113
+ const deferredByteBudgetCount = skipped.filter((entry) => entry.reason === "deferred_byte_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
114
+ const deferredObjectBudgetCount = skipped.filter((entry) => entry.reason === "deferred_object_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
109
115
  if (entries.length === 0) {
110
116
  const evidenceCompleteness = makeEvidenceCompleteness(collection, {
111
117
  startedAt,
@@ -119,7 +125,7 @@ export async function collectRawEvidencePack(context, options) {
119
125
  storage_bucket: RAW_EVIDENCE_BUCKET,
120
126
  file_count: 0,
121
127
  byte_size: 0,
122
- skipped_count: skipped.length,
128
+ skipped_count: countEvidenceEntries(skipped),
123
129
  reused_count: reused.length,
124
130
  deferred_byte_budget_count: deferredByteBudgetCount,
125
131
  deferred_object_budget_count: deferredObjectBudgetCount,
@@ -173,7 +179,7 @@ export async function collectRawEvidencePack(context, options) {
173
179
  storage_bucket: RAW_EVIDENCE_BUCKET,
174
180
  file_count: entries.length,
175
181
  byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
176
- skipped_count: skipped.length,
182
+ skipped_count: countEvidenceEntries(skipped),
177
183
  reused_count: reused.length,
178
184
  deferred_byte_budget_count: deferredByteBudgetCount,
179
185
  deferred_object_budget_count: deferredObjectBudgetCount,
@@ -201,18 +207,46 @@ export async function collectRawEvidencePack(context, options) {
201
207
  }),
202
208
  };
203
209
  }
204
- catch (error) {
205
- const scan = SourceScanResultSchema.parse({
206
- adapter: makeSourceAdapterIdentity("collector_runtime", "raw-evidence-pack"),
207
- work_context_id: context.workContextId,
208
- status: "failed",
209
- started_at: startedAt,
210
- finished_at: context.now.toISOString(),
211
- diagnostic_labels: [
212
- `raw_evidence_failed:${error instanceof Error ? error.message : String(error)}`,
213
- ],
210
+ catch {
211
+ failed.push({
212
+ kind: "raw_evidence",
213
+ reason: "collection_failed",
214
+ });
215
+ const evidenceCompleteness = makeEvidenceCompleteness(collection, {
216
+ startedAt,
217
+ finishedAt: context.now.toISOString(),
218
+ sinceMinutes,
214
219
  });
215
- return { scan, facts: null };
220
+ const facts = {
221
+ pack_id: packId,
222
+ manifest_path: path.join(evidenceDir, "manifest.json"),
223
+ evidence_dir: evidenceDir,
224
+ storage_bucket: RAW_EVIDENCE_BUCKET,
225
+ file_count: 0,
226
+ byte_size: 0,
227
+ skipped_count: countEvidenceEntries(skipped),
228
+ reused_count: reused.length,
229
+ deferred_byte_budget_count: skipped
230
+ .filter((entry) => entry.reason === "deferred_byte_budget")
231
+ .reduce((sum, entry) => sum + evidenceEntryCount(entry), 0),
232
+ deferred_object_budget_count: skipped
233
+ .filter((entry) => entry.reason === "deferred_object_budget")
234
+ .reduce((sum, entry) => sum + evidenceEntryCount(entry), 0),
235
+ content_kinds: [],
236
+ evidence_completeness: evidenceCompleteness,
237
+ pointers: [],
238
+ upload_files: [],
239
+ reused,
240
+ };
241
+ return {
242
+ facts,
243
+ scan: makeRawEvidenceScan({
244
+ context,
245
+ startedAt,
246
+ status: "failed",
247
+ facts,
248
+ }),
249
+ };
216
250
  }
217
251
  }
218
252
  function makeRawEvidenceScan(options) {
@@ -246,10 +280,105 @@ function makeRawEvidenceScan(options) {
246
280
  `completeness:${options.facts.evidence_completeness.status}`,
247
281
  `truncated:${options.facts.evidence_completeness.totals.truncated_count}`,
248
282
  `deferred:${options.facts.evidence_completeness.totals.deferred_count}`,
283
+ `failed:${options.facts.evidence_completeness.totals.failed_count}`,
249
284
  ...options.facts.content_kinds.map((kind) => `kind:${kind}`),
250
285
  ],
251
286
  });
252
287
  }
288
+ function recordAttributionCompleteness(collection, scans) {
289
+ if (scans.codex)
290
+ recordCodexAttributionCompleteness(collection, scans.codex);
291
+ if (scans.claude)
292
+ recordClaudeAttributionCompleteness(collection, scans.claude);
293
+ }
294
+ function recordCodexAttributionCompleteness(collection, scan) {
295
+ recordScanned(collection, "codex_attribution", scan.scanned_file_count);
296
+ collection.caps.push({
297
+ source: "codex_attribution",
298
+ cap_type: "scan_window_minutes",
299
+ limit: scan.since_minutes,
300
+ observed: scan.since_minutes,
301
+ applied: false,
302
+ }, {
303
+ source: "codex_attribution",
304
+ cap_type: "session_limit",
305
+ limit: scan.session_limit,
306
+ observed: scan.discovered_file_count,
307
+ applied: scan.session_limit_applied,
308
+ }, {
309
+ source: "codex_attribution",
310
+ cap_type: "max_file_bytes",
311
+ limit: scan.max_file_bytes,
312
+ applied: scan.results.some((result) => result.reason === "file_too_large"),
313
+ });
314
+ recordSkipCount(collection, "codex_attribution", "session_limit_overflow", Math.max(0, scan.discovered_file_count - scan.scanned_file_count));
315
+ recordSkipCount(collection, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
316
+ recordSkipCount(collection, "codex_attribution", "file_stat_failed", scan.stat_failed_count);
317
+ recordSkipCount(collection, "codex_attribution", "secret_like_directory", scan.secret_path_skipped_count);
318
+ recordAttributionResultSkips(collection, "codex_attribution", scan.results);
319
+ }
320
+ function recordClaudeAttributionCompleteness(collection, scan) {
321
+ recordScanned(collection, "claude_attribution", scan.scanned_session_count);
322
+ if (scan.disabled_reason) {
323
+ recordSkipCount(collection, "claude_attribution", scan.disabled_reason, 1);
324
+ }
325
+ collection.caps.push({
326
+ source: "claude_attribution",
327
+ cap_type: "scan_window_minutes",
328
+ limit: scan.since_minutes,
329
+ observed: scan.since_minutes,
330
+ applied: false,
331
+ }, {
332
+ source: "claude_attribution",
333
+ cap_type: "session_limit",
334
+ limit: scan.session_limit,
335
+ observed: scan.discovered_session_count,
336
+ applied: scan.session_limit_applied,
337
+ }, {
338
+ source: "claude_attribution",
339
+ cap_type: "max_file_bytes",
340
+ limit: scan.max_file_bytes,
341
+ applied: scan.counts.mains_oversized > 0 ||
342
+ scan.results.some((result) => result.reason === "file_too_large"),
343
+ }, {
344
+ source: "claude_attribution",
345
+ cap_type: "max_sidecar_files",
346
+ limit: scan.max_sidecar_files,
347
+ observed: scan.max_sidecar_files + scan.counts.sidecars_capped,
348
+ applied: scan.counts.sidecars_capped > 0,
349
+ }, {
350
+ source: "claude_attribution",
351
+ cap_type: "max_line_buffer_bytes",
352
+ limit: scan.max_line_buffer_bytes,
353
+ applied: scan.counts.oversized_lines_skipped > 0,
354
+ });
355
+ recordSkipCount(collection, "claude_attribution", "session_limit_overflow", Math.max(0, scan.discovered_session_count - scan.scanned_session_count));
356
+ recordSkipCount(collection, "claude_attribution", "secret_like_project_dir", scan.project_dirs_skipped);
357
+ recordSkipCount(collection, "claude_attribution", "project_dir_read_failed", scan.project_dir_read_failed_count);
358
+ recordSkipCount(collection, "claude_attribution", "session_stat_failed", scan.session_stat_failed_count);
359
+ recordSkipCount(collection, "claude_attribution", "sidecar_dir_read_failed", scan.sidecar_dir_read_failed_count);
360
+ recordSkipCount(collection, "claude_attribution", "sidecar_stat_failed", scan.sidecar_stat_failed_count);
361
+ recordSkipCount(collection, "claude_attribution", "sidecar_limit_overflow", scan.counts.sidecars_capped);
362
+ recordTruncationCount(collection, "claude_attribution", "oversized_jsonl_line", scan.counts.oversized_lines_skipped, { max_bytes: scan.max_line_buffer_bytes });
363
+ recordAttributionResultSkips(collection, "claude_attribution", scan.results);
364
+ for (const result of scan.results) {
365
+ for (const sidecar of result.sidecar_files) {
366
+ if (!sidecar.skipped_reason)
367
+ continue;
368
+ recordSkipCount(collection, "claude_attribution", `sidecar_${sidecar.skipped_reason}`, 1);
369
+ }
370
+ }
371
+ }
372
+ function recordAttributionResultSkips(collection, source, results) {
373
+ for (const result of results) {
374
+ if (result.state === "attributed")
375
+ continue;
376
+ const reason = result.state === "skipped"
377
+ ? result.reason
378
+ : `attribution_${result.state}:${result.reason}`;
379
+ recordSkipCount(collection, source, reason, 1);
380
+ }
381
+ }
253
382
  async function collectCodexJsonlFiles(collection, options) {
254
383
  const resolvedCandidates = options.codexSessionFiles
255
384
  ? options.codexSessionFiles.map((file) => ({
@@ -542,7 +671,18 @@ async function collectGitDiffFiles(collection, repoRoot) {
542
671
  ];
543
672
  for (const target of diffTargets) {
544
673
  recordScanned(collection, "git_diff");
545
- const diff = await runGitDiff(target.args, repoRoot);
674
+ let diff;
675
+ try {
676
+ diff = await runGitDiff(target.args, repoRoot);
677
+ }
678
+ catch {
679
+ collection.skipped.push({
680
+ kind: "git_diff",
681
+ label: target.label,
682
+ reason: "git_diff_failed",
683
+ });
684
+ continue;
685
+ }
546
686
  if (diff.truncated) {
547
687
  markCapApplied(collection, "git_diff", diff.truncationCapType);
548
688
  collection.truncated.push({
@@ -686,6 +826,32 @@ function markCapApplied(collection, source, capType) {
686
826
  if (cap)
687
827
  cap.applied = true;
688
828
  }
829
+ function recordSkipCount(collection, source, reason, count) {
830
+ if (count <= 0)
831
+ return;
832
+ collection.skipped.push({
833
+ kind: source,
834
+ label: reason,
835
+ reason,
836
+ count,
837
+ });
838
+ }
839
+ function recordTruncationCount(collection, source, reason, count, details = {}) {
840
+ if (count <= 0)
841
+ return;
842
+ collection.truncated.push({
843
+ kind: source,
844
+ reason,
845
+ count,
846
+ ...details,
847
+ });
848
+ }
849
+ function evidenceEntryCount(entry) {
850
+ return entry.count ?? 1;
851
+ }
852
+ function countEvidenceEntries(entries, predicate = () => true) {
853
+ return entries.reduce((sum, entry) => sum + (predicate(entry) ? evidenceEntryCount(entry) : 0), 0);
854
+ }
689
855
  function makeEvidenceCompleteness(collection, options) {
690
856
  const sources = new Set(collection.scanned.keys());
691
857
  for (const entry of collection.entries)
@@ -696,16 +862,19 @@ function makeEvidenceCompleteness(collection, options) {
696
862
  sources.add(entry.kind);
697
863
  for (const entry of collection.truncated)
698
864
  sources.add(entry.kind);
865
+ for (const entry of collection.failed)
866
+ sources.add(entry.kind);
699
867
  const sourceCounts = [...sources].sort().map((source) => {
700
868
  const skipped = collection.skipped.filter((entry) => entry.kind === source);
701
869
  return {
702
870
  source,
703
871
  scanned_count: collection.scanned.get(source) ?? 0,
704
872
  included_count: collection.entries.filter((entry) => entry.kind === source).length,
705
- skipped_count: skipped.length,
706
- truncated_count: collection.truncated.filter((entry) => entry.kind === source).length,
707
- deferred_count: skipped.filter((entry) => entry.reason.startsWith("deferred_")).length,
873
+ skipped_count: countEvidenceEntries(skipped),
874
+ truncated_count: countEvidenceEntries(collection.truncated, (entry) => entry.kind === source),
875
+ deferred_count: countEvidenceEntries(skipped, (entry) => entry.reason.startsWith("deferred_")),
708
876
  reused_count: collection.reused.filter((entry) => entry.kind === source).length,
877
+ failed_count: countEvidenceEntries(collection.failed, (entry) => entry.kind === source),
709
878
  };
710
879
  });
711
880
  const totals = sourceCounts.reduce((sum, count) => ({
@@ -715,6 +884,7 @@ function makeEvidenceCompleteness(collection, options) {
715
884
  truncated_count: sum.truncated_count + count.truncated_count,
716
885
  deferred_count: sum.deferred_count + count.deferred_count,
717
886
  reused_count: sum.reused_count + count.reused_count,
887
+ failed_count: sum.failed_count + count.failed_count,
718
888
  }), {
719
889
  scanned_count: 0,
720
890
  included_count: 0,
@@ -722,19 +892,35 @@ function makeEvidenceCompleteness(collection, options) {
722
892
  truncated_count: 0,
723
893
  deferred_count: 0,
724
894
  reused_count: 0,
895
+ failed_count: 0,
725
896
  });
726
897
  const skipReasonCounts = new Map();
727
898
  for (const skipped of collection.skipped) {
728
899
  const key = `${skipped.kind}:${skipped.reason}`;
729
900
  const existing = skipReasonCounts.get(key);
730
901
  if (existing) {
731
- existing.count += 1;
902
+ existing.count += evidenceEntryCount(skipped);
732
903
  }
733
904
  else {
734
905
  skipReasonCounts.set(key, {
735
906
  source: skipped.kind,
736
907
  reason: skipped.reason,
737
- count: 1,
908
+ count: evidenceEntryCount(skipped),
909
+ });
910
+ }
911
+ }
912
+ const failureReasonCounts = new Map();
913
+ for (const failed of collection.failed) {
914
+ const key = `${failed.kind}:${failed.reason}`;
915
+ const existing = failureReasonCounts.get(key);
916
+ if (existing) {
917
+ existing.count += evidenceEntryCount(failed);
918
+ }
919
+ else {
920
+ failureReasonCounts.set(key, {
921
+ source: failed.kind,
922
+ reason: failed.reason,
923
+ count: evidenceEntryCount(failed),
738
924
  });
739
925
  }
740
926
  }
@@ -743,7 +929,7 @@ function makeEvidenceCompleteness(collection, options) {
743
929
  const key = `${truncated.kind}:${truncated.reason}`;
744
930
  const existing = truncationCounts.get(key);
745
931
  if (existing) {
746
- existing.count += 1;
932
+ existing.count += evidenceEntryCount(truncated);
747
933
  existing.observed_bytes = Math.max(existing.observed_bytes ?? 0, truncated.observed_bytes ?? 0);
748
934
  existing.included_bytes = Math.max(existing.included_bytes ?? 0, truncated.included_bytes ?? 0);
749
935
  }
@@ -751,7 +937,7 @@ function makeEvidenceCompleteness(collection, options) {
751
937
  truncationCounts.set(key, {
752
938
  source: truncated.kind,
753
939
  reason: truncated.reason,
754
- count: 1,
940
+ count: evidenceEntryCount(truncated),
755
941
  ...(truncated.max_bytes !== undefined
756
942
  ? { max_bytes: truncated.max_bytes }
757
943
  : {}),
@@ -767,12 +953,16 @@ function makeEvidenceCompleteness(collection, options) {
767
953
  const hasGaps = totals.skipped_count > 0 ||
768
954
  totals.truncated_count > 0 ||
769
955
  totals.deferred_count > 0 ||
956
+ totals.failed_count > 0 ||
770
957
  collection.caps.some((cap) => cap.applied);
771
- const status = totals.included_count + totals.reused_count === 0 && !hasGaps
772
- ? "empty"
773
- : hasGaps
774
- ? "partial"
775
- : "complete";
958
+ const status = totals.failed_count > 0 &&
959
+ totals.included_count + totals.reused_count === 0
960
+ ? "failed"
961
+ : totals.included_count + totals.reused_count === 0 && !hasGaps
962
+ ? "empty"
963
+ : hasGaps
964
+ ? "partial"
965
+ : "complete";
776
966
  return EvidenceCompletenessPayloadSchema.parse({
777
967
  schema_version: "evidence-completeness.v1",
778
968
  status,
@@ -786,10 +976,13 @@ function makeEvidenceCompleteness(collection, options) {
786
976
  totals,
787
977
  caps: collection.caps,
788
978
  skip_reasons: [...skipReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
979
+ failure_reasons: [...failureReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
789
980
  truncation_markers: [...truncationCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
790
- notes: hasGaps
791
- ? ["Evidence is incomplete; downstream analysis should lower confidence."]
792
- : [],
981
+ notes: totals.failed_count > 0
982
+ ? ["Evidence collection failed; downstream analysis should not infer confidence."]
983
+ : hasGaps
984
+ ? ["Evidence is incomplete; downstream analysis should lower confidence."]
985
+ : [],
793
986
  });
794
987
  }
795
988
  async function walkJsonlFiles(dir, cutoffMs) {
@@ -120,7 +120,9 @@ export async function runAttributedWorktreeSync(options) {
120
120
  local_path: result.file_path,
121
121
  codex_session_id: result.codex_session_id,
122
122
  })),
123
+ codexAttributionScan: codexAttribution,
123
124
  claudeSessionFiles,
125
+ claudeAttributionScan: claudeAttribution,
124
126
  rawEvidenceBudget,
125
127
  fetch: options.fetchImpl,
126
128
  };
@@ -480,8 +482,20 @@ function buildAgentSessionSummary(options) {
480
482
  function emptyClaudeScan() {
481
483
  return {
482
484
  results: [],
485
+ discovered_session_count: 0,
483
486
  scanned_session_count: 0,
487
+ since_minutes: 0,
488
+ session_limit: 0,
489
+ session_limit_applied: false,
490
+ max_file_bytes: 0,
491
+ max_sidecar_files: 0,
492
+ max_line_buffer_bytes: 0,
484
493
  project_dirs_skipped: 0,
494
+ project_dir_read_failed_count: 0,
495
+ session_stat_failed_count: 0,
496
+ sidecar_dir_read_failed_count: 0,
497
+ sidecar_stat_failed_count: 0,
498
+ disabled_reason: "claude_collection_disabled_by_config",
485
499
  counts: {
486
500
  attributed: 0,
487
501
  ambiguous: 0,
package/dist/upload.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AgentImageArtifactReportRequestSchema, TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
1
+ import { AgentImageArtifactReportRequestSchema, EvidenceCompletenessPayloadSchema, TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
2
2
  import path from "node:path";
3
3
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
4
4
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
@@ -62,7 +62,9 @@ export async function buildLocalAmbientEnvelope(options = {}) {
62
62
  rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
63
63
  rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
64
64
  rawEvidenceCodexSessionFiles: options.codexSessionFiles,
65
+ rawEvidenceCodexAttributionScan: options.codexAttributionScan,
65
66
  rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
67
+ rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
66
68
  rawEvidenceSkipContentHashes: skipContentHashes,
67
69
  rawEvidenceByteBudget: options.rawEvidenceByteBudget,
68
70
  rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
@@ -534,12 +536,29 @@ function pruneUndurablePointers(envelope, outcomes) {
534
536
  .filter((outcome) => outcome.upload_state === "upload_failed" &&
535
537
  !durablePointerIds.has(outcome.pointer.raw_evidence_pointer_id))
536
538
  .map((outcome) => outcome.pointer.raw_evidence_pointer_id));
539
+ const failedOutcomes = outcomes.filter((outcome) => failedPointerIds.has(outcome.pointer.raw_evidence_pointer_id));
537
540
  if (failedPointerIds.size === 0)
538
541
  return envelope;
539
542
  return {
540
543
  ...envelope,
541
544
  events: envelope.events.map((event) => ({
542
545
  ...event,
546
+ metrics: {
547
+ ...event.metrics,
548
+ evidence_failed_count: (event.metrics["evidence_failed_count"] ?? 0) +
549
+ failedOutcomes.length,
550
+ },
551
+ attributes: event.evidence_completeness
552
+ ? {
553
+ ...event.attributes,
554
+ evidence_completeness_schema_version: event.evidence_completeness.schema_version,
555
+ evidence_completeness_status: "partial",
556
+ evidence_incomplete: true,
557
+ }
558
+ : event.attributes,
559
+ evidence_completeness: event.evidence_completeness
560
+ ? markCompletenessUploadFailures(event.evidence_completeness, failedOutcomes)
561
+ : event.evidence_completeness,
543
562
  raw_evidence_pointers: event.raw_evidence_pointers.filter((pointer) => !failedPointerIds.has(pointer.raw_evidence_pointer_id)),
544
563
  redaction: {
545
564
  ...event.redaction,
@@ -548,6 +567,71 @@ function pruneUndurablePointers(envelope, outcomes) {
548
567
  })),
549
568
  };
550
569
  }
570
+ function markCompletenessUploadFailures(completeness, failedOutcomes) {
571
+ const failureCounts = new Map();
572
+ for (const outcome of failedOutcomes) {
573
+ const source = outcome.kind ?? "raw_evidence";
574
+ failureCounts.set(source, (failureCounts.get(source) ?? 0) + 1);
575
+ }
576
+ const totalFailures = [...failureCounts.values()].reduce((sum, count) => sum + count, 0);
577
+ const sourceCounts = [...completeness.source_counts];
578
+ for (const [source, count] of failureCounts) {
579
+ const existingIndex = sourceCounts.findIndex((entry) => entry.source === source);
580
+ if (existingIndex === -1) {
581
+ sourceCounts.push({
582
+ source,
583
+ scanned_count: 0,
584
+ included_count: 0,
585
+ skipped_count: 0,
586
+ truncated_count: 0,
587
+ deferred_count: 0,
588
+ reused_count: 0,
589
+ failed_count: count,
590
+ });
591
+ continue;
592
+ }
593
+ const existing = sourceCounts[existingIndex];
594
+ if (!existing)
595
+ continue;
596
+ sourceCounts[existingIndex] = {
597
+ ...existing,
598
+ failed_count: existing.failed_count + count,
599
+ };
600
+ }
601
+ const failureReasons = [...completeness.failure_reasons];
602
+ for (const [source, count] of failureCounts) {
603
+ const reason = "upload_failed";
604
+ const existingIndex = failureReasons.findIndex((entry) => entry.source === source && entry.reason === reason);
605
+ if (existingIndex === -1) {
606
+ failureReasons.push({ source, reason, count });
607
+ }
608
+ else {
609
+ const existing = failureReasons[existingIndex];
610
+ if (existing) {
611
+ failureReasons[existingIndex] = {
612
+ ...existing,
613
+ count: existing.count + count,
614
+ };
615
+ }
616
+ }
617
+ }
618
+ return EvidenceCompletenessPayloadSchema.parse({
619
+ ...completeness,
620
+ status: "partial",
621
+ source_counts: sourceCounts,
622
+ totals: {
623
+ ...completeness.totals,
624
+ failed_count: completeness.totals.failed_count + totalFailures,
625
+ },
626
+ failure_reasons: failureReasons.sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
627
+ notes: [
628
+ ...new Set([
629
+ ...completeness.notes,
630
+ "Some collected evidence did not become durable; downstream analysis should lower confidence.",
631
+ ]),
632
+ ],
633
+ });
634
+ }
551
635
  function makeUploadWorkContext(options) {
552
636
  const provenance = makeCollectorProvenance({
553
637
  context: options.activeContext,
@@ -624,6 +708,7 @@ function makeSourceScanCompletedEvent(options) {
624
708
  evidence_truncated_count: evidenceCompleteness?.totals.truncated_count ?? 0,
625
709
  evidence_deferred_count: evidenceCompleteness?.totals.deferred_count ?? 0,
626
710
  evidence_reused_count: evidenceCompleteness?.totals.reused_count ?? 0,
711
+ evidence_failed_count: evidenceCompleteness?.totals.failed_count ?? 0,
627
712
  },
628
713
  attributes: {
629
714
  repo_label: options.context.repo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.6"
29
+ "@bli-cockpit/telemetry-core": "0.1.7"
30
30
  }
31
31
  }