@bli-cockpit/cli 0.2.47 → 0.2.49

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 (42) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/heartbeat.js +18 -0
  18. package/dist/commands/install-receipts.js +34 -0
  19. package/dist/commands/jarvis.js +179 -3
  20. package/dist/commands/local-arg-values.js +169 -0
  21. package/dist/commands/local-args-collector.js +578 -0
  22. package/dist/commands/local-args-tower.js +870 -0
  23. package/dist/commands/local-args.js +8 -1549
  24. package/dist/commands/local-help.js +11 -3
  25. package/dist/commands/local.js +18 -1786
  26. package/dist/commands/login.js +53 -0
  27. package/dist/commands/logout.js +66 -0
  28. package/dist/commands/onboard-receipts.js +66 -0
  29. package/dist/commands/onboard-report.js +274 -0
  30. package/dist/commands/onboard.js +449 -0
  31. package/dist/commands/ops-render.js +36 -0
  32. package/dist/commands/public-root.js +1 -1
  33. package/dist/commands/serve.js +13 -0
  34. package/dist/commands/session-sync.js +513 -534
  35. package/dist/commands/settings-render.js +28 -0
  36. package/dist/commands/settings.js +66 -2
  37. package/dist/commands/start.js +47 -0
  38. package/dist/commands/sync-followups.js +203 -0
  39. package/dist/commands/sync.js +381 -0
  40. package/dist/dev-build.js +186 -0
  41. package/dist/tower-stream.js +20 -4
  42. package/package.json +2 -2
@@ -6,12 +6,12 @@ import path from "node:path";
6
6
  import { makeSourceAdapterIdentity, } from "./common.js";
7
7
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
8
8
  import { defaultCodexSessionDirs, } from "./codex-attribution.js";
9
- import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
9
+ import { recordClaudeAttributionCompleteness, recordCodexAttributionCompleteness, } from "./raw-evidence-attribution-gaps.js";
10
10
  import { describeError } from "../health-detail.js";
11
11
  import { contentKeyedRawEvidencePackId, DELIVERY_BACKOFF_BYPASS_REASON, DELIVERY_BACKOFF_HOLDING_REASON, deliveryBackoffApplies, evidenceSourceKey, heldSourceKeys, readRawEvidenceStagingState, recordStagedObject, resolveStagedObject, } from "../raw-evidence-staging.js";
12
12
  import { isSecretLikePath, safeKeySegment, sha256, shortHash, } from "./raw-evidence-keys.js";
13
- import { sanitizeTextEvidenceForUpload } from "./raw-evidence-sanitize.js";
14
- import { countEvidenceEntries, makeEvidenceCompleteness, markBudgetCapApplied, markCapApplied, recordScanned, recordSkipCount, recordTruncationCount, } from "./raw-evidence-completeness.js";
13
+ import { sanitizeTextEvidenceForUpload, } from "./raw-evidence-sanitize.js";
14
+ import { countEvidenceEntries, makeEvidenceCompleteness, markBudgetCapApplied, markCapApplied, recordScanned, } from "./raw-evidence-completeness.js";
15
15
  import { evidenceEntry, pointerFromEntry, RAW_EVIDENCE_BUCKET, } from "./raw-evidence-manifest.js";
16
16
  import { chmodPrivate, ensurePrivateDir, persistStagingState, promoteStagedPack, stageManifest, } from "./raw-evidence-pack-store.js";
17
17
  import { GIT_DIFF_TIMEOUT_MS, MAX_GIT_DIFF_BYTES, REPO_ROOT_MISSING_REASON, RepoRootMissingError, repoRootExists, runGitDiff, } from "./raw-evidence-git-diff.js";
@@ -30,59 +30,68 @@ const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
30
30
  // ---------------------------------------------------------------------------
31
31
  // The pass
32
32
  // ---------------------------------------------------------------------------
