@bli-cockpit/cli 0.2.26 → 0.2.28

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.
@@ -8,6 +8,7 @@ import { makeSourceAdapterIdentity, } from "./common.js";
8
8
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
9
9
  import { defaultCodexSessionDirs, } from "./codex-attribution.js";
10
10
  import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
11
+ import { contentKeyedRawEvidencePackId, DELIVERY_BACKOFF_HOLDING_REASON, evidenceSourceKey, heldSourceKeys, readRawEvidenceStagingState, recordStagedObject, resolveStagedObject, writeRawEvidenceStagingState, } from "../raw-evidence-staging.js";
11
12
  const DEFAULT_SINCE_MINUTES = 24 * 60;
12
13
  const DEFAULT_SESSION_LIMIT = 50;
13
14
  const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
@@ -23,9 +24,14 @@ export const RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET = 300;
23
24
  const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
24
25
  export async function collectRawEvidencePack(context, options) {
25
26
  const startedAt = context.now.toISOString();
26
- const packId = rawEvidencePackId(context);
27
- const evidenceDir = path.join(options.stateDir, "raw-evidence", packId);
28
- const filesDir = path.join(evidenceDir, "files");
27
+ const rawEvidenceRoot = path.join(options.stateDir, "raw-evidence");
28
+ // Staging first, promotion second (BLI-3066). The pack id cannot be known
29
+ // until the content is, so bytes land in a private staging directory and the
30
+ // directory is then renamed to its content-keyed name — or dropped, when an
31
+ // identical pack is already there.
32
+ const stagingDir = path.join(rawEvidenceRoot, `.staging-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
33
+ const filesDir = path.join(stagingDir, "files");
34
+ const staging = await readRawEvidenceStagingState(options.stateDir);
29
35
  const entries = [];
30
36
  const skipped = [];
31
37
  const truncated = [];
@@ -39,7 +45,13 @@ export async function collectRawEvidencePack(context, options) {
39
45
  const collection = {
40
46
  context,
41
47
  filesDir,
42
- packId,
48
+ rawEvidenceRoot,
49
+ packId: "",
50
+ staging,
51
+ heldSources: heldSourceKeys(staging, context.now),
52
+ stagedReusedCount: 0,
53
+ stagedNewCount: 0,
54
+ deliveryHeldCount: 0,
43
55
  entries,
44
56
  skipped,
45
57
  truncated,
@@ -101,7 +113,7 @@ export async function collectRawEvidencePack(context, options) {
101
113
  index: { value: 0 },
102
114
  };
103
115
  try {
104
- await ensurePrivateDir(evidenceDir);
116
+ await ensurePrivateDir(stagingDir);
105
117
  await ensurePrivateDir(filesDir);
106
118
  recordAttributionCompleteness(collection, {
107
119
  codex: options.codexAttributionScan,
@@ -125,6 +137,16 @@ export async function collectRawEvidencePack(context, options) {
125
137
  const deferredByteBudgetCount = skipped.filter((entry) => entry.reason === "deferred_byte_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
126
138
  const deferredObjectBudgetCount = skipped.filter((entry) => entry.reason === "deferred_object_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
127
139
  if (entries.length === 0) {
140
+ // Nothing collected: drop the staging directory instead of leaving an
141
+ // empty pack behind. This used to leave one empty `work-*` dir per sync.
142
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
143
+ const packId = contentKeyedRawEvidencePackId({
144
+ workContextId: context.workContextId,
145
+ contentHashes: [],
146
+ });
147
+ collection.packId = packId;
148
+ const evidenceDir = path.join(rawEvidenceRoot, packId);
149
+ await persistStagingState(options.stateDir, collection);
128
150
  const evidenceCompleteness = makeEvidenceCompleteness(collection, {
129
151
  startedAt,
130
152
  finishedAt: context.now.toISOString(),
@@ -140,6 +162,10 @@ export async function collectRawEvidencePack(context, options) {
140
162
  skipped_count: countEvidenceEntries(skipped),
141
163
  sanitized_count: redacted.length,
142
164
  reused_count: reused.length,
165
+ staged_reused_count: collection.stagedReusedCount,
166
+ staged_new_count: collection.stagedNewCount,
167
+ delivery_held_count: collection.deliveryHeldCount,
168
+ stage_state: "empty",
143
169
  deferred_byte_budget_count: deferredByteBudgetCount,
144
170
  deferred_object_budget_count: deferredObjectBudgetCount,
145
171
  content_kinds: [],
@@ -158,18 +184,45 @@ export async function collectRawEvidencePack(context, options) {
158
184
  }),
159
185
  };
160
186
  }
161
- const manifestWithoutSelf = makeManifest({
187
+ // The pack is named by what is in it, never by when it was made. Identical
188
+ // content on the next sync resolves to the identical directory, which is
189
+ // the whole fix for BLI-3066.
190
+ const packId = contentKeyedRawEvidencePackId({
191
+ workContextId: context.workContextId,
192
+ contentHashes: entries.map((entry) => entry.content_hash_sha256),
193
+ });
194
+ collection.packId = packId;
195
+ const promotion = await promoteStagedPack({
196
+ rawEvidenceRoot,
197
+ stagingDir,
198
+ packId,
199
+ workContextId: context.workContextId,
200
+ entries,
201
+ });
202
+ const evidenceDir = promotion.evidenceDir;
203
+ for (const entry of entries) {
204
+ if (!entry.staged_in_pack)
205
+ continue;
206
+ entry.local_path = path.join(evidenceDir, "files", path.basename(entry.local_path));
207
+ recordStagedObject(collection.staging, entry.content_hash_sha256, {
208
+ pack_id: packId,
209
+ relative_path: `files/${path.basename(entry.local_path)}`,
210
+ byte_size: entry.byte_size,
211
+ source_key: entry.source_key,
212
+ staged_at: context.now.toISOString(),
213
+ });
214
+ }
215
+ const manifestPath = path.join(evidenceDir, "manifest.json");
216
+ const manifestBytes = await stageManifest({
162
217
  context,
163
218
  packId,
219
+ manifestPath,
164
220
  entries,
165
221
  skipped,
166
222
  redacted,
167
223
  reused,
224
+ reusePack: promotion.state === "reused",
168
225
  });
169
- const manifestPath = path.join(evidenceDir, "manifest.json");
170
- const manifestBytes = Buffer.from(`${JSON.stringify(manifestWithoutSelf, null, 2)}\n`, "utf8");
171
- await fs.writeFile(manifestPath, manifestBytes, { mode: 0o600 });
172
- await chmodPrivate(manifestPath, 0o600);
173
226
  const manifestEntry = evidenceEntry({
174
227
  context,
175
228
  kind: "manifest",
@@ -181,6 +234,25 @@ export async function collectRawEvidencePack(context, options) {
181
234
  bytes: manifestBytes,
182
235
  });
183
236
  entries.push(manifestEntry);
237
+ await persistStagingState(options.stateDir, collection);
238
+ console.error("[raw-evidence] pack staged", JSON.stringify({
239
+ pack_id: packId,
240
+ stage_state: promotion.state,
241
+ reason: promotion.state === "reused"
242
+ ? "staged_reused"
243
+ : promotion.state === "restaged_incomplete"
244
+ ? "restaged_incomplete"
245
+ : promotion.priorPackCount > 0
246
+ ? "restaged_content_changed"
247
+ : "staged_new",
248
+ prior_pack_count: promotion.priorPackCount,
249
+ refilled_file_count: promotion.refilledFileCount,
250
+ file_count: entries.length,
251
+ byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
252
+ staged_new: collection.stagedNewCount,
253
+ staged_reused: collection.stagedReusedCount,
254
+ delivery_held: collection.deliveryHeldCount,
255
+ }));
184
256
  const evidenceCompleteness = makeEvidenceCompleteness(collection, {
185
257
  startedAt,
186
258
  finishedAt: context.now.toISOString(),
@@ -191,6 +263,10 @@ export async function collectRawEvidencePack(context, options) {
191
263
  manifest_path: manifestPath,
192
264
  evidence_dir: evidenceDir,
193
265
  storage_bucket: RAW_EVIDENCE_BUCKET,
266
+ staged_reused_count: collection.stagedReusedCount,
267
+ staged_new_count: collection.stagedNewCount,
268
+ delivery_held_count: collection.deliveryHeldCount,
269
+ stage_state: promotion.state,
194
270
  file_count: entries.length,
195
271
  byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
196
272
  skipped_count: countEvidenceEntries(skipped),
@@ -222,11 +298,28 @@ export async function collectRawEvidencePack(context, options) {
222
298
  }),
223
299
  };
224
300
  }
225
- catch {
301
+ catch (error) {
226
302
  failed.push({
227
303
  kind: "raw_evidence",
228
304
  reason: "collection_failed",
229
305
  });
306
+ // Staging is per-attempt scratch: a crashed pass must not leave a partial
307
+ // directory behind to be counted, re-hashed or swept later.
308
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
309
+ const packId = collection.packId ||
310
+ contentKeyedRawEvidencePackId({
311
+ workContextId: context.workContextId,
312
+ contentHashes: entries.map((entry) => entry.content_hash_sha256),
313
+ });
314
+ const evidenceDir = path.join(rawEvidenceRoot, packId);
315
+ console.error("[raw-evidence] pack collection failed", JSON.stringify({
316
+ pack_id: packId,
317
+ reason: "collection_failed",
318
+ detail: error instanceof Error ? error.name : typeof error,
319
+ collected_file_count: entries.length,
320
+ staged_new: collection.stagedNewCount,
321
+ staged_reused: collection.stagedReusedCount,
322
+ }));
230
323
  const evidenceCompleteness = makeEvidenceCompleteness(collection, {
231
324
  startedAt,
232
325
  finishedAt: context.now.toISOString(),
@@ -242,6 +335,10 @@ export async function collectRawEvidencePack(context, options) {
242
335
  skipped_count: countEvidenceEntries(skipped),
243
336
  sanitized_count: redacted.length,
244
337
  reused_count: reused.length,
338
+ staged_reused_count: collection.stagedReusedCount,
339
+ staged_new_count: collection.stagedNewCount,
340
+ delivery_held_count: collection.deliveryHeldCount,
341
+ stage_state: "empty",
245
342
  deferred_byte_budget_count: skipped
246
343
  .filter((entry) => entry.reason === "deferred_byte_budget")
247
344
  .reduce((sum, entry) => sum + evidenceEntryCount(entry), 0),
@@ -293,6 +390,10 @@ function makeRawEvidenceScan(options) {
293
390
  `bytes:${options.facts.byte_size}`,
294
391
  `skipped:${options.facts.skipped_count}`,
295
392
  `reused:${options.facts.reused_count}`,
393
+ `stage_state:${options.facts.stage_state}`,
394
+ `staged_new:${options.facts.staged_new_count}`,
395
+ `staged_reused:${options.facts.staged_reused_count}`,
396
+ `delivery_held:${options.facts.delivery_held_count}`,
296
397
  `completeness:${options.facts.evidence_completeness.status}`,
297
398
  `truncated:${options.facts.evidence_completeness.totals.truncated_count}`,
298
399
  `deferred:${options.facts.evidence_completeness.totals.deferred_count}`,
@@ -576,15 +677,26 @@ async function collectOneAgentImageFile(collection, options) {
576
677
  }
577
678
  collection.index.value += 1;
578
679
  const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${contentHash.slice(0, 16)}.${options.image.extension}`);
579
- const destination = path.join(collection.filesDir, path.basename(relativePath));
580
- await fs.writeFile(destination, raw, { mode: 0o600 });
581
- await chmodPrivate(destination, 0o600);
680
+ const imageSourceKey = evidenceSourceKey({
681
+ kind: options.kind,
682
+ sessionId: options.sessionId,
683
+ label: options.image.label,
684
+ });
685
+ const staged = await stageEvidenceBytes(collection, {
686
+ contentHash,
687
+ bytes: raw,
688
+ fileName: path.basename(relativePath),
689
+ kind: options.kind,
690
+ sourceKey: imageSourceKey,
691
+ });
582
692
  collection.entries.push(evidenceEntry({
583
693
  context: collection.context,
584
694
  kind: options.kind,
585
695
  packId: collection.packId,
586
- localPath: destination,
696
+ localPath: staged.local_path,
587
697
  relativePath,
698
+ stagedInPack: staged.staged_in_pack,
699
+ sourceKey: imageSourceKey,
588
700
  mediaType: metadata.media_type,
589
701
  redactedSummary: "Raw image explicitly attached to an agent session, preserved in private durable storage.",
590
702
  bytes: raw,
@@ -606,6 +718,29 @@ async function collectOneEvidenceFile(collection, options) {
606
718
  ? "[REDACTED_FILE_NAME]"
607
719
  : fileName;
608
720
  const packedFileName = secretLikeFileName ? "redacted-file.jsonl" : fileName;
721
+ const sourceKey = evidenceSourceKey({
722
+ kind: options.kind,
723
+ sessionId: options.sessionId,
724
+ sourcePath: options.filePath,
725
+ });
726
+ // Delivery backoff is checked BEFORE the file is read. An object whose commit
727
+ // has failed repeatedly costs nothing at all this cycle — no read, no hash,
728
+ // no copy, no request — and the hold is a named, retryable gap so a held
729
+ // session cannot make the sync look clean (BLI-3066).
730
+ if (collection.heldSources.has(sourceKey)) {
731
+ collection.deliveryHeldCount += 1;
732
+ collection.skipped.push({
733
+ kind: options.kind,
734
+ label: evidenceLabel,
735
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
736
+ });
737
+ console.error("[raw-evidence] delivery backoff holding source", JSON.stringify({
738
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
739
+ kind: options.kind,
740
+ source_key: sourceKey,
741
+ }));
742
+ return false;
743
+ }
609
744
  if (options.maxFileBytes) {
610
745
  let stat;
611
746
  try {
@@ -694,14 +829,18 @@ async function collectOneEvidenceFile(collection, options) {
694
829
  }
695
830
  collection.index.value += 1;
696
831
  const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${packedFileName}`);
697
- const destination = path.join(collection.filesDir, path.basename(relativePath));
698
- await fs.writeFile(destination, evidenceBytes, { mode: 0o600 });
699
- await chmodPrivate(destination, 0o600);
832
+ const staged = await stageEvidenceBytes(collection, {
833
+ contentHash,
834
+ bytes: evidenceBytes,
835
+ fileName: path.basename(relativePath),
836
+ kind: options.kind,
837
+ sourceKey,
838
+ });
700
839
  collection.entries.push(evidenceEntry({
701
840
  context: collection.context,
702
841
  kind: options.kind,
703
842
  packId: collection.packId,
704
- localPath: destination,
843
+ localPath: staged.local_path,
705
844
  relativePath,
706
845
  mediaType: options.mediaType,
707
846
  redactedSummary: redaction
@@ -711,9 +850,38 @@ async function collectOneEvidenceFile(collection, options) {
711
850
  bytes: evidenceBytes,
712
851
  codexSessionId: options.sessionId,
713
852
  contentAddress: options.contentAddress(contentHash.slice(0, 16)),
853
+ stagedInPack: staged.staged_in_pack,
854
+ sourceKey,
714
855
  }));
715
856
  return true;
716
857
  }
858
+ /**
859
+ * Put these bytes on disk once.
860
+ *
861
+ * If an earlier sync already staged this exact content and the copy is still
862
+ * there, that copy is used — the uploader only needs a readable path, and it
863
+ * does not care which pack directory holds it. This is the branch that stops
864
+ * one 334 MB rollout from becoming 559 copies while its commit keeps failing.
865
+ */
866
+ async function stageEvidenceBytes(collection, options) {
867
+ const existing = await resolveStagedObject(collection.rawEvidenceRoot, collection.staging, options.contentHash);
868
+ if (existing) {
869
+ collection.stagedReusedCount += 1;
870
+ console.error("[raw-evidence] staged copy reused", JSON.stringify({
871
+ reason: "staged_reused",
872
+ kind: options.kind,
873
+ content_hash_prefix: options.contentHash.slice(0, 16),
874
+ byte_size: existing.entry.byte_size,
875
+ pack_id: existing.entry.pack_id,
876
+ }));
877
+ return { local_path: existing.local_path, staged_in_pack: false };
878
+ }
879
+ const destination = path.join(collection.filesDir, options.fileName);
880
+ await fs.writeFile(destination, options.bytes, { mode: 0o600 });
881
+ await chmodPrivate(destination, 0o600);
882
+ collection.stagedNewCount += 1;
883
+ return { local_path: destination, staged_in_pack: true };
884
+ }
717
885
  /**
718
886
  * Decrements the per-sync budget when a file fits, or returns a deferred-skip
719
887
  * reason when it does not. The object budget bounds request count; the byte
@@ -934,15 +1102,26 @@ async function collectGitDiffFiles(collection, repoRoot) {
934
1102
  continue;
935
1103
  }
936
1104
  const relativePath = path.join("files", `git-${target.label}.diff`);
937
- const destination = path.join(collection.filesDir, path.basename(relativePath));
938
- await fs.writeFile(destination, raw, { mode: 0o600 });
939
- await chmodPrivate(destination, 0o600);
1105
+ const diffSourceKey = evidenceSourceKey({
1106
+ kind: "git_diff",
1107
+ sessionId: collection.context.workContextId,
1108
+ label: target.label,
1109
+ });
1110
+ const staged = await stageEvidenceBytes(collection, {
1111
+ contentHash,
1112
+ bytes: raw,
1113
+ fileName: path.basename(relativePath),
1114
+ kind: "git_diff",
1115
+ sourceKey: diffSourceKey,
1116
+ });
940
1117
  collection.entries.push(evidenceEntry({
941
1118
  context: collection.context,
942
1119
  kind: "git_diff",
943
1120
  packId: collection.packId,
944
- localPath: destination,
1121
+ localPath: staged.local_path,
945
1122
  relativePath,
1123
+ stagedInPack: staged.staged_in_pack,
1124
+ sourceKey: diffSourceKey,
946
1125
  mediaType: "text/x-diff",
947
1126
  redactedSummary: redaction
948
1127
  ? `Raw git ${target.label} diff preserved locally with env/secret paths excluded and secret-like values deterministically redacted.`
@@ -1309,6 +1488,8 @@ function evidenceEntry(options) {
1309
1488
  redacted_summary: options.redactedSummary,
1310
1489
  ...(options.redaction ? { redaction: options.redaction } : {}),
1311
1490
  codex_session_id: options.codexSessionId ?? null,
1491
+ staged_in_pack: options.stagedInPack !== false,
1492
+ source_key: options.sourceKey ?? null,
1312
1493
  ...(options.artifactMetadata
1313
1494
  ? {
1314
1495
  artifact_metadata: {
@@ -1323,6 +1504,10 @@ function evidenceEntry(options) {
1323
1504
  : {}),
1324
1505
  };
1325
1506
  }
1507
+ // `local_path` is redacted out, and so are the staging bookkeeping fields:
1508
+ // where a copy happens to live on this disk is not part of the pack's identity,
1509
+ // and putting it in the manifest would make byte-identical content produce
1510
+ // different manifests.
1326
1511
  function redactManifestEntry(entry) {
1327
1512
  return {
1328
1513
  kind: entry.kind,
@@ -1417,13 +1602,125 @@ function readableKeySegment(value, fallback, options = {}) {
1417
1602
  function posixPath(parts) {
1418
1603
  return parts.join("/").replace(/\\/g, "/").replace(/\/+/g, "/");
1419
1604
  }
1420
- function rawEvidencePackId(context) {
1421
- const material = [
1422
- context.workContextId,
1423
- context.sessionId,
1424
- context.now.toISOString(),
1425
- ].join(":");
1426
- return `${context.workContextId}-${shortHash(material)}`;
1605
+ /**
1606
+ * Turn the staging directory into the content-keyed pack directory.
1607
+ *
1608
+ * Three outcomes, all of them named:
1609
+ * - the pack does not exist yet: rename staging into place (`new`);
1610
+ * - it exists and holds every file this pass staged: adopt it and delete the
1611
+ * staging copy (`reused`) — nothing is copied twice;
1612
+ * - it exists but is missing files: fill the gaps from staging
1613
+ * (`restaged_incomplete`), because a half-written pack must not be trusted.
1614
+ *
1615
+ * Never deletes an existing pack directory wholesale: another pack's entries
1616
+ * can point into it through the staged-object index.
1617
+ */
1618
+ async function promoteStagedPack(options) {
1619
+ const evidenceDir = path.join(options.rawEvidenceRoot, options.packId);
1620
+ const priorPackCount = await countPriorPacks(options.rawEvidenceRoot, options.workContextId, options.packId);
1621
+ const existing = await fs.stat(evidenceDir).catch(() => null);
1622
+ if (!existing?.isDirectory()) {
1623
+ try {
1624
+ await fs.rename(options.stagingDir, evidenceDir);
1625
+ return {
1626
+ state: "new",
1627
+ evidenceDir,
1628
+ priorPackCount,
1629
+ refilledFileCount: 0,
1630
+ };
1631
+ }
1632
+ catch {
1633
+ // A concurrent sync can win the race to the same content-keyed name.
1634
+ // Losing it is fine: the winner staged the identical bytes.
1635
+ }
1636
+ }
1637
+ let refilledFileCount = 0;
1638
+ await ensurePrivateDir(path.join(evidenceDir, "files"));
1639
+ for (const entry of options.entries) {
1640
+ if (!entry.staged_in_pack)
1641
+ continue;
1642
+ const fileName = path.basename(entry.local_path);
1643
+ const target = path.join(evidenceDir, "files", fileName);
1644
+ const info = await fs.stat(target).catch(() => null);
1645
+ if (info?.isFile() && info.size === entry.byte_size)
1646
+ continue;
1647
+ await fs.copyFile(entry.local_path, target).catch(() => undefined);
1648
+ await chmodPrivate(target, 0o600);
1649
+ refilledFileCount += 1;
1650
+ }
1651
+ await fs.rm(options.stagingDir, { recursive: true, force: true }).catch(() => undefined);
1652
+ return {
1653
+ state: refilledFileCount > 0 ? "restaged_incomplete" : "reused",
1654
+ evidenceDir,
1655
+ priorPackCount,
1656
+ refilledFileCount,
1657
+ };
1658
+ }
1659
+ async function countPriorPacks(rawEvidenceRoot, workContextId, packId) {
1660
+ const entries = await fs
1661
+ .readdir(rawEvidenceRoot, { withFileTypes: true })
1662
+ .catch(() => []);
1663
+ return entries.filter((entry) => entry.isDirectory() &&
1664
+ entry.name !== packId &&
1665
+ entry.name.startsWith(`${workContextId}-`)).length;
1666
+ }
1667
+ /**
1668
+ * Write the manifest, or keep the one already in a reused pack.
1669
+ *
1670
+ * Byte-stability matters here: the manifest's object key is the only key that
1671
+ * embeds the pack id, so rewriting it with a fresh `created_at` every sync
1672
+ * would push different bytes at the same content-addressed key forever. A
1673
+ * reused pack whose manifest already describes exactly these files keeps it.
1674
+ */
1675
+ async function stageManifest(options) {
1676
+ if (options.reusePack) {
1677
+ const existing = await fs.readFile(options.manifestPath).catch(() => null);
1678
+ if (existing && manifestDescribesEntries(existing, options.entries)) {
1679
+ return existing;
1680
+ }
1681
+ }
1682
+ const manifestBytes = Buffer.from(`${JSON.stringify(makeManifest({
1683
+ context: options.context,
1684
+ packId: options.packId,
1685
+ entries: options.entries,
1686
+ skipped: options.skipped,
1687
+ redacted: options.redacted,
1688
+ reused: options.reused,
1689
+ }), null, 2)}\n`, "utf8");
1690
+ await fs.writeFile(options.manifestPath, manifestBytes, { mode: 0o600 });
1691
+ await chmodPrivate(options.manifestPath, 0o600);
1692
+ return manifestBytes;
1693
+ }
1694
+ function manifestDescribesEntries(manifestBytes, entries) {
1695
+ try {
1696
+ const parsed = JSON.parse(manifestBytes.toString("utf8"));
1697
+ const recorded = (parsed.files ?? [])
1698
+ .map((file) => typeof file.content_hash_sha256 === "string"
1699
+ ? file.content_hash_sha256
1700
+ : "")
1701
+ .filter(Boolean)
1702
+ .sort();
1703
+ const expected = entries
1704
+ .map((entry) => entry.content_hash_sha256)
1705
+ .sort();
1706
+ return (recorded.length === expected.length &&
1707
+ recorded.every((hash, index) => hash === expected[index]));
1708
+ }
1709
+ catch {
1710
+ return false;
1711
+ }
1712
+ }
1713
+ async function persistStagingState(stateDir, collection) {
1714
+ collection.staging.updated_at = collection.context.now.toISOString();
1715
+ await writeRawEvidenceStagingState(stateDir, collection.staging).catch((error) => {
1716
+ // Losing this file costs reuse and attempt counts, not evidence, so it
1717
+ // must never fail a collection — but it must never be silent either.
1718
+ console.error("[raw-evidence] staging state write failed", JSON.stringify({
1719
+ reason: "staging_state_write_failed",
1720
+ detail: error instanceof Error ? error.name : typeof error,
1721
+ staged_count: Object.keys(collection.staging.staged).length,
1722
+ }));
1723
+ });
1427
1724
  }
1428
1725
  function shortHash(value) {
1429
1726
  return crypto.createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
package/dist/autostart.js CHANGED
@@ -226,6 +226,22 @@ async function installWindowsTask(options) {
226
226
  "-File",
227
227
  registrationPath,
228
228
  ]);
229
+ // BLI-2996: before this, a nonzero exit here (schtasks rejected the
230
+ // registration script, or Set-ScheduledTask threw) skipped straight to the
231
+ // return with `verified: null` and never logged anything — the doctor row
232
+ // said "needs repair" every run with no way to tell "the rewrite itself
233
+ // never ran" apart from "it ran and Windows still disagrees" (BLI-2996,
234
+ // Brandon's machine: same silent non-convergence under two different
235
+ // validator messages across CLI versions). Metadata only: exit code and
236
+ // whether the process produced any output, never the stderr/stdout text
237
+ // (it can carry the state directory or a workspace path).
238
+ if (created.code !== 0) {
239
+ console.error("[autostart] windows task repair rewrite failed", JSON.stringify({
240
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
241
+ exit_code: created.code,
242
+ had_output: Boolean(created.stderr.trim() || created.stdout.trim()),
243
+ }));
244
+ }
229
245
  const verified = created.code === 0
230
246
  ? await windowsTaskStatus({
231
247
  ...options,
@@ -238,6 +254,14 @@ async function installWindowsTask(options) {
238
254
  })
239
255
  : null;
240
256
  const loaded = verified?.status === "loaded";
257
+ // windowsTaskStatus() above already logs the needs-repair case (with the
258
+ // problem list) when the rewrite ran but the read-back still disagrees; log
259
+ // the converged branch here too, tagged as a repair outcome and by name, so
260
+ // "did the rewrite actually fix it this run" never depends on inferring it
261
+ // from a routine status log emitted for an unrelated reason.
262
+ if (created.code === 0 && loaded) {
263
+ console.error("[autostart] windows task repair converged", JSON.stringify({ task_name: WINDOWS_AUTOSTART_TASK_NAME }));
264
+ }
241
265
  return {
242
266
  status: "installed",
243
267
  label: AUTOSTART_LABEL,
@@ -549,10 +573,17 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
549
573
  .replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu, "")
550
574
  .trim();
551
575
  const exactExecBlock = execBlocks.length === 1 && actionRemainder === "" ? execBlocks[0] : "";
552
- const command = exactExecBlock
576
+ // Strip a wrapping quote pair defensively: every real capture we have shows
577
+ // Task Scheduler storing <Command> unquoted, but the writer builds the /TR
578
+ // executable segment with the same \"-escaped quoting it uses for the
579
+ // launcher argument (BLI-2598), and there is no verified capture proving
580
+ // schtasks' own TR-splitting heuristic always discards those quotes rather
581
+ // than leaving them as literal text (BLI-2996 canary-gated gap). A stray
582
+ // pair of quotes should never be the reason a correctly-registered task
583
+ // reads as broken.
584
+ const command = stripSurroundingQuotes(exactExecBlock
553
585
  ?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]
554
- ?.trim() ??
555
- "";
586
+ ?.trim() ?? "");
556
587
  const actionArguments = exactExecBlock
557
588
  .match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
558
589
  ?.trim() ?? "";
@@ -654,16 +685,35 @@ function windowsWScriptPath(env = process.env) {
654
685
  * DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
655
686
  * from the expected one only by those quotes (BLI-2541).
656
687
  *
657
- * Compare the flags exactly, because those are ours, and compare the launcher
658
- * as a path, because that is what Windows normalizes. Each failure returns its
659
- * own reason so a repair message says which half is wrong rather than
660
- * "arguments differ".
688
+ * BLI-2996: that lesson was only ever applied to the pre-BLI-2677 `-File`
689
+ * action. The wscript launcher pair (`//B "<launcher>"`) has NO verified
690
+ * real-Windows capture `windowlessWindowsTaskXml()` in autostart.test.ts is
691
+ * SYNTHETIC, built by hand-editing the one real capture we have rather than
692
+ * exported from a machine actually running this action. A repair that rewrites
693
+ * the task correctly but is judged against a guessed shape can fail forever
694
+ * without ever being wrong (Brandon's machine: same non-convergence under two
695
+ * different validator messages across two CLI versions, neither of which ever
696
+ * repaired anything). So this comparison is deliberately structural, not
697
+ * positional: tokenize the way CommandLineToArgvW groups quoted/unquoted
698
+ * arguments, then check flag-token-then-path-token semantically (a real path
699
+ * comparison, case- and quote-insensitive) instead of demanding the exact
700
+ * byte layout we happened to send. Whatever the wscript action's real
701
+ * normalization turns out to be once a canary captures it, this only fails on
702
+ * an actual different flag or different script — not on Windows' own
703
+ * reformatting.
661
704
  */
662
705
  function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
663
- if (!actionArguments.startsWith(expectedFlags)) {
706
+ const tokens = splitWindowsArgumentTokens(actionArguments);
707
+ if (tokens.length === 0) {
708
+ return "task action names no sync launcher to run";
709
+ }
710
+ if ((tokens[0] ?? "").toUpperCase() !== expectedFlags.toUpperCase()) {
664
711
  return "task action does not run the launcher with the expected batch-mode flag";
665
712
  }
666
- const launcherArgument = unquoteWindowsArgument(actionArguments.slice(expectedFlags.length));
713
+ if (tokens.length > 2) {
714
+ return "task action passes unexpected extra arguments to the sync launcher";
715
+ }
716
+ const launcherArgument = tokens[1];
667
717
  if (!launcherArgument) {
668
718
  return "task action names no sync launcher to run";
669
719
  }
@@ -672,10 +722,46 @@ function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLa
672
722
  }
673
723
  return null;
674
724
  }
675
- function unquoteWindowsArgument(value) {
676
- const trimmed = value.trim();
677
- const quoted = trimmed.match(/^"([\s\S]*)"$/u) ?? trimmed.match(/^'([\s\S]*)'$/u);
678
- return (quoted?.[1] ?? trimmed).trim();
725
+ /**
726
+ * Quote-aware whitespace tokenizer for an already-XML-decoded `<Arguments>`
727
+ * value: a `"..."` run is one token (quotes stripped, whitespace inside kept),
728
+ * everything else splits on whitespace. Windows paths never contain a quote
729
+ * character, so this does not need CommandLineToArgvW's backslash-escaping
730
+ * rules — only its quoting rule — to tell "one argument with a space in it"
731
+ * apart from "two arguments".
732
+ */
733
+ function splitWindowsArgumentTokens(value) {
734
+ const tokens = [];
735
+ let current = "";
736
+ let inQuotes = false;
737
+ let hasToken = false;
738
+ for (const char of value) {
739
+ if (char === '"') {
740
+ inQuotes = !inQuotes;
741
+ hasToken = true;
742
+ continue;
743
+ }
744
+ if (!inQuotes && /\s/u.test(char)) {
745
+ if (hasToken) {
746
+ tokens.push(current);
747
+ current = "";
748
+ hasToken = false;
749
+ }
750
+ continue;
751
+ }
752
+ current += char;
753
+ hasToken = true;
754
+ }
755
+ if (hasToken)
756
+ tokens.push(current);
757
+ return tokens;
758
+ }
759
+ /** Removes one wrapping pair of double quotes, if present. Used only to
760
+ * normalize before a path comparison — never to reconstruct a shell-safe
761
+ * value. */
762
+ function stripSurroundingQuotes(value) {
763
+ const match = value.match(/^"([\s\S]*)"$/u);
764
+ return match ? match[1] : value;
679
765
  }
680
766
  function sameWindowsPath(left, right) {
681
767
  if (!left || !right)