@bli-cockpit/cli 0.2.16 → 0.2.18

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.
@@ -94,9 +94,20 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
94
94
  collectionRoots,
95
95
  pathExists: options.pathExists,
96
96
  });
97
- if (transcriptFallback)
98
- return transcriptFallback;
99
- return skipped(terminalReasonForRecordedPaths(approvedRecordedPaths, options.pathExists));
97
+ if (transcriptFallback) {
98
+ return attachApprovedRootWorkspace({
99
+ outcome: transcriptFallback,
100
+ signals,
101
+ collectionRoots,
102
+ approvedRecordedPaths,
103
+ });
104
+ }
105
+ return attachApprovedRootWorkspace({
106
+ outcome: skipped(terminalReasonForRecordedPaths(approvedRecordedPaths, options.pathExists)),
107
+ signals,
108
+ collectionRoots,
109
+ approvedRecordedPaths,
110
+ });
100
111
  }
101
112
  const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
102
113
  ? "cwd_outside_scanned_worktrees"
@@ -129,20 +140,67 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
129
140
  signals,
130
141
  worktrees: scopedWorktrees,
131
142
  });
132
- if (fallback)
133
- return fallback;
143
+ if (fallback) {
144
+ return attachApprovedRootWorkspace({
145
+ outcome: fallback,
146
+ signals,
147
+ collectionRoots,
148
+ approvedRecordedPaths,
149
+ });
150
+ }
134
151
  }
152
+ return attachApprovedRootWorkspace({
153
+ outcome: {
154
+ state: "ambiguous",
155
+ reason: best.score - secondBestScore < ATTRIBUTION_MIN_MARGIN
156
+ ? "multiple_worktrees_close_scores"
157
+ : "signal_score_below_threshold",
158
+ signals: best.matched,
159
+ attribution_score: score,
160
+ path_score: pathScore,
161
+ worktree: null,
162
+ },
163
+ signals,
164
+ collectionRoots,
165
+ approvedRecordedPaths,
166
+ });
167
+ }
168
+ /**
169
+ * Gives an inside-boundary terminal attribution a durable upload context
170
+ * without pretending the session belongs to any discovered repository. The
171
+ * original state, reason, signals, and scores are deliberately untouched: the
172
+ * synthetic folder is transport identity, not stronger attribution.
173
+ */
174
+ function attachApprovedRootWorkspace(options) {
175
+ if (options.outcome.worktree || options.approvedRecordedPaths.length === 0) {
176
+ return options.outcome;
177
+ }
178
+ const approvedRoot = deepestApprovedRootForRecordedPaths(options.approvedRecordedPaths, options.collectionRoots);
179
+ if (!approvedRoot)
180
+ return options.outcome;
181
+ const repoLabel = basenameForAttributionPath(approvedRoot);
135
182
  return {
136
- state: "ambiguous",
137
- reason: best.score - secondBestScore < ATTRIBUTION_MIN_MARGIN
138
- ? "multiple_worktrees_close_scores"
139
- : "signal_score_below_threshold",
140
- signals: best.matched,
141
- attribution_score: score,
142
- path_score: pathScore,
143
- worktree: null,
183
+ ...options.outcome,
184
+ worktree: {
185
+ requested_path: approvedRoot,
186
+ repo_root: approvedRoot,
187
+ repo_label: repoLabel,
188
+ repo_fingerprint: repoFingerprintFromLocalRoot(approvedRoot),
189
+ repo_origin_url: null,
190
+ branch: options.signals.branches[0] ?? "unknown",
191
+ head_sha: options.signals.headShas[0] ?? null,
192
+ worktree_label: repoLabel,
193
+ worktree_fingerprint: stableWorktreeFingerprint(approvedRoot),
194
+ worktree_is_primary: true,
195
+ },
144
196
  };
145
197
  }