33
+ /**
34
+ * Decide which bytes on this laptop are allowed to become durable evidence for
35
+ * one repo, and put exactly those into one private, content-keyed pack.
36
+ *
37
+ * Never throws: a crash mid-pass still returns facts and a `failed` scan, so a
38
+ * broken sync is reported rather than silently absent.
39
+ */
33
40
  export async function collectRawEvidencePack(context, options) {
34
- const startedAt = context.now.toISOString();
35
41
  const rawEvidenceRoot = path.join(options.stateDir, "raw-evidence");
36
- // Staging first, promotion second (BLI-3066). The pack id cannot be known
37
- // until the content is, so bytes land in a private staging directory and the
38
- // directory is then renamed to its content-keyed name — or dropped, when an
39
- // identical pack is already there.
40
- const stagingDir = path.join(rawEvidenceRoot, `.staging-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
42
+ const stagingDir = newStagingDir(rawEvidenceRoot);
41
43
  const sinceMinutes = options.sinceMinutes ?? DEFAULT_SINCE_MINUTES;
42
44
  const collection = await openCollection(context, options, {
43
45
  rawEvidenceRoot,
44
46
  stagingDir,
45
47
  });
46
48
  const scanWindow = {
47
- startedAt,
49
+ startedAt: context.now.toISOString(),
48
50
  finishedAt: () => context.now.toISOString(),
49
51
  sinceMinutes,
50
52
  };
53
+ const run = { stagingDir, window: scanWindow };
51
54
  try {
52
55
  await ensurePrivateDir(stagingDir);
53
56
  await ensurePrivateDir(collection.filesDir);
54
57
  recordAttributionCompleteness(collection, options);
55
- if (options.includeCodexJsonl !== false) {
56
- await collectCodexJsonlFiles(collection, {
57
- codexSessionFiles: options.codexSessionFiles,
58
- sessionsDir: options.sessionsDir,
59
- sessionsDirs: options.sessionsDirs,
60
- sinceMinutes,
61
- limit: options.sessionLimit ?? DEFAULT_SESSION_LIMIT,
62
- });
63
- }
64
- if (options.includeClaudeJsonl !== false && options.claudeSessionFiles) {
65
- await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
66
- }
67
- await collectGitDiffFiles(collection, options.repoRoot);
68
- if (collection.entries.length === 0) {
69
- return await finishWithEmptyPack(collection, options, {
70
- stagingDir,
71
- window: scanWindow,
72
- });
73
- }
74
- return await finishWithPromotedPack(collection, options, {
75
- stagingDir,
76
- window: scanWindow,
77
- });
58
+ await collectEverySource(collection, options, sinceMinutes);
59
+ return collection.entries.length === 0
60
+ ? await finishWithEmptyPack(collection, options, run)
61
+ : await finishWithPromotedPack(collection, options, run);
78
62
  }
79
63
  catch (error) {
80
- return await finishWithFailedPack(collection, {
81
- stagingDir,
82
- window: scanWindow,
83
- error,
64
+ return await finishWithFailedPack(collection, { ...run, error });
65
+ }
66
+ }
67
+ /**
68
+ * Staging first, promotion second (BLI-3066). The pack id cannot be known until
69
+ * the content is, so bytes land in a private per-attempt directory, and that
70
+ * directory is then renamed to its content-keyed name — or dropped, when an
71
+ * identical pack is already there.
72
+ */
73
+ function newStagingDir(rawEvidenceRoot) {
74
+ return path.join(rawEvidenceRoot, `.staging-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
75
+ }
76
+ /**
77
+ * Every kind of evidence this repo can offer, in the order it is read. Codex and
78
+ * Claude transcripts are each switchable off by the caller; the git diffs are
79
+ * not, because they are half the evidence of what someone actually changed.
80
+ */
81
+ async function collectEverySource(collection, options, sinceMinutes) {
82
+ if (options.includeCodexJsonl !== false) {
83
+ await collectCodexJsonlFiles(collection, {
84
+ codexSessionFiles: options.codexSessionFiles,
85
+ sessionsDir: options.sessionsDir,
86
+ sessionsDirs: options.sessionsDirs,
87
+ sinceMinutes,
88
+ limit: options.sessionLimit ?? DEFAULT_SESSION_LIMIT,
84
89
  });
85
90
  }
91
+ if (options.includeClaudeJsonl !== false && options.claudeSessionFiles) {
92
+ await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
93
+ }
94
+ await collectGitDiffFiles(collection, options.repoRoot);
86
95
  }
87
96
  async function openCollection(context, options, places) {
88
97
  const byteBudget = options.byteBudget ?? RAW_EVIDENCE_DEFAULT_BYTE_BUDGET;
@@ -225,20 +234,64 @@ async function finishWithPromotedPack(collection, options, run) {
225
234
  workContextId: context.workContextId,
226
235
  entries,
227
236
  });
228
- const evidenceDir = promotion.evidenceDir;
229
- rebaseStagedEntriesOntoPack(collection, evidenceDir);
230
- const manifestPath = path.join(evidenceDir, "manifest.json");
237
+ rebaseStagedEntriesOntoPack(collection, promotion.evidenceDir);
238
+ const manifestPath = await addManifestToPack(collection, promotion, packId);
239
+ await persistStagingState(options.stateDir, collection.staging, context.now.toISOString());
240
+ logPackStaged(collection, promotion, packId);
241
+ const facts = makePromotedPackFacts(collection, {
242
+ packId,
243
+ manifestPath,
244
+ promotion,
245
+ window: run.window,
246
+ });
247
+ return {
248
+ facts,
249
+ scan: makeRawEvidenceScan({
250
+ context,
251
+ startedAt: run.window.startedAt,
252
+ // A pack of nothing but its own manifest carries no evidence, so it
253
+ // reports partial however cleanly it was written.
254
+ status: entries.length > 1 ? "ok" : "partial",
255
+ facts,
256
+ }),
257
+ };
258
+ }
259
+ /** A pack with files: the same shell as an empty one, plus what is in it. */
260
+ function makePromotedPackFacts(collection, place) {
261
+ const entries = collection.entries;
262
+ return {
263
+ ...packFactsShell(collection, {
264
+ packId: place.packId,
265
+ manifestPath: place.manifestPath,
266
+ evidenceDir: place.promotion.evidenceDir,
267
+ window: place.window,
268
+ }),
269
+ stage_state: place.promotion.state,
270
+ file_count: entries.length,
271
+ byte_size: totalByteSize(entries),
272
+ content_kinds: [...new Set(entries.map((entry) => entry.kind))],
273
+ pointers: entries.map(pointerFromEntry),
274
+ upload_files: entries.map(uploadFileFromEntry),
275
+ };
276
+ }
277
+ /**
278
+ * Write the manifest and make it an object of the pack like any other file, so
279
+ * the uploader has one list to walk. Returns where it landed.
280
+ */
281
+ async function addManifestToPack(collection, promotion, packId) {
282
+ const context = collection.context;
283
+ const manifestPath = path.join(promotion.evidenceDir, "manifest.json");
231
284
  const manifestBytes = await stageManifest({
232
285
  context,
233
286
  packId,
234
287
  manifestPath,
235
- entries,
288
+ entries: collection.entries,
236
289
  skipped: collection.skipped,
237
290
  redacted: collection.redacted,
238
291
  reused: collection.reused,
239
292
  reusePack: promotion.state === "reused",
240
293
  });
241
- entries.push(evidenceEntry({
294
+ collection.entries.push(evidenceEntry({
242
295
  context,
243
296
  kind: "manifest",
244
297
  packId,
@@ -261,42 +314,22 @@ async function finishWithPromotedPack(collection, options, run) {
261
314
  // now a nice-to-have rather than the only thing between us and a loop.
262
315
  contentAddress: `manifest/${sha256(manifestBytes).slice(0, 16)}.json`,
263
316
  }));
264
- await persistStagingState(options.stateDir, collection.staging, context.now.toISOString());
317
+ return manifestPath;
318
+ }
319
+ /** The success line. A sync that staged nothing new still has to say so. */
320
+ function logPackStaged(collection, promotion, packId) {
265
321
  console.error("[raw-evidence] pack staged", JSON.stringify({
266
322
  pack_id: packId,
267
323
  stage_state: promotion.state,
268
324
  reason: stageReasonLabel(promotion.state, promotion.priorPackCount),
269
325
  prior_pack_count: promotion.priorPackCount,
270
326
  refilled_file_count: promotion.refilledFileCount,
271
- file_count: entries.length,
272
- byte_size: totalByteSize(entries),
327
+ file_count: collection.entries.length,
328
+ byte_size: totalByteSize(collection.entries),
273
329
  staged_new: collection.stagedNewCount,
274
330
  staged_reused: collection.stagedReusedCount,
275
331
  delivery_held: collection.deliveryHeldCount,
276
332
  }));
277
- const facts = {
278
- ...packFactsShell(collection, {
279
- packId,
280
- manifestPath,
281
- evidenceDir,
282
- window: run.window,
283
- }),
284
- stage_state: promotion.state,
285
- file_count: entries.length,
286
- byte_size: totalByteSize(entries),
287
- content_kinds: [...new Set(entries.map((entry) => entry.kind))],
288
- pointers: entries.map(pointerFromEntry),
289
- upload_files: entries.map(uploadFileFromEntry),
290
- };
291
- return {
292
- facts,
293
- scan: makeRawEvidenceScan({
294
- context,
295
- startedAt: run.window.startedAt,
296
- status: entries.length > 1 ? "ok" : "partial",
297
- facts,
298
- }),
299
- };
300
333
  }
301
334
  /**
302
335
  * A crashed pass. The staged-object index is deliberately NOT written: this
@@ -484,108 +517,23 @@ function recordAttributionCompleteness(collection, options) {
484
517
  recordClaudeAttributionCompleteness(collection, options.claudeAttributionScan, new Set(options.claudeSessionFiles?.map((file) => file.local_path) ?? []));
485
518
  }
486
519
  }
487
- function recordCodexAttributionCompleteness(collection, scan, selectedPaths) {
488
- recordScanned(collection, "codex_attribution", scan.scanned_file_count);
489
- collection.caps.push({
490
- source: "codex_attribution",
491
- cap_type: "scan_window_minutes",
492
- limit: scan.since_minutes,
493
- observed: scan.since_minutes,
494
- applied: false,
495
- }, {
496
- source: "codex_attribution",
497
- cap_type: "session_limit",
498
- limit: scan.session_limit,
499
- observed: scan.discovered_file_count,
500
- applied: scan.session_limit_applied,
501
- });
502
- recordSkipCount(collection, "codex_attribution", "session_limit_overflow", Math.max(0, scan.discovered_file_count - scan.scanned_file_count));
503
- recordSkipCount(collection, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
504
- recordSkipCount(collection, "codex_attribution", "file_stat_failed", scan.stat_failed_count);
505
- recordSkipCount(collection, "codex_attribution", "secret_like_directory", scan.secret_path_skipped_count);
506
- recordAttributionResultSkips(collection, "codex_attribution", scan.results, selectedPaths);
507
- }
508
- function recordClaudeAttributionCompleteness(collection, scan, selectedPaths) {
509
- recordScanned(collection, "claude_attribution", scan.scanned_session_count);
510
- if (scan.disabled_reason) {
511
- recordSkipCount(collection, "claude_attribution", scan.disabled_reason, 1);
512
- }
513
- collection.caps.push({
514
- source: "claude_attribution",
515
- cap_type: "scan_window_minutes",
516
- limit: scan.since_minutes,
517
- observed: scan.since_minutes,
518
- applied: false,
519
- }, {
520
- source: "claude_attribution",
521
- cap_type: "session_limit",
522
- limit: scan.session_limit,
523
- observed: scan.discovered_session_count,
524
- applied: scan.session_limit_applied,
525
- }, {
526
- source: "claude_attribution",
527
- cap_type: "max_file_bytes",
528
- limit: scan.max_file_bytes,
529
- applied: scan.counts.mains_oversized > 0 ||
530
- scan.results.some((result) => result.reason === "file_too_large"),
531
- }, {
532
- source: "claude_attribution",
533
- cap_type: "max_sidecar_files",
534
- limit: scan.max_sidecar_files,
535
- observed: scan.max_sidecar_files + scan.counts.sidecars_capped,
536
- applied: scan.counts.sidecars_capped > 0,
537
- }, {
538
- source: "claude_attribution",
539
- cap_type: "max_line_buffer_bytes",
540
- limit: scan.max_line_buffer_bytes,
541
- applied: scan.counts.oversized_lines_skipped > 0,
542
- });
543
- recordSkipCount(collection, "claude_attribution", "session_limit_overflow", Math.max(0, scan.discovered_session_count - scan.scanned_session_count));
544
- recordSkipCount(collection, "claude_attribution", "secret_like_project_dir", scan.project_dirs_skipped);
545
- recordSkipCount(collection, "claude_attribution", "project_dir_read_failed", scan.project_dir_read_failed_count);
546
- recordSkipCount(collection, "claude_attribution", "session_stat_failed", scan.session_stat_failed_count);
547
- recordSkipCount(collection, "claude_attribution", "sidecar_dir_read_failed", scan.sidecar_dir_read_failed_count);
548
- recordSkipCount(collection, "claude_attribution", "sidecar_stat_failed", scan.sidecar_stat_failed_count);
549
- recordSkipCount(collection, "claude_attribution", "sidecar_limit_overflow", scan.counts.sidecars_capped);
550
- recordTruncationCount(collection, "claude_attribution", "oversized_jsonl_line", scan.counts.oversized_lines_skipped, { max_bytes: scan.max_line_buffer_bytes });
551
- recordAttributionResultSkips(collection, "claude_attribution", scan.results, selectedPaths);
552
- for (const result of scan.results) {
553
- for (const sidecar of result.sidecar_files) {
554
- if (!sidecar.skipped_reason)
555
- continue;
556
- recordSkipCount(collection, "claude_attribution", `sidecar_${sidecar.skipped_reason}`, 1);
557
- }
558
- }
559
- }
560
- function recordAttributionResultSkips(collection, source, results, selectedPaths) {
561
- for (const result of results) {
562
- if (isAttributionAccountedFor(result, selectedPaths))
563
- continue;
564
- const reason = result.state === "skipped"
565
- ? result.reason
566
- : `attribution_${result.state}:${result.reason}`;
567
- recordSkipCount(collection, source, reason, 1);
568
- }
569
- }
570
- /**
571
- * Not every unattributed result is a gap. A selected session is being collected
572
- * by this pass, and a live-sync-safe synthetic target is owned by another
573
- * workspace's pack — neither is missing evidence.
574
- */
575
- function isAttributionAccountedFor(result, selectedPaths) {
576
- if (result.state === "attributed")
577
- return true;
578
- if (selectedPaths.has(result.file_path))
579
- return true;
580
- return (result.worktree !== null &&
581
- isLiveRawEvidenceSyncAttribution(result.state, result.reason, true));
582
- }
583
520
  // ---------------------------------------------------------------------------
584
521
  // Codex transcripts
585
522
  // ---------------------------------------------------------------------------
586
523
  async function collectCodexJsonlFiles(collection, options) {
524
+ for (const candidate of await chooseCodexCandidates(collection, options)) {
525
+ recordScanned(collection, "codex_jsonl");
526
+ await collectOneCodexSession(collection, candidate);
527
+ }
528
+ }
529
+ /**
530
+ * Which Codex transcripts this pass will look at, and the record of how many it
531
+ * had to leave out. Attribution's own list is never trimmed — the session limit
532
+ * only guards the fallback walk, which can turn up every session on the machine.
533
+ */
534
+ async function chooseCodexCandidates(collection, options) {
587
535
  const attributed = Boolean(options.codexSessionFiles);
588
- const resolvedCandidates = options.codexSessionFiles
536
+ const candidates = options.codexSessionFiles
589
537
  ? options.codexSessionFiles.map((file) => ({
590
538
  filePath: file.local_path,
591
539
  codexSessionId: file.codex_session_id,
@@ -595,36 +543,34 @@ async function collectCodexJsonlFiles(collection, options) {
595
543
  source: "codex_jsonl",
596
544
  cap_type: "session_limit",
597
545
  limit: options.limit,
598
- observed: resolvedCandidates.length,
599
- applied: !attributed && resolvedCandidates.length > options.limit,
546
+ observed: candidates.length,
547
+ applied: !attributed && candidates.length > options.limit,
548
+ });
549
+ return attributed ? candidates : candidates.slice(0, options.limit);
550
+ }
551
+ /** The transcript, then the images it explicitly attached — never the reverse. */
552
+ async function collectOneCodexSession(collection, candidate) {
553
+ const codexSessionId = candidate.codexSessionId ?? shortHash(candidate.filePath);
554
+ const transcriptAccepted = await collectOneEvidenceFile(collection, {
555
+ filePath: candidate.filePath,
556
+ kind: "codex_jsonl",
557
+ sessionId: codexSessionId,
558
+ mediaType: "application/jsonl",
559
+ // Guard before read/toString: Codex files can exceed the buffered commit
560
+ // ceiling just like Claude mains.
561
+ maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
562
+ redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
563
+ contentAddress: (hash16) => `codex/${safeKeySegment(codexSessionId)}/${hash16}.jsonl`,
564
+ });
565
+ if (!transcriptAccepted)
566
+ return;
567
+ await collectAgentImagesFromTranscript(collection, {
568
+ filePath: candidate.filePath,
569
+ source: "codex",
570
+ sessionId: codexSessionId,
571
+ kind: "codex_image_attachment",
572
+ contentAddress: (hash16, extension) => `codex/${safeKeySegment(codexSessionId)}/images/${hash16}.${extension}`,
600
573
  });
601
- const candidates = attributed
602
- ? resolvedCandidates
603
- : resolvedCandidates.slice(0, options.limit);
604
- for (const candidate of candidates) {
605
- recordScanned(collection, "codex_jsonl");
606
- const codexSessionId = candidate.codexSessionId ?? shortHash(candidate.filePath);
607
- const transcriptAccepted = await collectOneEvidenceFile(collection, {
608
- filePath: candidate.filePath,
609
- kind: "codex_jsonl",
610
- sessionId: codexSessionId,
611
- mediaType: "application/jsonl",
612
- // Guard before read/toString: Codex files can exceed the buffered commit
613
- // ceiling just like Claude mains.
614
- maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
615
- redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
616
- contentAddress: (hash16) => `codex/${safeKeySegment(codexSessionId)}/${hash16}.jsonl`,
617
- });
618
- if (!transcriptAccepted)
619
- continue;
620
- await collectAgentImagesFromTranscript(collection, {
621
- filePath: candidate.filePath,
622
- source: "codex",
623
- sessionId: codexSessionId,
624
- kind: "codex_image_attachment",
625
- contentAddress: (hash16, extension) => `codex/${safeKeySegment(codexSessionId)}/images/${hash16}.${extension}`,
626
- });
627
- }
628
574
  }
629
575
  /** The unattributed fallback: every recent Codex transcript on this machine. */
630
576
  function walkRecentCodexJsonlFiles(collection, options) {
@@ -779,34 +725,43 @@ async function collectOneAgentImageFile(collection, options) {
779
725
  });
780
726
  return;
781
727
  }
728
+ await writeAgentImageToPack(collection, {
729
+ ...options,
730
+ contentHash,
731
+ metadata,
732
+ });
733
+ }
734
+ /** Put an accepted image's bytes on disk and add its manifest entry. */
735
+ async function writeAgentImageToPack(collection, image) {
736
+ const raw = image.image.bytes;
782
737
  collection.index.value += 1;
783
- const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${contentHash.slice(0, 16)}.${options.image.extension}`);
738
+ const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${image.contentHash.slice(0, 16)}.${image.image.extension}`);
784
739
  const imageSourceKey = evidenceSourceKey({
785
- kind: options.kind,
786
- sessionId: options.sessionId,
787
- label: options.image.label,
740
+ kind: image.kind,
741
+ sessionId: image.sessionId,
742
+ label: image.image.label,
788
743
  });
789
744
  const staged = await stageEvidenceBytes(collection, {
790
- contentHash,
745
+ contentHash: image.contentHash,
791
746
  bytes: raw,
792
747
  fileName: path.basename(relativePath),
793
- kind: options.kind,
748
+ kind: image.kind,
794
749
  sourceKey: imageSourceKey,
795
750
  });
796
751
  collection.entries.push(evidenceEntry({
797
752
  context: collection.context,
798
- kind: options.kind,
753
+ kind: image.kind,
799
754
  packId: collection.packId,
800
755
  localPath: staged.local_path,
801
756
  relativePath,
802
757
  stagedInPack: staged.staged_in_pack,
803
758
  sourceKey: imageSourceKey,
804
- mediaType: metadata.media_type,
759
+ mediaType: image.metadata.media_type,
805
760
  redactedSummary: "Raw image explicitly attached to an agent session, preserved in private durable storage.",
806
761
  bytes: raw,
807
- codexSessionId: options.sessionId,
808
- contentAddress: options.contentAddress(contentHash.slice(0, 16), options.image.extension),
809
- artifactMetadata: metadata,
762
+ codexSessionId: image.sessionId,
763
+ contentAddress: image.contentAddress(image.contentHash.slice(0, 16), image.image.extension),
764
+ artifactMetadata: image.metadata,
810
765
  }));
811
766
  }
812
767
  // ---------------------------------------------------------------------------
@@ -822,19 +777,11 @@ async function collectOneAgentImageFile(collection, options) {
822
777
  * collecting. Every false return has pushed a named skip first.
823
778
  */
824
779
  async function collectOneEvidenceFile(collection, options) {
825
- const fileName = path.basename(options.filePath);
826
- const secretLikeFileName = isSecretLikePath(fileName);
827
- const evidenceLabel = secretLikeFileName ? "[REDACTED_FILE_NAME]" : fileName;
828
- const packedFileName = secretLikeFileName ? "redacted-file.jsonl" : fileName;
829
- const sourceKey = evidenceSourceKey({
830
- kind: options.kind,
831
- sessionId: options.sessionId,
832
- sourcePath: options.filePath,
833
- });
780
+ const names = nameEvidenceFile(options);
834
781
  const skip = (reason) => {
835
782
  collection.skipped.push({
836
783
  kind: options.kind,
837
- label: evidenceLabel,
784
+ label: names.evidenceLabel,
838
785
  reason,
839
786
  });
840
787
  return false;
@@ -843,14 +790,10 @@ async function collectOneEvidenceFile(collection, options) {
843
790
  // has failed repeatedly costs nothing at all this cycle — no read, no hash,
844
791
  // no copy, no request — and the hold is a named, retryable gap so a held
845
792
  // session cannot make the sync look clean (BLI-3066).
846
- if (collection.heldSources.has(sourceKey)) {
793
+ if (collection.heldSources.has(names.sourceKey)) {
847
794
  collection.deliveryHeldCount += 1;
848
795
  skip(DELIVERY_BACKOFF_HOLDING_REASON);
849
- console.error("[raw-evidence] delivery backoff holding source", JSON.stringify({
850
- reason: DELIVERY_BACKOFF_HOLDING_REASON,
851
- kind: options.kind,
852
- source_key: sourceKey,
853
- }));
796
+ logDeliveryBackoffHold(options.kind, names.sourceKey);
854
797
  return false;
855
798
  }
856
799
  const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes);
@@ -860,74 +803,114 @@ async function collectOneEvidenceFile(collection, options) {
860
803
  markCapApplied(collection, options.kind, "max_file_bytes");
861
804
  return skip("file_too_large");
862
805
  }
863
- const raw = read.bytes;
806
+ const sanitized = maskSecretsInTranscript(collection, {
807
+ raw: read.bytes,
808
+ kind: options.kind,
809
+ evidenceLabel: names.evidenceLabel,
810
+ secretLikeFileName: names.secretLikeFileName,
811
+ });
812
+ const contentHash = sha256(sanitized.bytes);
813
+ if (collection.skipContentHashes.has(contentHash)) {
814
+ collection.reused.push({
815
+ kind: options.kind,
816
+ label: names.evidenceLabel,
817
+ content_hash_sha256: contentHash,
818
+ codex_session_id: options.sessionId,
819
+ });
820
+ return true;
821
+ }
822
+ const deferReason = admitToBudget(collection.budget, sanitized.bytes.byteLength);
823
+ if (deferReason) {
824
+ markBudgetCapApplied(collection, deferReason);
825
+ return skip(deferReason);
826
+ }
827
+ await stageOneTranscript(collection, {
828
+ ...options,
829
+ names,
830
+ sanitized,
831
+ contentHash,
832
+ });
833
+ return true;
834
+ }
835
+ function nameEvidenceFile(options) {
836
+ const fileName = path.basename(options.filePath);
837
+ const secretLikeFileName = isSecretLikePath(fileName);
838
+ return {
839
+ secretLikeFileName,
840
+ evidenceLabel: secretLikeFileName ? "[REDACTED_FILE_NAME]" : fileName,
841
+ packedFileName: secretLikeFileName ? "redacted-file.jsonl" : fileName,
842
+ sourceKey: evidenceSourceKey({
843
+ kind: options.kind,
844
+ sessionId: options.sessionId,
845
+ sourcePath: options.filePath,
846
+ }),
847
+ };
848
+ }
849
+ function logDeliveryBackoffHold(kind, sourceKey) {
850
+ console.error("[raw-evidence] delivery backoff holding source", JSON.stringify({
851
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
852
+ kind,
853
+ source_key: sourceKey,
854
+ }));
855
+ }
856
+ /**
857
+ * Mask secret-like values in a transcript and record that it happened, because
858
+ * masking is invisible in the uploaded bytes and an operator has to be able to
859
+ * see that this file went up altered.
860
+ */
861
+ function maskSecretsInTranscript(collection, options) {
864
862
  const sanitized = sanitizeTextEvidenceForUpload({
865
- text: raw.toString("utf8"),
866
- originalBytes: raw,
863
+ text: options.raw.toString("utf8"),
864
+ originalBytes: options.raw,
867
865
  redactedFields: [`${options.kind}.body`],
868
- secretLikeFileName,
866
+ secretLikeFileName: options.secretLikeFileName,
869
867
  });
870
868
  if (sanitized.status === "redacted") {
871
869
  collection.redacted.push({
872
870
  kind: options.kind,
873
- label: evidenceLabel,
871
+ label: options.evidenceLabel,
874
872
  redaction: sanitized.redaction,
875
873
  completenessLabel: sanitized.completenessLabel,
876
874
  });
877
875
  console.error("[raw-evidence] text evidence sanitized", JSON.stringify({
878
876
  kind: options.kind,
879
877
  mode: sanitized.completenessLabel,
880
- original_bytes: raw.byteLength,
878
+ original_bytes: options.raw.byteLength,
881
879
  uploaded_bytes: sanitized.bytes.byteLength,
882
880
  }));
883
881
  }
884
- const evidenceBytes = sanitized.bytes;
885
- // Both branches carry a record now (BLI-3277), so "was anything replaced?" is
886
- // the status, never the presence of `redaction`.
887
- const redaction = sanitized.redaction;
888
- const wasRedacted = sanitized.status === "redacted";
889
- const contentHash = sha256(evidenceBytes);
890
- if (collection.skipContentHashes.has(contentHash)) {
891
- collection.reused.push({
892
- kind: options.kind,
893
- label: evidenceLabel,
894
- content_hash_sha256: contentHash,
895
- codex_session_id: options.sessionId,
896
- });
897
- return true;
898
- }
899
- const deferReason = admitToBudget(collection.budget, evidenceBytes.byteLength);
900
- if (deferReason) {
901
- markBudgetCapApplied(collection, deferReason);
902
- return skip(deferReason);
903
- }
882
+ return sanitized;
883
+ }
884
+ /** Put an accepted transcript's bytes on disk and add its manifest entry. */
885
+ async function stageOneTranscript(collection, file) {
904
886
  collection.index.value += 1;
905
- const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${packedFileName}`);
887
+ const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(file.filePath)}-${file.names.packedFileName}`);
906
888
  const staged = await stageEvidenceBytes(collection, {
907
- contentHash,
908
- bytes: evidenceBytes,
889
+ contentHash: file.contentHash,
890
+ bytes: file.sanitized.bytes,
909
891
  fileName: path.basename(relativePath),
910
- kind: options.kind,
911
- sourceKey,
892
+ kind: file.kind,
893
+ sourceKey: file.names.sourceKey,
912
894
  });
913
895
  collection.entries.push(evidenceEntry({
914
896
  context: collection.context,
915
- kind: options.kind,
897
+ kind: file.kind,
916
898
  packId: collection.packId,
917
899
  localPath: staged.local_path,
918
900
  relativePath,
919
- mediaType: options.mediaType,
920
- redactedSummary: wasRedacted
921
- ? `${options.redactedSummary} Secret-like values were deterministically redacted before upload.`
922
- : options.redactedSummary,
923
- redaction,
924
- bytes: evidenceBytes,
925
- codexSessionId: options.sessionId,
926
- contentAddress: options.contentAddress(contentHash.slice(0, 16)),
901
+ mediaType: file.mediaType,
902
+ redactedSummary: file.sanitized.status === "redacted"
903
+ ? `${file.redactedSummary} Secret-like values were deterministically redacted before upload.`
904
+ : file.redactedSummary,
905
+ // Both branches carry a record now (BLI-3277), so "was anything
906
+ // replaced?" is the status, never the presence of `redaction`.
907
+ redaction: file.sanitized.redaction,
908
+ bytes: file.sanitized.bytes,
909
+ codexSessionId: file.sessionId,
910
+ contentAddress: file.contentAddress(file.contentHash.slice(0, 16)),
927
911
  stagedInPack: staged.staged_in_pack,
928
- sourceKey,
912
+ sourceKey: file.names.sourceKey,
929
913
  }));
930
- return true;
931
914
  }
932
915
  /**
933
916
  * Size is checked twice on purpose: once by `stat` so an oversized transcript
@@ -1024,70 +1007,17 @@ const GIT_DIFF_TARGETS = [
1024
1007
  { label: "staged", args: ["diff", "--cached", "--no-ext-diff", "--"] },
1025
1008
  ];
1026
1009
  async function collectGitDiffFiles(collection, repoRoot) {
1027
- // BLI-3551: asked once per root, before either target, so a pruned worktree
1028
- // costs ONE line instead of one per diff target per session per tick — and
1029
- // the line says the folder is gone rather than accusing git of failing.
1030
1010
  if (!(await repoRootExists(repoRoot))) {
1031
- console.error("[raw-evidence] git diff skipped, repository root is no longer on disk", JSON.stringify({
1032
- reason: REPO_ROOT_MISSING_REASON,
1033
- diff_targets_skipped: GIT_DIFF_TARGETS.length,
1034
- next_action: "nothing to do; the diff returns when the worktree is restored or the session ages out",
1035
- }));
1036
- for (const target of GIT_DIFF_TARGETS) {
1037
- recordScanned(collection, "git_diff");
1038
- collection.skipped.push({
1039
- kind: "git_diff",
1040
- label: target.label,
1041
- reason: REPO_ROOT_MISSING_REASON,
1042
- });
1043
- }
1011
+ skipEveryDiffTargetForMissingRoot(collection);
1044
1012
  return;
1045
1013
  }
1046
1014
  for (const target of GIT_DIFF_TARGETS) {
1047
1015
  recordScanned(collection, "git_diff");
1048
- let diff;
1049
- try {
1050
- diff = await runGitDiff(target.args, repoRoot);
1051
- }
1052
- catch (error) {
1053
- // The root was there a moment ago and is not now (or a second collector
1054
- // pruned it mid-tick). Same named outcome, still not a git failure.
1055
- if (error instanceof RepoRootMissingError) {
1056
- collection.skipped.push({
1057
- kind: "git_diff",
1058
- label: target.label,
1059
- reason: REPO_ROOT_MISSING_REASON,
1060
- });
1061
- continue;
1062
- }
1063
- // `git_diff_failed` is the skip label and stays. It covers git not being
1064
- // installed, the folder not being a repo, a locked index and a diff that
1065
- // exceeded the child-process buffer — and the diff is half the evidence
1066
- // for what someone actually changed, so losing it quietly matters.
1067
- console.error("[raw-evidence] git diff failed", JSON.stringify({
1068
- reason: "git_diff_failed",
1069
- diff_target: target.label,
1070
- ...describeError(error),
1071
- }));
1072
- collection.skipped.push({
1073
- kind: "git_diff",
1074
- label: target.label,
1075
- reason: "git_diff_failed",
1076
- });
1016
+ const diff = await runOneGitDiff(collection, target, repoRoot);
1017
+ if (!diff)
1077
1018
  continue;
1078
- }
1079
- if (diff.truncated) {
1080
- markCapApplied(collection, "git_diff", diff.truncationCapType);
1081
- collection.truncated.push({
1082
- kind: "git_diff",
1083
- reason: diff.truncationReason,
1084
- ...(diff.truncationCapType === "max_bytes_per_diff"
1085
- ? { max_bytes: MAX_GIT_DIFF_BYTES }
1086
- : {}),
1087
- observed_bytes: diff.observedBytes,
1088
- included_bytes: Buffer.byteLength(diff.stdout, "utf8"),
1089
- });
1090
- }
1019
+ if (diff.truncated)
1020
+ recordGitDiffTruncation(collection, diff);
1091
1021
  // An empty diff is not a gap: there was simply nothing to record. The
1092
1022
  // truncation marker above still stands even when zero bytes survived.
1093
1023
  if (!diff.stdout.trim())
@@ -1099,28 +1029,79 @@ async function collectGitDiffFiles(collection, repoRoot) {
1099
1029
  });
1100
1030
  }
1101
1031
  }
1102
- async function stageOneGitDiff(collection, target) {
1103
- const sanitized = sanitizeTextEvidenceForUpload({
1104
- text: target.diffText,
1105
- redactedFields: [`git_diff.${target.label}`],
1106
- });
1107
- if (sanitized.status === "redacted") {
1108
- collection.redacted.push({
1032
+ /**
1033
+ * BLI-3551: the root is asked about once per pass, before either target, so a
1034
+ * pruned worktree costs ONE line instead of one per diff target per session per
1035
+ * tick — and the line says the folder is gone rather than accusing git of
1036
+ * failing. Both targets still get their own named gap.
1037
+ */
1038
+ function skipEveryDiffTargetForMissingRoot(collection) {
1039
+ console.error("[raw-evidence] git diff skipped, repository root is no longer on disk", JSON.stringify({
1040
+ reason: REPO_ROOT_MISSING_REASON,
1041
+ diff_targets_skipped: GIT_DIFF_TARGETS.length,
1042
+ next_action: "nothing to do; the diff returns when the worktree is restored or the session ages out",
1043
+ }));
1044
+ for (const target of GIT_DIFF_TARGETS) {
1045
+ recordScanned(collection, "git_diff");
1046
+ collection.skipped.push({
1109
1047
  kind: "git_diff",
1110
1048
  label: target.label,
1111
- redaction: sanitized.redaction,
1112
- completenessLabel: sanitized.completenessLabel,
1049
+ reason: REPO_ROOT_MISSING_REASON,
1113
1050
  });
1114
- console.error("[raw-evidence] git diff sanitized", JSON.stringify({
1115
- mode: sanitized.completenessLabel,
1116
- original_bytes: Buffer.byteLength(target.diffText, "utf8"),
1117
- uploaded_bytes: sanitized.bytes.byteLength,
1051
+ }
1052
+ }
1053
+ /**
1054
+ * Run one diff, or record why it produced nothing. Null means the gap is
1055
+ * already named in the ledger, so the caller only has to move on.
1056
+ */
1057
+ async function runOneGitDiff(collection, target, repoRoot) {
1058
+ try {
1059
+ return await runGitDiff(target.args, repoRoot);
1060
+ }
1061
+ catch (error) {
1062
+ // The root was there a moment ago and is not now (or a second collector
1063
+ // pruned it mid-tick). Same named outcome, still not a git failure.
1064
+ if (error instanceof RepoRootMissingError) {
1065
+ collection.skipped.push({
1066
+ kind: "git_diff",
1067
+ label: target.label,
1068
+ reason: REPO_ROOT_MISSING_REASON,
1069
+ });
1070
+ return null;
1071
+ }
1072
+ // `git_diff_failed` is the skip label and stays. It covers git not being
1073
+ // installed, the folder not being a repo, a locked index and a diff that
1074
+ // exceeded the child-process buffer — and the diff is half the evidence
1075
+ // for what someone actually changed, so losing it quietly matters.
1076
+ console.error("[raw-evidence] git diff failed", JSON.stringify({
1077
+ reason: "git_diff_failed",
1078
+ diff_target: target.label,
1079
+ ...describeError(error),
1118
1080
  }));
1081
+ collection.skipped.push({
1082
+ kind: "git_diff",
1083
+ label: target.label,
1084
+ reason: "git_diff_failed",
1085
+ });
1086
+ return null;
1119
1087
  }
1120
- const raw = sanitized.bytes;
1121
- const redaction = sanitized.redaction;
1122
- const wasRedacted = sanitized.status === "redacted";
1123
- const contentHash = sha256(raw);
1088
+ }
1089
+ /** A diff that hit its size or time cap is partial evidence, and says so. */
1090
+ function recordGitDiffTruncation(collection, diff) {
1091
+ markCapApplied(collection, "git_diff", diff.truncationCapType);
1092
+ collection.truncated.push({
1093
+ kind: "git_diff",
1094
+ reason: diff.truncationReason,
1095
+ ...(diff.truncationCapType === "max_bytes_per_diff"
1096
+ ? { max_bytes: MAX_GIT_DIFF_BYTES }
1097
+ : {}),
1098
+ observed_bytes: diff.observedBytes,
1099
+ included_bytes: Buffer.byteLength(diff.stdout, "utf8"),
1100
+ });
1101
+ }
1102
+ async function stageOneGitDiff(collection, target) {
1103
+ const sanitized = maskSecretsInGitDiff(collection, target);
1104
+ const contentHash = sha256(sanitized.bytes);
1124
1105
  if (collection.skipContentHashes.has(contentHash)) {
1125
1106
  collection.reused.push({
1126
1107
  kind: "git_diff",
@@ -1130,7 +1111,7 @@ async function stageOneGitDiff(collection, target) {
1130
1111
  });
1131
1112
  return;
1132
1113
  }
1133
- const deferReason = admitToBudget(collection.budget, raw.byteLength);
1114
+ const deferReason = admitToBudget(collection.budget, sanitized.bytes.byteLength);
1134
1115
  if (deferReason) {
1135
1116
  markBudgetCapApplied(collection, deferReason);
1136
1117
  collection.skipped.push({
@@ -1140,15 +1121,45 @@ async function stageOneGitDiff(collection, target) {
1140
1121
  });
1141
1122
  return;
1142
1123
  }
1143
- const relativePath = path.join("files", `git-${target.label}.diff`);
1124
+ await writeGitDiffToPack(collection, { target, sanitized, contentHash });
1125
+ }
1126
+ /**
1127
+ * Mask secret-like values in a diff and record that it happened. A diff carries
1128
+ * whatever a person pasted into a config file, so this is the branch that most
1129
+ * often fires — and an operator has to see that the bytes went up altered.
1130
+ */
1131
+ function maskSecretsInGitDiff(collection, target) {
1132
+ const sanitized = sanitizeTextEvidenceForUpload({
1133
+ text: target.diffText,
1134
+ redactedFields: [`git_diff.${target.label}`],
1135
+ });
1136
+ if (sanitized.status === "redacted") {
1137
+ collection.redacted.push({
1138
+ kind: "git_diff",
1139
+ label: target.label,
1140
+ redaction: sanitized.redaction,
1141
+ completenessLabel: sanitized.completenessLabel,
1142
+ });
1143
+ console.error("[raw-evidence] git diff sanitized", JSON.stringify({
1144
+ mode: sanitized.completenessLabel,
1145
+ original_bytes: Buffer.byteLength(target.diffText, "utf8"),
1146
+ uploaded_bytes: sanitized.bytes.byteLength,
1147
+ }));
1148
+ }
1149
+ return sanitized;
1150
+ }
1151
+ /** Put an accepted diff's bytes on disk and add its manifest entry. */
1152
+ async function writeGitDiffToPack(collection, diff) {
1153
+ const label = diff.target.label;
1154
+ const relativePath = path.join("files", `git-${label}.diff`);
1144
1155
  const diffSourceKey = evidenceSourceKey({
1145
1156
  kind: "git_diff",
1146
1157
  sessionId: collection.context.workContextId,
1147
- label: target.label,
1158
+ label,
1148
1159
  });
1149
1160
  const staged = await stageEvidenceBytes(collection, {
1150
- contentHash,
1151
- bytes: raw,
1161
+ contentHash: diff.contentHash,
1162
+ bytes: diff.sanitized.bytes,
1152
1163
  fileName: path.basename(relativePath),
1153
1164
  kind: "git_diff",
1154
1165
  sourceKey: diffSourceKey,
@@ -1162,13 +1173,13 @@ async function stageOneGitDiff(collection, target) {
1162
1173
  stagedInPack: staged.staged_in_pack,
1163
1174
  sourceKey: diffSourceKey,
1164
1175
  mediaType: "text/x-diff",
1165
- redactedSummary: gitDiffSummary(target.label, {
1166
- redacted: wasRedacted,
1167
- truncated: target.truncated,
1176
+ redactedSummary: gitDiffSummary(label, {
1177
+ redacted: diff.sanitized.status === "redacted",
1178
+ truncated: diff.target.truncated,
1168
1179
  }),
1169
- redaction,
1170
- bytes: raw,
1171
- contentAddress: `git-diff/${target.label}-${contentHash.slice(0, 16)}.diff`,
1180
+ redaction: diff.sanitized.redaction,
1181
+ bytes: diff.sanitized.bytes,
1182
+ contentAddress: `git-diff/${label}-${diff.contentHash.slice(0, 16)}.diff`,
1172
1183
  }));
1173
1184
  }
1174
1185
  function gitDiffSummary(label, state) {