@bli-cockpit/cli 0.1.9 → 0.1.12

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.
@@ -22,7 +22,10 @@ function cockpitHelp() {
22
22
  "Usage:",
23
23
  localCommandHelp(),
24
24
  "",
25
+ "Install/update: `npm install -g @bli-cockpit/cli@latest`.",
25
26
  "Intern path: run `cockpit onboard` from the repo root; add `--ticket <id>` only when work already has a ticket.",
27
+ "Already onboarded: run `cockpit sync --repo \"$PWD\" --json`.",
28
+ "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
26
29
  "Manual collector path: `install`, `login`, `start [--ticket <id>]`, `sync`, `status`.",
27
30
  ].join("\n");
28
31
  }
@@ -0,0 +1,504 @@
1
+ // Session attribution + ambient sync engine for the local collector. Split out
2
+ // of commands/local.ts so the command runners read as a table of contents; this
3
+ // is the core dual-source (Codex + Claude) scan/attribute/upload/report pass.
4
+ //
5
+ // Behavior-preserving extraction: moved verbatim from local.ts, no logic change.
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { getCollectorRuntimePaths, startLocalWorkContext, readLocalCollectorConfig } from "../local-state.js";
9
+ import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
10
+ import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
11
+ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
12
+ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
13
+ import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
14
+ const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
15
+ const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
16
+ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
17
+ /**
18
+ * Shared dual-source sync orchestration for single-repo and parent-folder
19
+ * modes. Codex AND Claude Code sessions are scanned and attributed once across
20
+ * every discovered worktree; each worktree syncs with only its own attributed
21
+ * transcripts (codex + claude main + sidecars), and the
22
+ * ambiguous/unattributed/skipped remainder is reported with reason labels and a
23
+ * `source` discriminator instead of being duplicated into every repo or
24
+ * silently dropped. The session row's upload state maps ONLY from the main-file
25
+ * outcome (D3); sidecar outcomes aggregate into CLI counts.
26
+ */
27
+ export async function runAttributedWorktreeSync(options) {
28
+ const now = new Date();
29
+ const homeDir = options.homeDir ?? os.homedir();
30
+ const paths = getCollectorRuntimePaths(options.homeDir);
31
+ const claudeEnabled = await isClaudeCollectionEnabled(paths);
32
+ const codexAttribution = await scanAndAttributeCodexSessions({
33
+ sessionsDir: path.join(homeDir, ".codex", "sessions"),
34
+ worktrees: options.worktrees,
35
+ now,
36
+ });
37
+ // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
38
+ // days so the first sync captures retroactive history instead of only 24h.
39
+ const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
40
+ const firstRunBackfill = claudeEnabled && !claudeCursorExists;
41
+ const claudeAttribution = claudeEnabled
42
+ ? await scanAndAttributeClaudeSessions({
43
+ projectsDir: path.join(homeDir, ".claude", "projects"),
44
+ worktrees: options.worktrees,
45
+ now,
46
+ sinceMinutes: firstRunBackfill
47
+ ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
48
+ : undefined,
49
+ })
50
+ : emptyClaudeScan();
51
+ // Read the Claude cursor up front: damping decisions need prior upload state.
52
+ const claudeCursorBefore = claudeEnabled
53
+ ? await readRawEvidenceCursor(paths, {
54
+ filename: CLAUDE_CURSOR_FILENAME,
55
+ }).catch(() => emptyRawEvidenceCursorState())
56
+ : emptyRawEvidenceCursorState();
57
+ // sessionId -> prior durable pointer for damped sessions (drives the
58
+ // growth_damped count + the skip_main decision).
59
+ const dampedClaudePointers = new Map();
60
+ // sessionId -> prior durable pointer for EVERY already-durable Claude session
61
+ // (superset of damped). A session that was durable before but had no fresh
62
+ // upload this sync (damped, spooled, budget-deferred) reports reused_existing
63
+ // with this pointer instead of not_uploaded, so the store row never flips.
64
+ const claudePriorDurablePointers = new Map();
65
+ for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
66
+ if (entry.uploaded_object_key) {
67
+ claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
68
+ }
69
+ }
70
+ // One shared budget for the whole sync (D7b is per-sync): a parent-folder
71
+ // sync over many worktrees honors a single byte/object cap rather than N×.
72
+ const rawEvidenceBudget = {
73
+ remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
74
+ remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
75
+ };
76
+ const outcomes = [];
77
+ let ok = true;
78
+ for (const worktree of options.worktrees) {
79
+ let context = null;
80
+ if (options.startContexts) {
81
+ context = await startLocalWorkContext({
82
+ homeDir: options.homeDir,
83
+ repoRoot: worktree.repo_root,
84
+ branch: options.branch,
85
+ activeTicketId: options.activeTicketId,
86
+ operatorId: options.operatorId,
87
+ sessionId: options.sessionId,
88
+ });
89
+ }
90
+ const claudeSessionFiles = claudeAttribution.results
91
+ .filter((result) => result.state === "attributed" &&
92
+ result.worktree?.worktree_fingerprint ===
93
+ worktree.worktree_fingerprint)
94
+ .map((result) => {
95
+ const damped = !result.main_file_oversized &&
96
+ shouldDampClaudeMain(result, claudeCursorBefore, now);
97
+ if (damped) {
98
+ dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
99
+ ?.uploaded_object_key ?? null);
100
+ }
101
+ return {
102
+ local_path: result.file_path,
103
+ claude_session_id: result.claude_session_id,
104
+ main_file_oversized: result.main_file_oversized,
105
+ skip_main: damped,
106
+ sidecar_files: result.sidecar_files
107
+ .filter((sidecar) => !sidecar.skipped_reason)
108
+ .map((sidecar) => ({ local_path: sidecar.local_path })),
109
+ };
110
+ });
111
+ const syncOptions = {
112
+ homeDir: options.homeDir,
113
+ repoRoot: worktree.repo_root,
114
+ dashboardUrl: options.dashboardUrl,
115
+ codexSessionFiles: codexAttribution.results
116
+ .filter((result) => result.state === "attributed" &&
117
+ result.worktree?.worktree_fingerprint ===
118
+ worktree.worktree_fingerprint)
119
+ .map((result) => ({
120
+ local_path: result.file_path,
121
+ codex_session_id: result.codex_session_id,
122
+ })),
123
+ claudeSessionFiles,
124
+ rawEvidenceBudget,
125
+ fetch: options.fetchImpl,
126
+ };
127
+ let sync;
128
+ try {
129
+ sync = await syncLocalAmbientEnvelope(syncOptions);
130
+ }
131
+ catch (error) {
132
+ // A newly cloned repo has no work context yet. Capture is permissive
133
+ // and ticket binding comes later, so start general ambient capture for
134
+ // it instead of blocking every other repo's sync until someone runs
135
+ // `cockpit start` by hand.
136
+ if (error instanceof LocalUploadBlockedError &&
137
+ error.blocker === "missing_context") {
138
+ context = await startLocalWorkContext({
139
+ homeDir: options.homeDir,
140
+ repoRoot: worktree.repo_root,
141
+ branch: options.branch,
142
+ });
143
+ sync = await syncLocalAmbientEnvelope(syncOptions);
144
+ }
145
+ else {
146
+ throw error;
147
+ }
148
+ }
149
+ ok = ok && sync.status === "uploaded";
150
+ outcomes.push({ worktree, context, sync });
151
+ }
152
+ const sessions = buildAgentSessionReport({
153
+ codexResults: codexAttribution.results,
154
+ claudeResults: claudeAttribution.results,
155
+ outcomes,
156
+ now,
157
+ claudePriorDurablePointers,
158
+ });
159
+ // The sessions cursor is an optimization; a broken local state dir must not
160
+ // turn already-completed syncs into a CLI crash.
161
+ let codexStaleCount = 0;
162
+ let claudeStaleCount = 0;
163
+ try {
164
+ const codexCursor = await readRawEvidenceCursor(paths);
165
+ codexStaleCount = recordSourceObservations({
166
+ cursor: codexCursor,
167
+ results: codexAttribution.results,
168
+ sessions,
169
+ source: "codex",
170
+ sessionIdOf: (result) => result.codex_session_id,
171
+ now,
172
+ });
173
+ codexCursor.updated_at = now.toISOString();
174
+ await writeRawEvidenceCursor(paths, codexCursor);
175
+ }
176
+ catch {
177
+ // Best-effort: stale counts read 0 and observations re-record next sync.
178
+ }
179
+ if (claudeEnabled) {
180
+ try {
181
+ claudeStaleCount = recordSourceObservations({
182
+ cursor: claudeCursorBefore,
183
+ results: claudeAttribution.results,
184
+ sessions,
185
+ source: "claude_code",
186
+ sessionIdOf: (result) => result.claude_session_id,
187
+ now,
188
+ priorCursor: claudeCursorBefore,
189
+ });
190
+ claudeCursorBefore.updated_at = now.toISOString();
191
+ await writeRawEvidenceCursor(paths, claudeCursorBefore, {
192
+ filename: CLAUDE_CURSOR_FILENAME,
193
+ sessionsOnly: true,
194
+ });
195
+ }
196
+ catch {
197
+ // Best-effort: a broken Claude cursor must not fail the sync.
198
+ }
199
+ }
200
+ const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
201
+ const report = firstUploaded
202
+ ? await postCodexSessionReport({
203
+ homeDir: options.homeDir,
204
+ repoRoot: firstUploaded.worktree.repo_root,
205
+ dashboardUrl: options.dashboardUrl,
206
+ sessions,
207
+ fetch: options.fetchImpl,
208
+ now,
209
+ })
210
+ : {
211
+ posted: false,
212
+ reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
213
+ };
214
+ const summary = buildAgentSessionSummary({
215
+ codexAttribution,
216
+ claudeAttribution,
217
+ outcomes,
218
+ codexStaleCount,
219
+ claudeStaleCount,
220
+ firstRunBackfill,
221
+ growthDamped: dampedClaudePointers.size,
222
+ report,
223
+ });
224
+ return { ok, outcomes, codexAttribution, claudeAttribution, summary };
225
+ }
226
+ const ATTRIBUTION_STATE_RANK = {
227
+ attributed: 3,
228
+ ambiguous: 2,
229
+ unattributed: 1,
230
+ skipped: 0,
231
+ };
232
+ function normalizeCodexResult(result) {
233
+ return {
234
+ source: "codex",
235
+ session_id: result.codex_session_id,
236
+ state: result.state,
237
+ reason: result.reason,
238
+ signals: result.signals,
239
+ attribution_score: result.attribution_score,
240
+ path_score: result.path_score,
241
+ content_hash_sha256: result.content_hash_sha256,
242
+ byte_size: result.byte_size,
243
+ session_file_mtime: result.session_file_mtime,
244
+ session_file_mtime_ms: result.session_file_mtime_ms,
245
+ worktree: result.worktree,
246
+ cwd_basename: result.cwd_basename,
247
+ cwd_hash: result.cwd_hash,
248
+ };
249
+ }
250
+ function normalizeClaudeResult(result) {
251
+ return {
252
+ source: "claude_code",
253
+ session_id: result.claude_session_id,
254
+ state: result.state,
255
+ reason: result.reason,
256
+ signals: result.signals,
257
+ attribution_score: result.attribution_score,
258
+ path_score: result.path_score,
259
+ content_hash_sha256: result.content_hash_sha256,
260
+ byte_size: result.byte_size,
261
+ session_file_mtime: result.session_file_mtime,
262
+ session_file_mtime_ms: result.session_file_mtime_ms,
263
+ worktree: result.worktree,
264
+ cwd_basename: result.cwd_basename,
265
+ cwd_hash: result.cwd_hash,
266
+ };
267
+ }
268
+ /**
269
+ * Generalizes the per-session report across sources. Dedupe is per
270
+ * `(source, session_id)` so a Codex session and a Claude session that happen to
271
+ * share an id are never collapsed. Upload state maps ONLY from the main-file
272
+ * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
273
+ * session's upload state (D3). Damped Claude sessions report `reused_existing`
274
+ * carrying their prior durable pointer.
275
+ */
276
+ function buildAgentSessionReport(options) {
277
+ const normalized = [
278
+ ...options.codexResults.map(normalizeCodexResult),
279
+ ...options.claudeResults.map(normalizeClaudeResult),
280
+ ];
281
+ const bestByKey = new Map();
282
+ for (const result of normalized) {
283
+ const key = `${result.source}:${result.session_id}`;
284
+ const existing = bestByKey.get(key);
285
+ if (!existing ||
286
+ (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
287
+ (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
288
+ ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
289
+ (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
290
+ result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
291
+ bestByKey.set(key, result);
292
+ }
293
+ }
294
+ // Main-file outcomes only (D3): a sidecar making it must never mark a session
295
+ // uploaded when the main did not.
296
+ const uploadByKey = new Map();
297
+ for (const outcome of options.outcomes) {
298
+ if (outcome.sync.status !== "uploaded")
299
+ continue;
300
+ for (const upload of outcome.sync.raw_evidence_outcomes) {
301
+ if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
302
+ continue;
303
+ const source = upload.kind === "claude_jsonl"
304
+ ? "claude_code"
305
+ : upload.kind === "codex_jsonl"
306
+ ? "codex"
307
+ : null;
308
+ if (!source)
309
+ continue; // sidecars and other kinds do not set session state
310
+ uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
311
+ }
312
+ }
313
+ return [...bestByKey.values()].map((result) => {
314
+ const key = `${result.source}:${result.session_id}`;
315
+ const upload = uploadByKey.get(key);
316
+ // A previously-durable Claude session with no fresh main upload this sync
317
+ // (damped / spooled / budget-deferred) reports reused_existing + its prior
318
+ // pointer rather than not_uploaded, so the store row never flips.
319
+ const priorDurablePointer = result.source === "claude_code"
320
+ ? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
321
+ : null;
322
+ return {
323
+ codex_session_id: result.session_id,
324
+ source: result.source,
325
+ observed_at: options.now.toISOString(),
326
+ attribution_state: result.state,
327
+ attribution_reason: result.reason,
328
+ attribution_score: result.attribution_score,
329
+ path_score: result.path_score,
330
+ signals: result.signals,
331
+ ...(result.content_hash_sha256
332
+ ? { session_file_hash_sha256: result.content_hash_sha256 }
333
+ : {}),
334
+ session_file_byte_size: result.byte_size,
335
+ session_file_mtime: result.session_file_mtime,
336
+ ...(result.worktree
337
+ ? {
338
+ repo_fingerprint: result.worktree.repo_fingerprint,
339
+ worktree_fingerprint: result.worktree.worktree_fingerprint,
340
+ repo_label: result.worktree.repo_label,
341
+ branch: result.worktree.branch,
342
+ }
343
+ : {}),
344
+ ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
345
+ ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
346
+ ...(upload
347
+ ? {
348
+ raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
349
+ upload_state: upload.upload_state,
350
+ }
351
+ : priorDurablePointer
352
+ ? {
353
+ raw_evidence_pointer_id: priorDurablePointer,
354
+ upload_state: "reused_existing",
355
+ }
356
+ : result.state === "attributed"
357
+ ? { upload_state: "not_uploaded" }
358
+ : {}),
359
+ };
360
+ });
361
+ }
362
+ /**
363
+ * Records per-source session observations into its cursor and returns the stale
364
+ * count. Damped/reused Claude sessions carry forward their prior upload
365
+ * timestamp + byte size so the 6h damping window keeps counting from the real
366
+ * last upload (otherwise a slowly-growing file would never re-upload — D21).
367
+ */
368
+ function recordSourceObservations(options) {
369
+ const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
370
+ const stale = countStaleSessions(options.cursor, seen);
371
+ for (const result of options.results) {
372
+ const sessionId = options.sessionIdOf(result);
373
+ const reported = options.sessions.find((session) => session.source === options.source &&
374
+ session.codex_session_id === sessionId);
375
+ const uploadedThisSync = reported?.upload_state === "uploaded";
376
+ const durableThisSync = reported?.upload_state === "uploaded" ||
377
+ reported?.upload_state === "reused_existing";
378
+ const prior = options.priorCursor?.sessions[sessionId];
379
+ // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
380
+ // upload-failed for a session that was ALREADY durable must NOT wipe the
381
+ // prior durable state — otherwise damping is forfeited forever and the
382
+ // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
383
+ // durable pointer/timestamp/size forward unless we durably uploaded anew.
384
+ const uploadedObjectKey = durableThisSync
385
+ ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
386
+ : (prior?.uploaded_object_key ?? null);
387
+ const uploadedAt = uploadedThisSync
388
+ ? options.now.toISOString()
389
+ : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
390
+ const uploadedByteSize = uploadedThisSync
391
+ ? result.byte_size
392
+ : (prior?.uploaded_byte_size ??
393
+ (durableThisSync ? result.byte_size : null));
394
+ const entry = {
395
+ file_hash_sha256: result.content_hash_sha256,
396
+ file_mtime_ms: result.session_file_mtime_ms,
397
+ byte_size: result.byte_size,
398
+ // Durable byte offset reflects how many bytes are durable remotely (the
399
+ // last uploaded size), not the current file size.
400
+ byte_offset: uploadedByteSize ?? 0,
401
+ state: result.state,
402
+ reason: result.reason,
403
+ worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
404
+ uploaded_object_key: uploadedObjectKey,
405
+ uploaded_at: uploadedAt,
406
+ uploaded_byte_size: uploadedByteSize,
407
+ last_seen_at: options.now.toISOString(),
408
+ };
409
+ recordSessionObservation(options.cursor, sessionId, entry);
410
+ }
411
+ return stale;
412
+ }
413
+ function shouldDampClaudeMain(result, cursor, now) {
414
+ const entry = cursor.sessions[result.claude_session_id];
415
+ if (!entry ||
416
+ !entry.uploaded_object_key ||
417
+ !entry.uploaded_at ||
418
+ entry.uploaded_byte_size == null) {
419
+ return false;
420
+ }
421
+ const grew = result.byte_size > entry.uploaded_byte_size;
422
+ if (!grew)
423
+ return false; // unchanged content reuses via the object cursor
424
+ const growth = result.byte_size - entry.uploaded_byte_size;
425
+ const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
426
+ return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
427
+ Number.isFinite(ageMs) &&
428
+ ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
429
+ }
430
+ function buildAgentSessionSummary(options) {
431
+ const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
432
+ const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
433
+ const sidecarsCollected = attributedClaude.reduce((total, result) => total +
434
+ result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
435
+ const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
436
+ result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
437
+ const codex = {
438
+ scanned: options.codexAttribution.scanned_file_count,
439
+ attributed: options.codexAttribution.counts.attributed,
440
+ ambiguous: options.codexAttribution.counts.ambiguous,
441
+ unattributed: options.codexAttribution.counts.unattributed,
442
+ skipped: options.codexAttribution.counts.skipped,
443
+ stale: options.codexStaleCount,
444
+ };
445
+ const claude = {
446
+ scanned: options.claudeAttribution.scanned_session_count,
447
+ attributed: options.claudeAttribution.counts.attributed,
448
+ ambiguous: options.claudeAttribution.counts.ambiguous,
449
+ unattributed: options.claudeAttribution.counts.unattributed,
450
+ skipped: options.claudeAttribution.counts.skipped,
451
+ stale: options.claudeStaleCount,
452
+ sidecars_collected: sidecarsCollected,
453
+ sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
454
+ upload.upload_state === "reused_existing").length,
455
+ sidecars_skipped: sidecarsSkipped,
456
+ sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
457
+ sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
458
+ mains_oversized: options.claudeAttribution.counts.mains_oversized,
459
+ oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
460
+ project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
461
+ sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
462
+ growth_damped: options.growthDamped,
463
+ first_run_backfill: options.firstRunBackfill,
464
+ };
465
+ return {
466
+ scanned: codex.scanned,
467
+ attributed: codex.attributed,
468
+ ambiguous: codex.ambiguous,
469
+ unattributed: codex.unattributed,
470
+ skipped: codex.skipped,
471
+ stale: codex.stale,
472
+ report_posted: options.report.posted,
473
+ report_reason: options.report.reason,
474
+ codex,
475
+ claude,
476
+ files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
477
+ files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
478
+ };
479
+ }
480
+ function emptyClaudeScan() {
481
+ return {
482
+ results: [],
483
+ scanned_session_count: 0,
484
+ project_dirs_skipped: 0,
485
+ counts: {
486
+ attributed: 0,
487
+ ambiguous: 0,
488
+ unattributed: 0,
489
+ skipped: 0,
490
+ mains_oversized: 0,
491
+ oversized_lines_skipped: 0,
492
+ sessions_schema_drift: 0,
493
+ sidecars_capped: 0,
494
+ },
495
+ };
496
+ }
497
+ async function isClaudeCollectionEnabled(paths) {
498
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
499
+ return config?.collect_claude_jsonl !== false;
500
+ }
501
+ async function fileExists(filePath) {
502
+ const { stat } = await import("node:fs/promises");
503
+ return stat(filePath).then(() => true, () => false);
504
+ }
@@ -117,6 +117,7 @@ function duplicateOutcome(primary, duplicate) {
117
117
  pointer: duplicate.pointer,
118
118
  codex_session_id: duplicate.codex_session_id ?? null,
119
119
  kind: duplicate.kind ?? "unknown",
120
+ artifact_metadata: duplicate.artifact_metadata,
120
121
  upload_state: primary.upload_state === "uploaded"
121
122
  ? "reused_existing"
122
123
  : primary.upload_state,
@@ -132,6 +133,7 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
132
133
  object_key: objectKey,
133
134
  codex_session_id: entry.file.codex_session_id ?? null,
134
135
  kind: entry.file.kind ?? "unknown",
136
+ artifact_metadata: entry.file.artifact_metadata,
135
137
  upload_state: "reused_existing",
136
138
  reason: "already_committed",
137
139
  uploaded_chunk_count: 0,
@@ -185,6 +187,7 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
185
187
  object_key: objectKey,
186
188
  codex_session_id: entry.file.codex_session_id ?? null,
187
189
  kind: entry.file.kind ?? "unknown",
190
+ artifact_metadata: entry.file.artifact_metadata,
188
191
  upload_state: "reused_existing",
189
192
  reason: "already_committed",
190
193
  uploaded_chunk_count: uploadedChunks,
@@ -195,6 +198,7 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
195
198
  object_key: objectKey,
196
199
  codex_session_id: entry.file.codex_session_id ?? null,
197
200
  kind: entry.file.kind ?? "unknown",
201
+ artifact_metadata: entry.file.artifact_metadata,
198
202
  upload_state: "uploaded",
199
203
  reason: null,
200
204
  uploaded_chunk_count: uploadedChunks,
@@ -228,6 +232,7 @@ async function uploadWithLegacyFallback(options, loaded, outcomes) {
228
232
  object_key: entry.file.pointer.object_key ?? "",
229
233
  codex_session_id: entry.file.codex_session_id ?? null,
230
234
  kind: entry.file.kind ?? "unknown",
235
+ artifact_metadata: entry.file.artifact_metadata,
231
236
  upload_state: "uploaded",
232
237
  reason: "legacy_single_shot_upload",
233
238
  uploaded_chunk_count: 1,
@@ -241,6 +246,7 @@ async function uploadWithLegacyFallback(options, loaded, outcomes) {
241
246
  object_key: entry.file.pointer.object_key ?? "",
242
247
  codex_session_id: entry.file.codex_session_id ?? null,
243
248
  kind: entry.file.kind ?? "unknown",
249
+ artifact_metadata: entry.file.artifact_metadata,
244
250
  upload_state: "reused_existing",
245
251
  reason: "legacy_already_exists",
246
252
  uploaded_chunk_count: 0,
@@ -319,6 +325,7 @@ function failedOutcome(file, reason, uploadedChunks = 0) {
319
325
  object_key: file.pointer.object_key ?? "",
320
326
  codex_session_id: file.codex_session_id ?? null,
321
327
  kind: file.kind ?? "unknown",
328
+ artifact_metadata: file.artifact_metadata,
322
329
  upload_state: "upload_failed",
323
330
  reason,
324
331
  uploaded_chunk_count: uploadedChunks,
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
7
7
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
8
- export const LOCAL_COLLECTOR_VERSION = "0.1.9";
8
+ export const LOCAL_COLLECTOR_VERSION = "0.1.10";
9
9
  export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
10
10
  export function getCollectorRuntimePaths(homeDir = os.homedir()) {
11
11
  const paths = getUserLocalCockpitPaths(homeDir);