198
+ function deepestApprovedRootForRecordedPaths(recordedPaths, collectionRoots) {
199
+ const candidates = collectionRoots.filter((root) => recordedPaths.some((recordedPath) => isPathWithin(recordedPath, root)));
200
+ candidates.sort((a, b) => pathDepth(b) - pathDepth(a) ||
201
+ normalizedAttributionPathKey(a).localeCompare(normalizedAttributionPathKey(b)));
202
+ return candidates[0] ? resolveAttributionPath(candidates[0]) : null;
203
+ }
146
204
  function unattributed(reason) {
147
205
  return {
148
206
  state: "unattributed",
@@ -1,4 +1,4 @@
1
- import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, SECRET_FILE_SEGMENT_PATTERN, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
1
+ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import { createReadStream, existsSync } from "node:fs";
@@ -84,7 +84,7 @@ export async function scanAndAttributeClaudeSessions(options) {
84
84
  }
85
85
  async function discoverClaudeSessions(projectsDir, cutoffMs) {
86
86
  const sessions = [];
87
- let projectDirsSkipped = 0;
87
+ const projectDirsSkipped = 0;
88
88
  let projectDirReadFailedCount = 0;
89
89
  let sessionStatFailedCount = 0;
90
90
  let sidecarDirReadFailedCount = 0;
@@ -106,14 +106,6 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
106
106
  for (const projectEntry of projectEntries) {
107
107
  if (!projectEntry.isDirectory())
108
108
  continue;
109
- // A project slug encodes the full cwd, so a repo named e.g.
110
- // `credentials-service` poisons its slug. Never read inside such a dir, but
111
- // count the skip so a whole repo silently missing is visible (D12). The
112
- // slug itself is a local path encoding and never printed.
113
- if (isSecretLikePath(projectEntry.name)) {
114
- projectDirsSkipped += 1;
115
- continue;
116
- }
117
109
  const projectDir = path.join(projectsDir, projectEntry.name);
118
110
  let sessionEntries;
119
111
  try {
@@ -237,18 +229,14 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
237
229
  sidecars_capped: session.sidecarsCapped,
238
230
  sidecar_files: [],
239
231
  };
240
- if (isSecretLikePath(fileName)) {
241
- return skippedResult(base, "secret_like_file_name");
242
- }
243
232
  if (session.mainByteSize === 0) {
244
233
  return skippedResult(base, "empty_file");
245
234
  }
246
235
  let signals;
247
- let secretLike;
248
236
  const oversized = session.mainByteSize > CLAUDE_SESSION_MAX_FILE_BYTES;
249
237
  if (oversized) {
250
- // D7: stream the oversized main so signals + secret guard still run, but the
251
- // bytes themselves are never uploaded (collection skips it as file_too_large).
238
+ // D7: stream the oversized main so metadata signals remain available. The
239
+ // bytes themselves are not uploaded until the collection ceiling changes.
252
240
  let streamed;
253
241
  try {
254
242
  streamed = await streamMainSignals(session.mainFile);
@@ -259,7 +247,6 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
259
247
  return skippedResult(base, "file_read_failed");
260
248
  }
261
249
  signals = streamed.signals;
262
- secretLike = streamed.secretLike;
263
250
  base.content_hash_sha256 = streamed.contentHash;
264
251
  base.byte_size = streamed.byteSize;
265
252
  base.main_file_oversized = true;
@@ -280,10 +267,6 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
280
267
  return skippedResult(base, "empty_file");
281
268
  }
282
269
  signals = extractClaudeSessionSignals(content);
283
- secretLike = containsSecretLikeContent(content);
284
- }
285
- if (secretLike) {
286
- return skippedResult(base, "secret_like_content_guard");
287
270
  }
288
271
  const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
289
272
  if (metaSessionId) {
@@ -317,7 +300,7 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
317
300
  extraSignals.push("main_file_oversized");
318
301
  if (isSchemaDriftSuspected(signals))
319
302
  extraSignals.push("schema_drift_suspected");
320
- const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state) && outcome.worktree
303
+ const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
321
304
  ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree)
322
305
  : session.sidecars.map((sidecar) => ({
323
306
  local_path: sidecar.local_path,
@@ -355,10 +338,6 @@ async function collectSidecarDiagnostics(sidecars, worktree) {
355
338
  content_hash_sha256: null,
356
339
  skipped_reason: null,
357
340
  };
358
- if (isSecretLikePath(sidecar.file_name)) {
359
- out.push({ ...entry, skipped_reason: "secret_like_file_name" });
360
- continue;
361
- }
362
341
  if (sidecar.byteSize > CLAUDE_SESSION_MAX_FILE_BYTES) {
363
342
  out.push({ ...entry, skipped_reason: "file_too_large" });
364
343
  continue;
@@ -372,10 +351,6 @@ async function collectSidecarDiagnostics(sidecars, worktree) {
372
351
  continue;
373
352
  }
374
353
  const content = raw.toString("utf8");
375
- if (containsSecretLikeContent(content)) {
376
- out.push({ ...entry, skipped_reason: "secret_like_content_guard" });
377
- continue;
378
- }
379
354
  const sidecarCwds = extractClaudeSessionSignals(content).cwds;
380
355
  if (sidecarCwds.length > 0 &&
381
356
  !sidecarCwds.some((cwd) => isPathWithin(cwd, worktree.repo_root))) {
@@ -483,25 +458,21 @@ function isSchemaDriftSuspected(signals) {
483
458
  async function streamMainSignals(filePath) {
484
459
  const accumulator = createSignalAccumulator();
485
460
  const hash = crypto.createHash("sha256");
486
- let secretLike = false;
487
461
  let oversizedLinesSkipped = 0;
488
462
  let byteSize = 0;
489
463
  let pending = "";
490
464
  let pendingTruncated = false;
491
465
  const flushCompletedLine = (lineText) => {
492
466
  if (pendingTruncated) {
493
- // The line exceeded the buffer; guard the head we retained, count it, drop.
467
+ // The line exceeded the metadata buffer; count it and continue. The raw
468
+ // evidence collector performs full-file sanitization before upload.
494
469
  oversizedLinesSkipped += 1;
495
- if (containsSecretLikeContent(pending))
496
- secretLike = true;
497
470
  pending = "";
498
471
  pendingTruncated = false;
499
472
  return;
500
473
  }
501
474
  const full = pending + lineText;
502
475
  pending = "";
503
- if (containsSecretLikeContent(full))
504
- secretLike = true;
505
476
  accumulator.processLine(full);
506
477
  };
507
478
  // StringDecoder buffers an incomplete multibyte char across chunk boundaries
@@ -547,7 +518,6 @@ async function streamMainSignals(filePath) {
547
518
  });
548
519
  return {
549
520
  signals: accumulator.finalize(),
550
- secretLike,
551
521
  oversizedLinesSkipped,
552
522
  contentHash: hash.digest("hex"),
553
523
  byteSize,
@@ -578,9 +548,6 @@ function skippedResult(base, reason) {
578
548
  function stringOrNull(value) {
579
549
  return typeof value === "string" && value.trim() ? value.trim() : null;
580
550
  }
581
- function isSecretLikePath(value) {
582
- return SECRET_FILE_SEGMENT_PATTERN.test(value);
583
- }
584
551
  function sha256(value) {
585
552
  return crypto.createHash("sha256").update(value).digest("hex");
586
553
  }
@@ -1,4 +1,3 @@
1
- import { SECRET_FILE_SEGMENT_PATTERN, } from "@bli-cockpit/telemetry-core";
2
1
  import { createReadStream, existsSync } from "node:fs";
3
2
  import crypto from "node:crypto";
4
3
  import fs from "node:fs/promises";
@@ -72,17 +71,13 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
72
71
  const out = [];
73
72
  let directoryReadFailedCount = 0;
74
73
  let statFailedCount = 0;
75
- let secretPathSkippedCount = 0;
74
+ const secretPathSkippedCount = 0;
76
75
  const stack = Array.isArray(dir) ? [...dir] : [dir];
77
76
  const seenFiles = new Set();
78
77
  while (stack.length > 0) {
79
78
  const current = stack.pop();
80
79
  if (!current)
81
80
  continue;
82
- if (isSecretLikePath(current)) {
83
- secretPathSkippedCount += 1;
84
- continue;
85
- }
86
81
  let entries;
87
82
  try {
88
83
  entries = await fs.readdir(current, { withFileTypes: true });
@@ -95,18 +90,9 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
95
90
  for (const entry of entries) {
96
91
  const full = path.join(current, entry.name);
97
92
  if (entry.isDirectory()) {
98
- // Never descend into secret-like directories.
99
- if (isSecretLikePath(full)) {
100
- secretPathSkippedCount += 1;
101
- }
102
- else {
103
- stack.push(full);
104
- }
93
+ stack.push(full);
105
94
  continue;
106
95
  }
107
- // Secret-like file NAMES stay in the list so attribution can record a
108
- // "skipped" observation with a reason label (the content is never read)
109
- // instead of silently dropping the session.
110
96
  if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
111
97
  continue;
112
98
  let stat;
@@ -168,9 +154,6 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
168
154
  byte_size: file.byteSize,
169
155
  content_hash_sha256: null,
170
156
  };
171
- if (isSecretLikePath(fileName)) {
172
- return skippedResult(base, "secret_like_file_name");
173
- }
174
157
  let read;
175
158
  try {
176
159
  read = await readCodexMetadataSignals(file.file);
@@ -352,7 +335,4 @@ function skippedResult(base, reason) {
352
335
  function addString(target, value) {
353
336
  if (typeof value === "string" && value.trim())
354
337
  target.add(value.trim());
355
- }
356
- function isSecretLikePath(value) {
357
- return SECRET_FILE_SEGMENT_PATTERN.test(value);
358
338
  }
@@ -383,13 +383,12 @@ function recordClaudeAttributionCompleteness(collection, scan, selectedPaths) {
383
383
  }
384
384
  function recordAttributionResultSkips(collection, source, results, selectedPaths) {
385
385
  for (const result of results) {
386
- // A selected fallback is being collected by this pass. A live-sync-safe
387
- // fallback is also not a skip when another worktree pack owns its upload.
386
+ // A selected session is being collected by this pass. A live-sync-safe
387
+ // synthetic target is also not a skip when another workspace pack owns it.
388
388
  if (result.state === "attributed" ||
389
- (result.state === "attributed_fallback" &&
390
- (selectedPaths.has(result.file_path) ||
391
- (result.worktree !== null &&
392
- isLiveRawEvidenceSyncAttribution(result.state, result.reason))))) {
389
+ selectedPaths.has(result.file_path) ||
390
+ (result.worktree !== null &&
391
+ isLiveRawEvidenceSyncAttribution(result.state, result.reason, true))) {
393
392
  continue;
394
393
  }
395
394
  const reason = result.state === "skipped"
@@ -497,6 +496,9 @@ async function collectClaudeJsonlFiles(collection, sessions) {
497
496
  }
498
497
  for (const sidecar of session.sidecar_files) {
499
498
  const stem = path.basename(sidecar.local_path).replace(/\.jsonl$/i, "");
499
+ const safeSidecarId = isSecretLikePath(stem)
500
+ ? "redacted-file-name"
501
+ : safeKeySegment(stem);
500
502
  const sidecarAccepted = await collectOneEvidenceFile(collection, {
501
503
  filePath: sidecar.local_path,
502
504
  kind: "claude_jsonl_sidecar",
@@ -504,16 +506,16 @@ async function collectClaudeJsonlFiles(collection, sessions) {
504
506
  mediaType: "application/jsonl",
505
507
  maxFileBytes: CLAUDE_MAX_COLLECT_FILE_BYTES,
506
508
  redactedSummary: "Raw Claude Code subagent transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
507
- contentAddress: (hash16) => `claude/${safeKeySegment(sessionId)}/subagents/${safeKeySegment(stem)}-${hash16}.jsonl`,
509
+ contentAddress: (hash16) => `claude/${safeKeySegment(sessionId)}/subagents/${safeSidecarId}-${hash16}.jsonl`,
508
510
  });
509
511
  if (sidecarAccepted) {
510
512
  await collectAgentImagesFromTranscript(collection, {
511
513
  filePath: sidecar.local_path,
512
514
  source: "claude_code",
513
515
  sessionId,
514
- sidecarId: stem,
516
+ sidecarId: safeSidecarId,
515
517
  kind: "claude_image_attachment",
516
- contentAddress: (hash16, extension) => `claude/${safeKeySegment(sessionId)}/subagents/${safeKeySegment(stem)}/images/${hash16}.${extension}`,
518
+ contentAddress: (hash16, extension) => `claude/${safeKeySegment(sessionId)}/subagents/${safeSidecarId}/images/${hash16}.${extension}`,
517
519
  });
518
520
  }
519
521
  }
@@ -598,14 +600,11 @@ async function collectOneAgentImageFile(collection, options) {
598
600
  */
599
601
  async function collectOneEvidenceFile(collection, options) {
600
602
  const fileName = path.basename(options.filePath);
601
- if (isSecretLikePath(fileName)) {
602
- collection.skipped.push({
603
- kind: options.kind,
604
- label: fileName,
605
- reason: "secret_like_file_name",
606
- });
607
- return false;
608
- }
603
+ const secretLikeFileName = isSecretLikePath(fileName);
604
+ const evidenceLabel = secretLikeFileName
605
+ ? "[REDACTED_FILE_NAME]"
606
+ : fileName;
607
+ const packedFileName = secretLikeFileName ? "redacted-file.jsonl" : fileName;
609
608
  if (options.maxFileBytes) {
610
609
  let stat;
611
610
  try {
@@ -614,7 +613,7 @@ async function collectOneEvidenceFile(collection, options) {
614
613
  catch {
615
614
  collection.skipped.push({
616
615
  kind: options.kind,
617
- label: fileName,
616
+ label: evidenceLabel,
618
617
  reason: "file_read_failed",
619
618
  });
620
619
  return false;
@@ -623,7 +622,7 @@ async function collectOneEvidenceFile(collection, options) {
623
622
  markCapApplied(collection, options.kind, "max_file_bytes");
624
623
  collection.skipped.push({
625
624
  kind: options.kind,
626
- label: fileName,
625
+ label: evidenceLabel,
627
626
  reason: "file_too_large",
628
627
  });
629
628
  return false;
@@ -636,7 +635,7 @@ async function collectOneEvidenceFile(collection, options) {
636
635
  catch {
637
636
  collection.skipped.push({
638
637
  kind: options.kind,
639
- label: fileName,
638
+ label: evidenceLabel,
640
639
  reason: "file_read_failed",
641
640
  });
642
641
  return false;
@@ -645,7 +644,7 @@ async function collectOneEvidenceFile(collection, options) {
645
644
  markCapApplied(collection, options.kind, "max_file_bytes");
646
645
  collection.skipped.push({
647
646
  kind: options.kind,
648
- label: fileName,
647
+ label: evidenceLabel,
649
648
  reason: "file_too_large",
650
649
  });
651
650
  return false;
@@ -654,21 +653,21 @@ async function collectOneEvidenceFile(collection, options) {
654
653
  text: raw.toString("utf8"),
655
654
  originalBytes: raw,
656
655
  redactedFields: [`${options.kind}.body`],
656
+ secretLikeFileName,
657
657
  });
658
- if (sanitized.status === "blocked") {
659
- collection.skipped.push({
660
- kind: options.kind,
661
- label: fileName,
662
- reason: sanitized.reason,
663
- });
664
- return false;
665
- }
666
658
  if (sanitized.status === "redacted") {
667
659
  collection.redacted.push({
668
660
  kind: options.kind,
669
- label: fileName,
661
+ label: evidenceLabel,
670
662
  redaction: sanitized.redaction,
663
+ completenessLabel: sanitized.completenessLabel,
671
664
  });
665
+ console.error("[raw-evidence] text evidence sanitized", JSON.stringify({
666
+ kind: options.kind,
667
+ mode: sanitized.completenessLabel,
668
+ original_bytes: raw.byteLength,
669
+ uploaded_bytes: sanitized.bytes.byteLength,
670
+ }));
672
671
  }
673
672
  const evidenceBytes = sanitized.bytes;
674
673
  const redaction = sanitized.redaction;
@@ -676,7 +675,7 @@ async function collectOneEvidenceFile(collection, options) {
676
675
  if (collection.skipContentHashes.has(contentHash)) {
677
676
  collection.reused.push({
678
677
  kind: options.kind,
679
- label: fileName,
678
+ label: evidenceLabel,
680
679
  content_hash_sha256: contentHash,
681
680
  codex_session_id: options.sessionId,
682
681
  });
@@ -687,13 +686,13 @@ async function collectOneEvidenceFile(collection, options) {
687
686
  markBudgetCapApplied(collection, deferReason);
688
687
  collection.skipped.push({
689
688
  kind: options.kind,
690
- label: fileName,
689
+ label: evidenceLabel,
691
690
  reason: deferReason,
692
691
  });
693
692
  return false;
694
693
  }
695
694
  collection.index.value += 1;
696
- const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${fileName}`);
695
+ const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${packedFileName}`);
697
696
  const destination = path.join(collection.filesDir, path.basename(relativePath));
698
697
  await fs.writeFile(destination, evidenceBytes, { mode: 0o600 });
699
698
  await chmodPrivate(destination, 0o600);
@@ -728,30 +727,138 @@ function admitToBudget(budget, byteLength) {
728
727
  budget.remainingBytes -= byteLength;
729
728
  return null;
730
729
  }
731
- function sanitizeTextEvidenceForUpload(options) {
730
+ export function sanitizeTextEvidenceForUpload(options) {
732
731
  const originalBytes = options.originalBytes ?? Buffer.from(options.text, "utf8");
733
- const redactionResult = redactSecretLikeContent(options.text, {
734
- appliedBy: "local_collector",
735
- redactedFields: options.redactedFields,
736
- });
737
- if (redactionResult.redacted) {
738
- if (containsSecretLikeContent(redactionResult.text)) {
739
- return { status: "blocked", reason: "secret_redaction_failed" };
732
+ const secretLikeContent = containsSecretLikeContent(options.text);
733
+ try {
734
+ const redactionResult = (options.redact ?? redactSecretLikeContent)(options.text, {
735
+ appliedBy: "local_collector",
736
+ redactedFields: options.redactedFields,
737
+ });
738
+ if (options.secretLikeFileName) {
739
+ const sanitizedText = redactionResult.redacted
740
+ ? redactionResult.text
741
+ : options.text;
742
+ const safeText = containsSecretLikeContent(sanitizedText)
743
+ ? "[REDACTED_LINE:secret_redaction_failed]\n"
744
+ : sanitizedText;
745
+ const sanitizedBytes = Buffer.from(safeText, "utf8");
746
+ return {
747
+ status: "redacted",
748
+ bytes: sanitizedBytes,
749
+ redaction: withRedactionContentMetadata(redactionResult.metadata ??
750
+ fallbackRedactionMetadata({
751
+ ruleId: "secret_like_file_name",
752
+ originalText: options.text,
753
+ redactedFields: options.redactedFields,
754
+ fullContentRedacted: false,
755
+ }), { originalBytes, sanitizedBytes }),
756
+ completenessLabel: "secret_like_name_masked",
757
+ };
740
758
  }
741
- const sanitizedBytes = Buffer.from(redactionResult.text, "utf8");
759
+ if (secretLikeContent) {
760
+ const redaction = redactionResult.metadata ??
761
+ fallbackRedactionMetadata({
762
+ ruleId: "secret_like_content_guard",
763
+ originalText: options.text,
764
+ redactedFields: options.redactedFields,
765
+ });
766
+ let maskedText = maskSecretBearingLines(options.text, redaction);
767
+ if (containsSecretLikeContent(maskedText)) {
768
+ maskedText = "[REDACTED_LINE:secret_redaction_failed]\n";
769
+ }
770
+ const sanitizedBytes = Buffer.from(maskedText, "utf8");
771
+ return {
772
+ status: "redacted",
773
+ bytes: sanitizedBytes,
774
+ redaction: withRedactionContentMetadata(redaction, {
775
+ originalBytes,
776
+ sanitizedBytes,
777
+ }),
778
+ completenessLabel: "secret_content_masked",
779
+ };
780
+ }
781
+ if (redactionResult.redacted) {
782
+ const sanitizedBytes = Buffer.from(redactionResult.text, "utf8");
783
+ return {
784
+ status: "redacted",
785
+ bytes: sanitizedBytes,
786
+ redaction: withRedactionContentMetadata(redactionResult.metadata ??
787
+ fallbackRedactionMetadata({
788
+ ruleId: "secret_redaction_failed",
789
+ originalText: options.text,
790
+ redactedFields: options.redactedFields,
791
+ }), { originalBytes, sanitizedBytes }),
792
+ completenessLabel: "secret_content_masked",
793
+ };
794
+ }
795
+ return { status: "clean", bytes: originalBytes };
796
+ }
797
+ catch {
798
+ const sanitizedBytes = Buffer.from("[REDACTED_LINE:redaction_crashed_stubbed]\n", "utf8");
742
799
  return {
743
800
  status: "redacted",
744
801
  bytes: sanitizedBytes,
745
- redaction: withRedactionContentMetadata(redactionResult.metadata, {
746
- originalBytes,
747
- sanitizedBytes,
748
- }),
802
+ redaction: withRedactionContentMetadata(fallbackRedactionMetadata({
803
+ ruleId: "redaction_crashed_stubbed",
804
+ originalText: options.text,
805
+ redactedFields: options.redactedFields,
806
+ }), { originalBytes, sanitizedBytes }),
807
+ completenessLabel: "redaction_crashed_stubbed",
749
808
  };
750
809
  }
751
- if (containsSecretLikeContent(options.text)) {
752
- return { status: "blocked", reason: "secret_like_content_guard" };
810
+ }
811
+ function maskSecretBearingLines(text, redaction) {
812
+ if (redaction.redacted_ranges.length === 0) {
813
+ return "[REDACTED_LINE:secret_like_content_guard]\n";
753
814
  }
754
- return { status: "clean", bytes: originalBytes };
815
+ const segments = text.match(/[^\n]*(?:\n|$)/gu)?.filter(Boolean) ?? [];
816
+ let offset = 0;
817
+ return segments
818
+ .map((segment) => {
819
+ const start = offset;
820
+ const end = offset + segment.length;
821
+ offset = end;
822
+ const matched = redaction.redacted_ranges.find((range) => range.start < end && start < range.end);
823
+ if (!matched)
824
+ return segment;
825
+ const lineEnding = segment.endsWith("\r\n")
826
+ ? "\r\n"
827
+ : segment.endsWith("\n")
828
+ ? "\n"
829
+ : "";
830
+ return `[REDACTED_LINE:${matched.rule_id}]${lineEnding}`;
831
+ })
832
+ .join("");
833
+ }
834
+ function fallbackRedactionMetadata(options) {
835
+ const fullContentRedacted = options.fullContentRedacted !== false;
836
+ return {
837
+ schema_version: "raw-evidence-redaction.v1",
838
+ status: "sanitized",
839
+ mode: "deterministic_text_replacement",
840
+ applied_by: ["local_collector"],
841
+ rule_counts: [
842
+ {
843
+ rule_id: options.ruleId,
844
+ match_count: 1,
845
+ redacted_char_count: fullContentRedacted
846
+ ? options.originalText.length
847
+ : 0,
848
+ },
849
+ ],
850
+ secret_like_match_count: 1,
851
+ redacted_fields: options.redactedFields,
852
+ redacted_ranges: fullContentRedacted && options.originalText.length > 0
853
+ ? [
854
+ {
855
+ start: 0,
856
+ end: options.originalText.length,
857
+ rule_id: options.ruleId,
858
+ },
859
+ ]
860
+ : [],
861
+ };
755
862
  }
756
863
  async function collectGitDiffFiles(collection, repoRoot) {
757
864
  const diffTargets = [
@@ -790,20 +897,18 @@ async function collectGitDiffFiles(collection, repoRoot) {
790
897
  text: diff.stdout,
791
898
  redactedFields: [`git_diff.${target.label}`],
792
899
  });
793
- if (sanitized.status === "blocked") {
794
- collection.skipped.push({
795
- kind: "git_diff",
796
- label: target.label,
797
- reason: sanitized.reason,
798
- });
799
- continue;
800
- }
801
900
  if (sanitized.status === "redacted") {
802
901
  collection.redacted.push({
803
902
  kind: "git_diff",
804
903
  label: target.label,
805
904
  redaction: sanitized.redaction,
905
+ completenessLabel: sanitized.completenessLabel,
806
906
  });
907
+ console.error("[raw-evidence] git diff sanitized", JSON.stringify({
908
+ mode: sanitized.completenessLabel,
909
+ original_bytes: Buffer.byteLength(diff.stdout, "utf8"),
910
+ uploaded_bytes: sanitized.bytes.byteLength,
911
+ }));
807
912
  }
808
913
  const raw = sanitized.bytes;
809
914
  const redaction = sanitized.redaction;
@@ -1059,7 +1164,7 @@ function makeEvidenceCompleteness(collection, options) {
1059
1164
  const redactionCounts = new Map();
1060
1165
  for (const redacted of collection.redacted) {
1061
1166
  const ruleIds = redacted.redaction.rule_counts.map((rule) => rule.rule_id);
1062
- const key = `${redacted.kind}:${redacted.redaction.mode}:${ruleIds.sort().join(",")}`;
1167
+ const key = `${redacted.kind}:${redacted.completenessLabel}:${ruleIds.sort().join(",")}`;
1063
1168
  const existing = redactionCounts.get(key);
1064
1169
  if (existing) {
1065
1170
  existing.count += 1;
@@ -1069,7 +1174,7 @@ function makeEvidenceCompleteness(collection, options) {
1069
1174
  redactionCounts.set(key, {
1070
1175
  source: redacted.kind,
1071
1176
  status: "sanitized",
1072
- mode: redacted.redaction.mode,
1177
+ mode: redacted.completenessLabel,
1073
1178
  count: 1,
1074
1179
  rule_ids: [...new Set(ruleIds)].sort(),
1075
1180
  });
@@ -1,4 +1,4 @@
1
- import { containsSecretLikeContent, NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
1
+ import { NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import os from "node:os";
@@ -8,6 +8,7 @@ import { defaultCodexSessionDirs, scanAndAttributeCodexSessions } from "../adapt
8
8
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
9
9
  import { acquireBackfillLock } from "../backfill-lock.js";
10
10
  import { BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, recordBackfillCursorObservations, recordBackfillScanCoverage, writeBackfillCompletionMarker, writeBackfillCursor, } from "../cursors/backfill-cursor.js";
11
+ import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
11
12
  import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, startLocalWorkContext } from "../local-state.js";
12
13
  import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
13
14
  import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
@@ -87,6 +88,7 @@ export async function runBackfill(command, io) {
87
88
  : await readBackfillCursor(paths);
88
89
  const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
89
90
  const cursor = scopedCursor.cursor;
91
+ const pointerlessTerminalSessions = await loadPointerlessTerminalSessions(paths, sources);
90
92
  const scan = await scanBackfillSessions({
91
93
  command,
92
94
  homeDir: command.homeDir ?? os.homedir(),
@@ -95,6 +97,7 @@ export async function runBackfill(command, io) {
95
97
  sources,
96
98
  window,
97
99
  cursor,
100
+ pointerlessTerminalSessions,
98
101
  now,
99
102
  });
100
103
  for (const reason of worktreeDiscovery.incomplete_reasons) {
@@ -107,13 +110,6 @@ export async function runBackfill(command, io) {
107
110
  scan.issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
108
111
  a.reason.localeCompare(b.reason));
109
112
  const guards = await countReadOnlyGuards(scan.candidates);
110
- for (const candidate of scan.candidates) {
111
- const permanentReason = guards.permanent_skip_reasons.get(candidateCursorKey(candidate));
112
- if (!permanentReason)
113
- continue;
114
- candidate.state = "skipped";
115
- candidate.reason = permanentReason;
116
- }
117
113
  scan.retryable_candidate_keys = new Set([
118
114
  ...scan.retryable_candidate_keys,
119
115
  ...guards.retryable_candidate_keys,
@@ -392,6 +388,14 @@ export async function runBackfill(command, io) {
392
388
  ? "session_report_ack_incomplete"
393
389
  : report.reason;
394
390
  }
391
+ if (reportAcknowledged && durableCandidateKeys.size > 0) {
392
+ await recordBackfillDurableSessionPointers({
393
+ paths,
394
+ candidates: scan.candidates,
395
+ syncResults,
396
+ now,
397
+ });
398
+ }
395
399
  // Raw evidence durability is necessary but not sufficient: the server must
396
400
  // also acknowledge the session attribution rows before their historical
397
401
  // cursor positions become irreversible.
@@ -672,8 +676,9 @@ async function scanBackfillSessions(options) {
672
676
  ...(codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
673
677
  ...(claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
674
678
  ]
675
- .filter((candidate) => isAfterCursor(candidate, options.cursor))
676
- .sort(compareBackfillCandidates);
679
+ .filter((candidate) => isAfterCursor(candidate, options.cursor) ||
680
+ isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
681
+ .sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
677
682
  const candidates = allCandidates.slice(0, options.command.maxFiles ?? Number.MAX_SAFE_INTEGER);
678
683
  const omittedCandidateCount = allCandidates.length - candidates.length;
679
684
  const issues = await backfillScanIssues({
@@ -693,6 +698,52 @@ async function scanBackfillSessions(options) {
693
698
  omitted_candidate_count: omittedCandidateCount,
694
699
  };
695
700
  }
701
+ async function loadPointerlessTerminalSessions(paths, sources) {
702
+ const [codexCursor, claudeCursor] = await Promise.all([
703
+ sources.includes("codex")
704
+ ? readRawEvidenceCursor(paths)
705
+ : Promise.resolve(null),
706
+ sources.includes("claude_code")
707
+ ? readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME })
708
+ : Promise.resolve(null),
709
+ ]);
710
+ const terminalIds = (sessions) => new Set(Object.entries(sessions ?? {})
711
+ .filter(([, entry]) => isPointerlessTerminalCursorEntry(entry))
712
+ .map(([sessionId]) => sessionId));
713
+ const result = {
714
+ codex: terminalIds(codexCursor?.sessions),
715
+ claude_code: terminalIds(claudeCursor?.sessions),
716
+ };
717
+ const count = result.codex.size + result.claude_code.size;
718
+ if (count > 0) {
719
+ console.error("[backfill] pointer-less terminal sessions reopened", JSON.stringify({
720
+ count,
721
+ codex_count: result.codex.size,
722
+ claude_count: result.claude_code.size,
723
+ }));
724
+ }
725
+ return result;
726
+ }
727
+ function isPointerlessTerminalCursorEntry(entry) {
728
+ return (!entry.uploaded_object_key &&
729
+ (entry.state === "ambiguous" ||
730
+ entry.state === "unattributed" ||
731
+ entry.state === "skipped"));
732
+ }
733
+ function isPointerlessTerminalRetry(candidate, sessions) {
734
+ return sessions[candidate.source].has(candidate.session_id);
735
+ }
736
+ function compareBackfillCandidatesForRetry(a, b, sessions) {
737
+ const aRetry = isPointerlessTerminalRetry(a, sessions);
738
+ const bRetry = isPointerlessTerminalRetry(b, sessions);
739
+ if (aRetry !== bRetry)
740
+ return aRetry ? -1 : 1;
741
+ if (aRetry && bRetry) {
742
+ return (a.session_file_mtime_ms - b.session_file_mtime_ms ||
743
+ candidateCursorKey(a).localeCompare(candidateCursorKey(b)));
744
+ }
745
+ return compareBackfillCandidates(a, b);
746
+ }
696
747
  function normalizeCodexCandidate(result) {
697
748
  return {
698
749
  source: "codex",
@@ -808,7 +859,7 @@ async function backfillScanIssues(options) {
808
859
  }
809
860
  add("backfill_max_files_applied", options.omittedCandidateCount, "selection");
810
861
  add("candidate_file_read_failed", options.candidates.filter((candidate) => candidate.reason === "file_read_failed").length, "candidate");
811
- add("candidate_worktree_unavailable", options.candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state) &&
862
+ add("candidate_worktree_unavailable", options.candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
812
863
  candidate.worktree === null).length, "candidate");
813
864
  add("claude_main_file_too_large", options.candidates.filter((candidate) => candidate.source === "claude_code" &&
814
865
  candidate.claude?.main_file_oversized).length, "candidate");
@@ -830,7 +881,7 @@ function retryableCandidateKeys(candidates) {
830
881
  return new Set(candidates
831
882
  .filter((candidate) => candidate.reason === "file_read_failed" ||
832
883
  candidate.reason === "repo_not_on_disk" ||
833
- (isRawEvidenceUploadableAttributionState(candidate.state) &&
884
+ (isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
834
885
  !candidate.worktree) ||
835
886
  Boolean(candidate.claude?.main_file_oversized) ||
836
887
  (candidate.claude?.sidecars_capped ?? 0) > 0 ||
@@ -872,7 +923,6 @@ function isMissingFsError(error) {
872
923
  async function countReadOnlyGuards(candidates) {
873
924
  const counts = new Map();
874
925
  const retryableCandidateKeys = new Set();
875
- const permanentSkipReasons = new Map();
876
926
  for (const candidate of candidates) {
877
927
  if (candidate.reason === "repo_not_on_disk") {
878
928
  increment(counts, "repo_not_on_disk");
@@ -888,14 +938,10 @@ async function countReadOnlyGuards(candidates) {
888
938
  retryableCandidateKeys.add(candidateCursorKey(candidate));
889
939
  continue;
890
940
  }
891
- if (!isRawEvidenceUploadableAttributionState(candidate.state))
941
+ if (!isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null))
892
942
  continue;
893
943
  try {
894
- const raw = await fs.readFile(candidate.file_path, "utf8");
895
- if (containsSecretLikeContent(raw)) {
896
- increment(counts, "secret_like_content_guard");
897
- permanentSkipReasons.set(candidateCursorKey(candidate), "secret_like_content_guard");
898
- }
944
+ await fs.readFile(candidate.file_path);
899
945
  }
900
946
  catch {
901
947
  increment(counts, "file_read_failed");
@@ -905,7 +951,6 @@ async function countReadOnlyGuards(candidates) {
905
951
  return {
906
952
  counts,
907
953
  retryable_candidate_keys: retryableCandidateKeys,
908
- permanent_skip_reasons: permanentSkipReasons,
909
954
  };
910
955
  }
911
956
  function reasonCountsFor(candidates, guardCounts, scanIssues) {
@@ -918,11 +963,7 @@ function reasonCountsFor(candidates, guardCounts, scanIssues) {
918
963
  for (const issue of scanIssues) {
919
964
  counts.set(issue.reason, Math.max(counts.get(issue.reason) ?? 0, issue.count));
920
965
  }
921
- for (const required of [
922
- "secret_like_content_guard",
923
- "file_too_large",
924
- "repo_not_on_disk",
925
- ]) {
966
+ for (const required of ["file_too_large", "repo_not_on_disk"]) {
926
967
  counts.set(required, counts.get(required) ?? 0);
927
968
  }
928
969
  return [...counts.entries()]
@@ -936,8 +977,8 @@ function reasonCountsFor(candidates, guardCounts, scanIssues) {
936
977
  function reasonClassification(reason) {
937
978
  if (reason === "secret_like_content_guard" || reason === "secret_redaction_failed") {
938
979
  return {
939
- classification: "permanent",
940
- note: "secret-guarded permanent by design",
980
+ classification: "retryable",
981
+ note: "historical guard result; collector now masks and retries",
941
982
  };
942
983
  }
943
984
  if (reason === "file_too_large") {
@@ -988,7 +1029,7 @@ function reasonClassification(reason) {
988
1029
  };
989
1030
  }
990
1031
  function uploadableCandidates(candidates) {
991
- return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state) &&
1032
+ return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
992
1033
  candidate.worktree);
993
1034
  }
994
1035
  function buildBackfillBatches(candidates) {
@@ -1157,7 +1198,7 @@ function buildBackfillSessionReport(options) {
1157
1198
  ? { upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED }
1158
1199
  : {}),
1159
1200
  }
1160
- : isRawEvidenceUploadableAttributionState(candidate.state)
1201
+ : isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null)
1161
1202
  ? {
1162
1203
  upload_state: "not_uploaded",
1163
1204
  // BLI-2107: same rule as live sync — an attributed session with
@@ -1237,6 +1278,61 @@ function durableBackfillCandidateKeys(batch, sync) {
1237
1278
  }
1238
1279
  return durable;
1239
1280
  }
1281
+ async function recordBackfillDurableSessionPointers(options) {
1282
+ const durableObjectBySession = new Map();
1283
+ for (const sync of options.syncResults) {
1284
+ for (const outcome of sync.raw_evidence_outcomes) {
1285
+ if (!outcome.codex_session_id ||
1286
+ (outcome.kind !== "codex_jsonl" && outcome.kind !== "claude_jsonl") ||
1287
+ (outcome.upload_state !== "uploaded" &&
1288
+ outcome.upload_state !== "reused_existing") ||
1289
+ !outcome.object_key) {
1290
+ continue;
1291
+ }
1292
+ const source = outcome.kind === "codex_jsonl" ? "codex" : "claude_code";
1293
+ durableObjectBySession.set(`${source}:${outcome.codex_session_id}`, outcome.object_key);
1294
+ }
1295
+ }
1296
+ let recordedCount = 0;
1297
+ for (const source of ["codex", "claude_code"]) {
1298
+ const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
1299
+ const cursor = await readRawEvidenceCursor(options.paths, { filename });
1300
+ let changed = false;
1301
+ for (const candidate of options.candidates) {
1302
+ if (candidate.source !== source)
1303
+ continue;
1304
+ const objectKey = durableObjectBySession.get(`${source}:${candidate.session_id}`);
1305
+ const prior = cursor.sessions[candidate.session_id];
1306
+ if (!objectKey || !prior || prior.uploaded_object_key)
1307
+ continue;
1308
+ cursor.sessions[candidate.session_id] = {
1309
+ ...prior,
1310
+ file_hash_sha256: candidate.content_hash_sha256,
1311
+ file_mtime_ms: candidate.session_file_mtime_ms,
1312
+ byte_size: candidate.byte_size,
1313
+ byte_offset: candidate.byte_size,
1314
+ state: candidate.state,
1315
+ reason: candidate.reason,
1316
+ worktree_fingerprint: candidate.worktree?.worktree_fingerprint ?? null,
1317
+ uploaded_object_key: objectKey,
1318
+ uploaded_at: options.now.toISOString(),
1319
+ uploaded_byte_size: candidate.byte_size,
1320
+ last_seen_at: options.now.toISOString(),
1321
+ };
1322
+ changed = true;
1323
+ recordedCount += 1;
1324
+ }
1325
+ if (changed) {
1326
+ await writeRawEvidenceCursor(options.paths, cursor, {
1327
+ filename,
1328
+ sessionsOnly: source === "claude_code",
1329
+ });
1330
+ }
1331
+ }
1332
+ if (recordedCount > 0) {
1333
+ console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount }));
1334
+ }
1335
+ }
1240
1336
  function countSessionUploadFailures(sync) {
1241
1337
  return sync.raw_evidence_outcomes.filter((outcome) => Boolean(outcome.codex_session_id) &&
1242
1338
  outcome.upload_state === "upload_failed" &&
@@ -1266,7 +1362,7 @@ function advanceBackfillCursorThroughResolvedPrefix(options) {
1266
1362
  const key = candidateCursorKey(candidate);
1267
1363
  if (options.retryableCandidateKeys.has(key))
1268
1364
  break;
1269
- const resolved = isRawEvidenceUploadableAttributionState(candidate.state)
1365
+ const resolved = isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null)
1270
1366
  ? options.durableCandidateKeys.has(key)
1271
1367
  : true;
1272
1368
  if (!resolved)
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.16");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.18");
19
19
  return 0;
20
20
  }
21
21
 
@@ -14,7 +14,7 @@ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET }
14
14
  import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
15
15
  import { normalizeCollectionRoots } from "../root-normalization.js";
16
16
  import { clearSourceRetryFailure, readLocalUploadSpoolState, recordSourceRetryFailure, } from "../spool/local-spool.js";
17
- import { isLiveRawEvidenceSyncAttribution, } from "../raw-evidence-attribution-policy.js";
17
+ import { isLiveRawEvidenceSyncAttribution, isRawEvidenceUploadableAttributionState, } from "../raw-evidence-attribution-policy.js";
18
18
  /**
19
19
  * The label used when a sync fails and nothing on the way there said why.
20
20
  *
@@ -30,16 +30,15 @@ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
30
30
  * Live sync remains reason-allowlisted even though historical backfill accepts
31
31
  * every deterministic fallback state.
32
32
  */
33
- export function isLiveSyncCollectableAttributionState(state, reason) {
34
- return isLiveRawEvidenceSyncAttribution(state, reason);
33
+ export function isLiveSyncCollectableAttributionState(state, reason, hasApprovedWorkspace = false) {
34
+ return isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace);
35
35
  }
36
36
  function cursorHasUndurableCollectableSession(cursor) {
37
37
  return Object.values(cursor.sessions).some((entry) => liveSyncCursorEntryRequiresRetry(entry));
38
38
  }
39
39
  export function liveSyncCursorEntryRequiresRetry(entry) {
40
40
  return (!entry.uploaded_object_key &&
41
- (isLiveSyncCollectableAttributionState(entry.state, entry.reason) ||
42
- entry.reason === "repo_not_on_disk"));
41
+ (isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk"));
43
42
  }
44
43
  function allLocalHistorySinceMinutes(now) {
45
44
  // A cutoff just before the Unix epoch is effectively unbounded for Codex and
@@ -82,7 +81,7 @@ async function reconcileSourceScanRetry(options) {
82
81
  }
83
82
  }
84
83
  export function matchesLiveSyncWorktree(result, target) {
85
- return (isLiveRawEvidenceSyncAttribution(result.state, result.reason) &&
84
+ return (isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null) &&
86
85
  result.worktree !== null &&
87
86
  liveSyncTargetKey(result.worktree) === liveSyncTargetKey(target));
88
87
  }
@@ -101,7 +100,7 @@ export function liveSyncTargetWorktrees(discovered, results) {
101
100
  const targetKey = result.worktree
102
101
  ? liveSyncTargetKey(result.worktree)
103
102
  : null;
104
- if (!isLiveSyncCollectableAttributionState(result.state, result.reason) ||
103
+ if (!isLiveSyncCollectableAttributionState(result.state, result.reason, result.worktree !== null) ||
105
104
  !result.worktree ||
106
105
  !targetKey ||
107
106
  seen.has(targetKey)) {
@@ -117,10 +116,11 @@ export function liveSyncTargetWorktrees(discovered, results) {
117
116
  * modes. Codex AND Claude Code sessions are scanned and attributed once across
118
117
  * every discovered worktree; each worktree syncs its exact and deterministic
119
118
  * fallback-attributed transcripts (Codex + Claude main + sidecars). The
120
- * ambiguous/unattributed/skipped remainder is reported with reason labels and
121
- * a `source` discriminator instead of being duplicated into every repo or
122
- * silently dropped. The session row's upload state maps ONLY from the main-file
123
- * outcome (D3); sidecar outcomes aggregate into CLI counts.
119
+ * ambiguous/unattributed/skipped sessions inside an approved root retain those
120
+ * labels while syncing once through that root's synthetic folder workspace.
121
+ * Outside-root sessions remain diagnostic-only. The session row's upload state
122
+ * maps ONLY from the main-file outcome (D3); sidecar outcomes aggregate into
123
+ * CLI counts.
124
124
  */
125
125
  export async function runAttributedWorktreeSync(options) {
126
126
  const now = new Date();
@@ -625,8 +625,7 @@ export function buildAgentSessionReport(options) {
625
625
  raw_evidence_pointer_id: priorDurablePointer,
626
626
  upload_state: "reused_existing",
627
627
  }
628
- : result.state === "attributed" ||
629
- result.state === "attributed_fallback"
628
+ : isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
630
629
  ? {
631
630
  upload_state: "not_uploaded",
632
631
  // BLI-2107: `not_uploaded` used to be the branch of last
@@ -140,6 +140,7 @@ export async function uploadRawEvidenceFilesChunked(options) {
140
140
  continue;
141
141
  }
142
142
  const outcome = await uploadOneObject(options, entry, disposition, chunkSizeBytes);
143
+ await reportAbandonedUpload(options, disposition, outcome);
143
144
  outcomes.push(outcome);
144
145
  for (const duplicate of entry.duplicates) {
145
146
  outcomes.push(duplicateOutcome(outcome, duplicate));
@@ -171,6 +172,72 @@ function duplicateOutcome(primary, duplicate) {
171
172
  uploaded_chunk_count: 0,
172
173
  };
173
174
  }
175
+ /**
176
+ * Tell the server why we gave up on a row `begin` already opened (BLI-2539).
177
+ *
178
+ * `begin` opens a ledger row per object and the chunks then go one object at a
179
+ * time, so one object failing leaves its siblings untouched — the production
180
+ * shape is a lone failure among committed rows. This function is the only thing
181
+ * standing between that failure and a row that sits `pending` with a null
182
+ * reason until a drain relabels it `staging_incomplete` weeks later, which
183
+ * names the shape and not the cause. 275 rows reached that state by 2026-08-14.
184
+ *
185
+ * The server records the reason on the still-open row (for a reason classified
186
+ * permanent it fails the row closed), so reporting a give-up never costs the
187
+ * staged chunks the next sync resumes from.
188
+ *
189
+ * Deliberately best-effort: the upload has already failed and the caller's
190
+ * outcome is the answer that matters. Losing the abort as well leaves exactly
191
+ * the row we had before this existed, so a delivery failure must not throw —
192
+ * but it is named on stderr rather than swallowed, because a reason the server
193
+ * never received is invisible precisely where BLI-2539 needed it visible.
194
+ */
195
+ async function reportAbandonedUpload(options, disposition, outcome) {
196
+ if (outcome.upload_state !== "upload_failed")
197
+ return;
198
+ if (!outcome.reason)
199
+ return;
200
+ // Only a row this call actually opened or resumed. `conflict` never allocated
201
+ // one for us, and `already_committed` names durable content an abort must not
202
+ // touch.
203
+ if (disposition.disposition !== "new" && disposition.disposition !== "resume") {
204
+ return;
205
+ }
206
+ if (!disposition.upload_id)
207
+ return;
208
+ const reason = conformReasonLabel(outcome.reason);
209
+ const response = await requestJson(options, "/api/ambient/evidence/upload/abort", {
210
+ schema_version: "ambient-raw-evidence-upload-abort.v1",
211
+ generated_at: options.generatedAt,
212
+ provenance: options.provenance,
213
+ upload_id: disposition.upload_id,
214
+ object_key: outcome.object_key,
215
+ reason,
216
+ uploaded_chunk_count: outcome.uploaded_chunk_count,
217
+ });
218
+ if (!response.ok) {
219
+ // status 404 is an old dashboard without the abort route; status 0 means
220
+ // the request itself never completed. Metadata only — ids, status, labels.
221
+ console.error("[evidence-abort] delivery failed; the server did not record the reason", JSON.stringify({
222
+ upload_id: disposition.upload_id,
223
+ reason,
224
+ http_status: response.status,
225
+ }));
226
+ }
227
+ }
228
+ /**
229
+ * Force a reason into the label shape the abort route accepts.
230
+ *
231
+ * Every reason this client composes already fits, and this exists so that stays
232
+ * true without anyone having to remember it. A label the server rejects comes
233
+ * back 400 and the reason is lost — which is the precise failure BLI-2539 is
234
+ * about, arriving through the code meant to fix it. A mangled label that lands
235
+ * beats a perfect one that does not.
236
+ */
237
+ function conformReasonLabel(reason) {
238
+ const conformed = reason.replace(/[^a-z0-9_:.-]/gi, "_").slice(0, 120);
239
+ return conformed.length > 0 ? conformed : "upload_failed_unlabelled";
240
+ }
174
241
  async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
175
242
  const objectKey = entry.file.pointer.object_key ?? "";
176
243
  if (disposition.disposition === "already_committed") {
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Historical backfill and attribution diagnostics may retain any deterministic
3
- * fallback that produced a worktree identity. Ambiguous and unattributed
4
- * sessions still need a separate quarantine lane so collection does not invent
5
- * repo provenance.
3
+ * fallback that produced a worktree identity. Session-first terminal labels
4
+ * are uploadable only when attribution attached an approved-root synthetic
5
+ * workspace; the label stays terminal and no repository provenance is invented.
6
6
  */
7
- export function isRawEvidenceUploadableAttributionState(state) {
8
- return state === "attributed" || state === "attributed_fallback";
7
+ export function isRawEvidenceUploadableAttributionState(state, hasApprovedWorkspace = false) {
8
+ return (state === "attributed" ||
9
+ state === "attributed_fallback" ||
10
+ (hasApprovedWorkspace && isTerminalAttributionState(state)));
9
11
  }
10
12
  const LIVE_WORKTREE_FALLBACK_REASONS = new Set([
11
13
  "known_repo_primary_fallback",
@@ -18,8 +20,12 @@ const LIVE_WORKTREE_FALLBACK_REASONS = new Set([
18
20
  * identities it knows how to validate and materialize as sync targets. Future
19
21
  * fallback reasons stay fail-closed until their provenance contract is added.
20
22
  */
21
- export function isLiveRawEvidenceSyncAttribution(state, reason) {
23
+ export function isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace = false) {
22
24
  return (state === "attributed" ||
23
25
  (state === "attributed_fallback" &&
24
- LIVE_WORKTREE_FALLBACK_REASONS.has(reason)));
26
+ LIVE_WORKTREE_FALLBACK_REASONS.has(reason)) ||
27
+ (hasApprovedWorkspace && isTerminalAttributionState(state)));
28
+ }
29
+ function isTerminalAttributionState(state) {
30
+ return state === "ambiguous" || state === "unattributed" || state === "skipped";
25
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
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-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.18"
29
+ "@bli-cockpit/telemetry-core": "0.1.19"
30
30
  }
31
31
  }