@bli-cockpit/cli 0.2.49 → 0.2.51

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 (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +286 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +465 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -0,0 +1,338 @@
1
+ /**
2
+ * How a pass ends, and what it says about itself when it does.
3
+ *
4
+ * Three endings, one report shape: nothing collected, a promoted pack, or a
5
+ * crash. All three return the same `RawEvidenceFacts` counts and an
6
+ * `evidence_completeness` payload carrying every gap the pass recorded, so a
7
+ * sync that collected nothing is legible rather than absent, and a crashed one
8
+ * is reported rather than silent.
9
+ *
10
+ * Two rules are load-bearing here:
11
+ *
12
+ * - **A pack is named by its content, never by when it ran** (BLI-3066).
13
+ * Identical content on the next sync resolves to the identical directory.
14
+ * - **The manifest is an object of the pack like any other file**, content
15
+ * hash in its key and all (BLI-3552). Keying it by pack id alone made one
16
+ * key name different bytes, and `begin` then answered
17
+ * `hash_mismatch_committed_object` on every sync forever.
18
+ *
19
+ * The pointers this module hands back are what the uploader delivers and what
20
+ * the server commits; `raw-evidence-manifest.ts` owns their shape.
21
+ */
22
+ import { SourceScanResultSchema } from "@bli-cockpit/telemetry-core";
23
+ import path from "node:path";
24
+ import { makeSourceAdapterIdentity, } from "./common.js";
25
+ import { contentKeyedRawEvidencePackId, recordStagedObject, } from "../raw-evidence-staging.js";
26
+ import { sha256 } from "./raw-evidence-keys.js";
27
+ import { countEvidenceEntries, makeEvidenceCompleteness, } from "./raw-evidence-completeness.js";
28
+ import { evidenceEntry, pointerFromEntry, RAW_EVIDENCE_BUCKET, } from "./raw-evidence-manifest.js";
29
+ import { persistStagingState, promoteStagedPack, stageManifest, } from "./raw-evidence-pack-store.js";
30
+ import { discardStagingDir, } from "./raw-evidence-collection-state.js";
31
+ // ---------------------------------------------------------------------------
32
+ // The three ways a pass ends
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * Nothing collected: drop the staging directory instead of leaving an empty
36
+ * pack behind. This used to leave one empty `work-*` dir per sync.
37
+ */
38
+ export async function finishWithEmptyPack(collection, options, run) {
39
+ await discardStagingDir(run.stagingDir);
40
+ collection.packId = contentKeyedRawEvidencePackId({
41
+ workContextId: collection.context.workContextId,
42
+ contentHashes: [],
43
+ });
44
+ await persistStagingState(options.stateDir, collection.staging, collection.context.now.toISOString());
45
+ const facts = makeEmptyPackFacts(collection, run.window);
46
+ return {
47
+ facts,
48
+ scan: makeRawEvidenceScan({
49
+ context: collection.context,
50
+ startedAt: run.window.startedAt,
51
+ status: "partial",
52
+ facts,
53
+ }),
54
+ };
55
+ }
56
+ export async function finishWithPromotedPack(collection, options, run) {
57
+ const context = collection.context;
58
+ const entries = collection.entries;
59
+ // The pack is named by what is in it, never by when it was made. Identical
60
+ // content on the next sync resolves to the identical directory, which is
61
+ // the whole fix for BLI-3066.
62
+ const packId = contentKeyedRawEvidencePackId({
63
+ workContextId: context.workContextId,
64
+ contentHashes: entries.map((entry) => entry.content_hash_sha256),
65
+ });
66
+ collection.packId = packId;
67
+ const promotion = await promoteStagedPack({
68
+ rawEvidenceRoot: collection.rawEvidenceRoot,
69
+ stagingDir: run.stagingDir,
70
+ packId,
71
+ workContextId: context.workContextId,
72
+ entries,
73
+ });
74
+ rebaseStagedEntriesOntoPack(collection, promotion.evidenceDir);
75
+ const manifestPath = await addManifestToPack(collection, promotion, packId);
76
+ await persistStagingState(options.stateDir, collection.staging, context.now.toISOString());
77
+ logPackStaged(collection, promotion, packId);
78
+ const facts = makePromotedPackFacts(collection, {
79
+ packId,
80
+ manifestPath,
81
+ promotion,
82
+ window: run.window,
83
+ });
84
+ return {
85
+ facts,
86
+ scan: makeRawEvidenceScan({
87
+ context,
88
+ startedAt: run.window.startedAt,
89
+ // A pack of nothing but its own manifest carries no evidence, so it
90
+ // reports partial however cleanly it was written.
91
+ status: entries.length > 1 ? "ok" : "partial",
92
+ facts,
93
+ }),
94
+ };
95
+ }
96
+ /** A pack with files: the same shell as an empty one, plus what is in it. */
97
+ function makePromotedPackFacts(collection, place) {
98
+ const entries = collection.entries;
99
+ return {
100
+ ...packFactsShell(collection, {
101
+ packId: place.packId,
102
+ manifestPath: place.manifestPath,
103
+ evidenceDir: place.promotion.evidenceDir,
104
+ window: place.window,
105
+ }),
106
+ stage_state: place.promotion.state,
107
+ file_count: entries.length,
108
+ byte_size: totalByteSize(entries),
109
+ content_kinds: [...new Set(entries.map((entry) => entry.kind))],
110
+ pointers: entries.map(pointerFromEntry),
111
+ upload_files: entries.map(uploadFileFromEntry),
112
+ };
113
+ }
114
+ /**
115
+ * Write the manifest and make it an object of the pack like any other file, so
116
+ * the uploader has one list to walk. Returns where it landed.
117
+ */
118
+ async function addManifestToPack(collection, promotion, packId) {
119
+ const context = collection.context;
120
+ const manifestPath = path.join(promotion.evidenceDir, "manifest.json");
121
+ const manifestBytes = await stageManifest({
122
+ context,
123
+ packId,
124
+ manifestPath,
125
+ entries: collection.entries,
126
+ skipped: collection.skipped,
127
+ redacted: collection.redacted,
128
+ reused: collection.reused,
129
+ reusePack: promotion.state === "reused",
130
+ });
131
+ collection.entries.push(evidenceEntry({
132
+ context,
133
+ kind: "manifest",
134
+ packId,
135
+ localPath: manifestPath,
136
+ relativePath: "manifest.json",
137
+ mediaType: "application/json",
138
+ redactedSummary: "Local raw evidence pack manifest.",
139
+ bytes: manifestBytes,
140
+ // The manifest carries its own content hash in its key, exactly like
141
+ // every other object in the pack (BLI-3552). Without it the key was
142
+ // `…/<packId>/manifest.json`, and a pack id is a function of the OTHER
143
+ // files' hashes — not of the manifest's bytes, which also carry
144
+ // `created_at`, the ordering-dependent `files[].relative_path`, `branch`,
145
+ // and the skipped/redacted/reused ledgers. So the same key named
146
+ // different bytes whenever the pack directory was not adopted verbatim
147
+ // (a refill, a pruned or wiped state dir, a machine that had never seen
148
+ // the pack). `begin` then answered `hash_mismatch_committed_object` on
149
+ // every sync forever, because the new manifest is stable and the old one
150
+ // is durable. `stageManifest`'s byte-stability trick still stands; it is
151
+ // now a nice-to-have rather than the only thing between us and a loop.
152
+ contentAddress: `manifest/${sha256(manifestBytes).slice(0, 16)}.json`,
153
+ }));
154
+ return manifestPath;
155
+ }
156
+ /** The success line. A sync that staged nothing new still has to say so. */
157
+ function logPackStaged(collection, promotion, packId) {
158
+ console.error("[raw-evidence] pack staged", JSON.stringify({
159
+ pack_id: packId,
160
+ stage_state: promotion.state,
161
+ reason: stageReasonLabel(promotion.state, promotion.priorPackCount),
162
+ prior_pack_count: promotion.priorPackCount,
163
+ refilled_file_count: promotion.refilledFileCount,
164
+ file_count: collection.entries.length,
165
+ byte_size: totalByteSize(collection.entries),
166
+ staged_new: collection.stagedNewCount,
167
+ staged_reused: collection.stagedReusedCount,
168
+ delivery_held: collection.deliveryHeldCount,
169
+ }));
170
+ }
171
+ /**
172
+ * A crashed pass. The staged-object index is deliberately NOT written: this
173
+ * attempt's bookkeeping describes a directory that is about to be deleted.
174
+ */
175
+ export async function finishWithFailedPack(collection, run) {
176
+ collection.failed.push({
177
+ kind: "raw_evidence",
178
+ reason: "collection_failed",
179
+ });
180
+ // Staging is per-attempt scratch: a crashed pass must not leave a partial
181
+ // directory behind to be counted, re-hashed or swept later.
182
+ await discardStagingDir(run.stagingDir);
183
+ collection.packId =
184
+ collection.packId ||
185
+ contentKeyedRawEvidencePackId({
186
+ workContextId: collection.context.workContextId,
187
+ contentHashes: collection.entries.map((entry) => entry.content_hash_sha256),
188
+ });
189
+ console.error("[raw-evidence] pack collection failed", JSON.stringify({
190
+ pack_id: collection.packId,
191
+ reason: "collection_failed",
192
+ detail: run.error instanceof Error ? run.error.name : typeof run.error,
193
+ collected_file_count: collection.entries.length,
194
+ staged_new: collection.stagedNewCount,
195
+ staged_reused: collection.stagedReusedCount,
196
+ }));
197
+ const facts = makeEmptyPackFacts(collection, run.window);
198
+ return {
199
+ facts,
200
+ scan: makeRawEvidenceScan({
201
+ context: collection.context,
202
+ startedAt: run.window.startedAt,
203
+ status: "failed",
204
+ facts,
205
+ }),
206
+ };
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // What every ending reports
210
+ // ---------------------------------------------------------------------------
211
+ /**
212
+ * A pack with no files of its own: the empty pass and the crashed pass report
213
+ * the same shape, differing only in the scan status and the reason already
214
+ * logged. `evidence_completeness` still carries every gap this pass recorded.
215
+ */
216
+ function makeEmptyPackFacts(collection, scanWindow) {
217
+ const evidenceDir = path.join(collection.rawEvidenceRoot, collection.packId);
218
+ return {
219
+ ...packFactsShell(collection, {
220
+ packId: collection.packId,
221
+ manifestPath: path.join(evidenceDir, "manifest.json"),
222
+ evidenceDir,
223
+ window: scanWindow,
224
+ }),
225
+ stage_state: "empty",
226
+ file_count: 0,
227
+ byte_size: 0,
228
+ content_kinds: [],
229
+ pointers: [],
230
+ upload_files: [],
231
+ };
232
+ }
233
+ /** The counts every ending reports identically, however the pass ended. */
234
+ function packFactsShell(collection, place) {
235
+ return {
236
+ pack_id: place.packId,
237
+ manifest_path: place.manifestPath,
238
+ evidence_dir: place.evidenceDir,
239
+ storage_bucket: RAW_EVIDENCE_BUCKET,
240
+ skipped_count: countEvidenceEntries(collection.skipped),
241
+ sanitized_count: collection.redacted.length,
242
+ reused_count: collection.reused.length,
243
+ staged_reused_count: collection.stagedReusedCount,
244
+ staged_new_count: collection.stagedNewCount,
245
+ delivery_held_count: collection.deliveryHeldCount,
246
+ deferred_byte_budget_count: countDeferred(collection, "deferred_byte_budget"),
247
+ deferred_object_budget_count: countDeferred(collection, "deferred_object_budget"),
248
+ evidence_completeness: makeEvidenceCompleteness(collection, {
249
+ startedAt: place.window.startedAt,
250
+ finishedAt: place.window.finishedAt(),
251
+ sinceMinutes: place.window.sinceMinutes,
252
+ }),
253
+ reused: collection.reused,
254
+ };
255
+ }
256
+ function countDeferred(collection, reason) {
257
+ return countEvidenceEntries(collection.skipped, (entry) => entry.reason === reason);
258
+ }
259
+ function totalByteSize(entries) {
260
+ return entries.reduce((sum, entry) => sum + entry.byte_size, 0);
261
+ }
262
+ function uploadFileFromEntry(entry) {
263
+ return {
264
+ pointer: pointerFromEntry(entry),
265
+ local_path: entry.local_path,
266
+ kind: entry.kind,
267
+ codex_session_id: entry.codex_session_id ?? null,
268
+ ...(entry.artifact_metadata
269
+ ? { artifact_metadata: entry.artifact_metadata }
270
+ : {}),
271
+ };
272
+ }
273
+ function stageReasonLabel(state, priorPackCount) {
274
+ if (state === "reused")
275
+ return "staged_reused";
276
+ if (state === "restaged_incomplete")
277
+ return "restaged_incomplete";
278
+ return priorPackCount > 0 ? "restaged_content_changed" : "staged_new";
279
+ }
280
+ /**
281
+ * Point every entry this pass staged at its home in the promoted pack, and
282
+ * remember the content hash so the next sync can adopt the copy instead of
283
+ * writing it again. Entries adopted from another pack keep their path.
284
+ */
285
+ function rebaseStagedEntriesOntoPack(collection, evidenceDir) {
286
+ for (const entry of collection.entries) {
287
+ if (!entry.staged_in_pack)
288
+ continue;
289
+ entry.local_path = path.join(evidenceDir, "files", path.basename(entry.local_path));
290
+ recordStagedObject(collection.staging, entry.content_hash_sha256, {
291
+ pack_id: collection.packId,
292
+ relative_path: `files/${path.basename(entry.local_path)}`,
293
+ byte_size: entry.byte_size,
294
+ source_key: entry.source_key,
295
+ staged_at: collection.context.now.toISOString(),
296
+ });
297
+ }
298
+ }
299
+ function makeRawEvidenceScan(options) {
300
+ return SourceScanResultSchema.parse({
301
+ adapter: makeSourceAdapterIdentity("collector_runtime", "raw-evidence-pack"),
302
+ work_context_id: options.context.workContextId,
303
+ status: options.status,
304
+ started_at: options.startedAt,
305
+ finished_at: options.context.now.toISOString(),
306
+ events: [
307
+ {
308
+ source_event_id: `raw-evidence-pack:${options.facts.pack_id}`,
309
+ event_type: "raw_evidence_pack_written",
310
+ occurred_at: options.context.now.toISOString(),
311
+ redaction: {
312
+ privacy_classification: "remote_durable_raw_evidence",
313
+ redaction_status: "raw_remote_durable",
314
+ redacted_fields: ["local_path"],
315
+ raw_evidence_pointer_ids: options.facts.pointers.map((pointer) => pointer.raw_evidence_pointer_id),
316
+ redacted_summary: "Raw prompt/response/tool/session/diff evidence harvested to a private durable remote evidence pack.",
317
+ },
318
+ raw_evidence_pointers: options.facts.pointers,
319
+ },
320
+ ],
321
+ diagnostic_labels: [
322
+ `pack_id:${options.facts.pack_id}`,
323
+ `files:${options.facts.file_count}`,
324
+ `bytes:${options.facts.byte_size}`,
325
+ `skipped:${options.facts.skipped_count}`,
326
+ `reused:${options.facts.reused_count}`,
327
+ `stage_state:${options.facts.stage_state}`,
328
+ `staged_new:${options.facts.staged_new_count}`,
329
+ `staged_reused:${options.facts.staged_reused_count}`,
330
+ `delivery_held:${options.facts.delivery_held_count}`,
331
+ `completeness:${options.facts.evidence_completeness.status}`,
332
+ `truncated:${options.facts.evidence_completeness.totals.truncated_count}`,
333
+ `deferred:${options.facts.evidence_completeness.totals.deferred_count}`,
334
+ `failed:${options.facts.evidence_completeness.totals.failed_count}`,
335
+ ...options.facts.content_kinds.map((kind) => `kind:${kind}`),
336
+ ],
337
+ });
338
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The other half of the evidence of what someone actually changed: the working
3
+ * tree's own two diffs, unstaged and staged.
4
+ *
5
+ * `raw-evidence-git-diff.ts` runs git and enforces the pathspec and the caps;
6
+ * this is the pack side — the scanned counts, the named gaps, masking, the
7
+ * budget, and the manifest entry. Neither target is switchable off by the
8
+ * caller, because a transcript without its diff describes work nobody can
9
+ * check.
10
+ *
11
+ * A missing repo root is a fact about the folder, not a fault in git
12
+ * (BLI-3551): it is asked once per pass, costs one log line, and still gives
13
+ * both targets their own named gap.
14
+ */
15
+ import path from "node:path";
16
+ import { describeError } from "../health-detail.js";
17
+ import { evidenceSourceKey } from "../raw-evidence-staging.js";
18
+ import { sha256 } from "./raw-evidence-keys.js";
19
+ import { maskSecretsInGitDiff, } from "./raw-evidence-sanitize.js";
20
+ import { markBudgetCapApplied, markCapApplied, recordScanned, } from "./raw-evidence-completeness.js";
21
+ import { evidenceEntry } from "./raw-evidence-manifest.js";
22
+ import { admitToBudget, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
23
+ import { MAX_GIT_DIFF_BYTES, REPO_ROOT_MISSING_REASON, RepoRootMissingError, repoRootExists, runGitDiff, } from "./raw-evidence-git-diff.js";
24
+ const GIT_DIFF_TARGETS = [
25
+ { label: "unstaged", args: ["diff", "--no-ext-diff", "--"] },
26
+ { label: "staged", args: ["diff", "--cached", "--no-ext-diff", "--"] },
27
+ ];
28
+ export async function collectGitDiffFiles(collection, repoRoot) {
29
+ if (!(await repoRootExists(repoRoot))) {
30
+ skipEveryDiffTargetForMissingRoot(collection);
31
+ return;
32
+ }
33
+ for (const target of GIT_DIFF_TARGETS) {
34
+ recordScanned(collection, "git_diff");
35
+ const diff = await runOneGitDiff(collection, target, repoRoot);
36
+ if (!diff)
37
+ continue;
38
+ if (diff.truncated)
39
+ recordGitDiffTruncation(collection, diff);
40
+ // An empty diff is not a gap: there was simply nothing to record. The
41
+ // truncation marker above still stands even when zero bytes survived.
42
+ if (!diff.stdout.trim())
43
+ continue;
44
+ await stageOneGitDiff(collection, {
45
+ label: target.label,
46
+ diffText: diff.stdout,
47
+ truncated: diff.truncated,
48
+ });
49
+ }
50
+ }
51
+ /**
52
+ * BLI-3551: the root is asked about once per pass, before either target, so a
53
+ * pruned worktree costs ONE line instead of one per diff target per session per
54
+ * tick — and the line says the folder is gone rather than accusing git of
55
+ * failing. Both targets still get their own named gap.
56
+ */
57
+ function skipEveryDiffTargetForMissingRoot(collection) {
58
+ console.error("[raw-evidence] git diff skipped, repository root is no longer on disk", JSON.stringify({
59
+ reason: REPO_ROOT_MISSING_REASON,
60
+ diff_targets_skipped: GIT_DIFF_TARGETS.length,
61
+ next_action: "nothing to do; the diff returns when the worktree is restored or the session ages out",
62
+ }));
63
+ for (const target of GIT_DIFF_TARGETS) {
64
+ recordScanned(collection, "git_diff");
65
+ collection.skipped.push({
66
+ kind: "git_diff",
67
+ label: target.label,
68
+ reason: REPO_ROOT_MISSING_REASON,
69
+ });
70
+ }
71
+ }
72
+ /**
73
+ * Run one diff, or record why it produced nothing. Null means the gap is
74
+ * already named in the ledger, so the caller only has to move on.
75
+ */
76
+ async function runOneGitDiff(collection, target, repoRoot) {
77
+ try {
78
+ return await runGitDiff(target.args, repoRoot);
79
+ }
80
+ catch (error) {
81
+ // The root was there a moment ago and is not now (or a second collector
82
+ // pruned it mid-tick). Same named outcome, still not a git failure.
83
+ if (error instanceof RepoRootMissingError) {
84
+ collection.skipped.push({
85
+ kind: "git_diff",
86
+ label: target.label,
87
+ reason: REPO_ROOT_MISSING_REASON,
88
+ });
89
+ return null;
90
+ }
91
+ // `git_diff_failed` is the skip label and stays. It covers git not being
92
+ // installed, the folder not being a repo, a locked index and a diff that
93
+ // exceeded the child-process buffer — and the diff is half the evidence
94
+ // for what someone actually changed, so losing it quietly matters.
95
+ console.error("[raw-evidence] git diff failed", JSON.stringify({
96
+ reason: "git_diff_failed",
97
+ diff_target: target.label,
98
+ ...describeError(error),
99
+ }));
100
+ collection.skipped.push({
101
+ kind: "git_diff",
102
+ label: target.label,
103
+ reason: "git_diff_failed",
104
+ });
105
+ return null;
106
+ }
107
+ }
108
+ /** A diff that hit its size or time cap is partial evidence, and says so. */
109
+ function recordGitDiffTruncation(collection, diff) {
110
+ markCapApplied(collection, "git_diff", diff.truncationCapType);
111
+ collection.truncated.push({
112
+ kind: "git_diff",
113
+ reason: diff.truncationReason,
114
+ ...(diff.truncationCapType === "max_bytes_per_diff"
115
+ ? { max_bytes: MAX_GIT_DIFF_BYTES }
116
+ : {}),
117
+ observed_bytes: diff.observedBytes,
118
+ included_bytes: Buffer.byteLength(diff.stdout, "utf8"),
119
+ });
120
+ }
121
+ async function stageOneGitDiff(collection, target) {
122
+ const sanitized = maskSecretsInGitDiff(collection, target);
123
+ const contentHash = sha256(sanitized.bytes);
124
+ if (collection.skipContentHashes.has(contentHash)) {
125
+ collection.reused.push({
126
+ kind: "git_diff",
127
+ label: target.label,
128
+ content_hash_sha256: contentHash,
129
+ codex_session_id: null,
130
+ });
131
+ return;
132
+ }
133
+ const deferReason = admitToBudget(collection.budget, sanitized.bytes.byteLength);
134
+ if (deferReason) {
135
+ markBudgetCapApplied(collection, deferReason);
136
+ collection.skipped.push({
137
+ kind: "git_diff",
138
+ label: target.label,
139
+ reason: deferReason,
140
+ });
141
+ return;
142
+ }
143
+ await writeGitDiffToPack(collection, { target, sanitized, contentHash });
144
+ }
145
+ /** Put an accepted diff's bytes on disk and add its manifest entry. */
146
+ async function writeGitDiffToPack(collection, diff) {
147
+ const label = diff.target.label;
148
+ const relativePath = path.join("files", `git-${label}.diff`);
149
+ const diffSourceKey = evidenceSourceKey({
150
+ kind: "git_diff",
151
+ sessionId: collection.context.workContextId,
152
+ label,
153
+ });
154
+ const staged = await stageEvidenceBytes(collection, {
155
+ contentHash: diff.contentHash,
156
+ bytes: diff.sanitized.bytes,
157
+ fileName: path.basename(relativePath),
158
+ kind: "git_diff",
159
+ sourceKey: diffSourceKey,
160
+ });
161
+ collection.entries.push(evidenceEntry({
162
+ context: collection.context,
163
+ kind: "git_diff",
164
+ packId: collection.packId,
165
+ localPath: staged.local_path,
166
+ relativePath,
167
+ stagedInPack: staged.staged_in_pack,
168
+ sourceKey: diffSourceKey,
169
+ mediaType: "text/x-diff",
170
+ redactedSummary: gitDiffSummary(label, {
171
+ redacted: diff.sanitized.status === "redacted",
172
+ truncated: diff.target.truncated,
173
+ }),
174
+ redaction: diff.sanitized.redaction,
175
+ bytes: diff.sanitized.bytes,
176
+ contentAddress: `git-diff/${label}-${diff.contentHash.slice(0, 16)}.diff`,
177
+ }));
178
+ }
179
+ function gitDiffSummary(label, state) {
180
+ if (state.redacted) {
181
+ return `Raw git ${label} diff preserved locally with env/secret paths excluded and secret-like values deterministically redacted.`;
182
+ }
183
+ if (state.truncated) {
184
+ return `Raw git ${label} diff truncated to the capture cap and preserved locally with env/secret paths excluded.`;
185
+ }
186
+ return `Raw git ${label} diff preserved locally with env/secret paths excluded.`;
187
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Images a person explicitly attached to an agent session, and only those.
3
+ *
4
+ * `agent-image-evidence.ts` decides what counts as an attachment and hands back
5
+ * decoded bytes; this is the pack side of that split — scanned counts, named
6
+ * skips, the reuse and budget checks, and the manifest entry. An image is only
7
+ * ever collected after its transcript was accepted, so a session that was
8
+ * skipped cannot leak its screenshots.
9
+ */
10
+ import path from "node:path";
11
+ import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
12
+ import { evidenceSourceKey } from "../raw-evidence-staging.js";
13
+ import { sha256 } from "./raw-evidence-keys.js";
14
+ import { markBudgetCapApplied, recordScanned, } from "./raw-evidence-completeness.js";
15
+ import { evidenceEntry } from "./raw-evidence-manifest.js";
16
+ import { admitToBudget, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
17
+ export async function collectAgentImagesFromTranscript(collection, options) {
18
+ const result = await collectAgentImageEvidenceFromJsonlFile({
19
+ filePath: options.filePath,
20
+ source: options.source,
21
+ sessionId: options.sessionId,
22
+ sidecarId: options.sidecarId,
23
+ });
24
+ recordScanned(collection, options.kind, result.images.length + result.skipped.length);
25
+ for (const skipped of result.skipped) {
26
+ collection.skipped.push({
27
+ kind: options.kind,
28
+ label: skipped.label,
29
+ reason: skipped.reason,
30
+ });
31
+ }
32
+ for (const image of result.images) {
33
+ await collectOneAgentImageFile(collection, {
34
+ image,
35
+ kind: options.kind,
36
+ sessionId: options.sessionId,
37
+ contentAddress: options.contentAddress,
38
+ });
39
+ }
40
+ }
41
+ async function collectOneAgentImageFile(collection, options) {
42
+ const raw = options.image.bytes;
43
+ const contentHash = sha256(raw);
44
+ const metadata = {
45
+ ...options.image.metadata,
46
+ content_hash_sha256: contentHash,
47
+ byte_size: raw.byteLength,
48
+ };
49
+ if (collection.skipContentHashes.has(contentHash)) {
50
+ collection.reused.push({
51
+ kind: options.kind,
52
+ label: options.image.label,
53
+ content_hash_sha256: contentHash,
54
+ codex_session_id: options.sessionId,
55
+ artifact_metadata: metadata,
56
+ });
57
+ return;
58
+ }
59
+ const deferReason = admitToBudget(collection.budget, raw.byteLength);
60
+ if (deferReason) {
61
+ markBudgetCapApplied(collection, deferReason);
62
+ collection.skipped.push({
63
+ kind: options.kind,
64
+ label: options.image.label,
65
+ reason: deferReason,
66
+ });
67
+ return;
68
+ }
69
+ await writeAgentImageToPack(collection, {
70
+ ...options,
71
+ contentHash,
72
+ metadata,
73
+ });
74
+ }
75
+ /** Put an accepted image's bytes on disk and add its manifest entry. */
76
+ async function writeAgentImageToPack(collection, image) {
77
+ const raw = image.image.bytes;
78
+ collection.index.value += 1;
79
+ const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${image.contentHash.slice(0, 16)}.${image.image.extension}`);
80
+ const imageSourceKey = evidenceSourceKey({
81
+ kind: image.kind,
82
+ sessionId: image.sessionId,
83
+ label: image.image.label,
84
+ });
85
+ const staged = await stageEvidenceBytes(collection, {
86
+ contentHash: image.contentHash,
87
+ bytes: raw,
88
+ fileName: path.basename(relativePath),
89
+ kind: image.kind,
90
+ sourceKey: imageSourceKey,
91
+ });
92
+ collection.entries.push(evidenceEntry({
93
+ context: collection.context,
94
+ kind: image.kind,
95
+ packId: collection.packId,
96
+ localPath: staged.local_path,
97
+ relativePath,
98
+ stagedInPack: staged.staged_in_pack,
99
+ sourceKey: imageSourceKey,
100
+ mediaType: image.metadata.media_type,
101
+ redactedSummary: "Raw image explicitly attached to an agent session, preserved in private durable storage.",
102
+ bytes: raw,
103
+ codexSessionId: image.sessionId,
104
+ contentAddress: image.contentAddress(image.contentHash.slice(0, 16), image.image.extension),
105
+ artifactMetadata: image.metadata,
106
+ }));
107
+ }
@@ -205,4 +205,60 @@ function withRedactionContentMetadata(metadata, options) {
205
205
  original_byte_size: options.originalBytes.byteLength,
206
206
  sanitized_byte_size: options.sanitizedBytes.byteLength,
207
207
  };
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // Masking with a receipt
211
+ // ---------------------------------------------------------------------------
212
+ /**
213
+ * Mask secret-like values in a transcript and record that it happened, because
214
+ * masking is invisible in the uploaded bytes and an operator has to be able to
215
+ * see that this file went up altered.
216
+ */
217
+ export function maskSecretsInTranscript(collection, options) {
218
+ const sanitized = sanitizeTextEvidenceForUpload({
219
+ text: options.raw.toString("utf8"),
220
+ originalBytes: options.raw,
221
+ redactedFields: [`${options.kind}.body`],
222
+ secretLikeFileName: options.secretLikeFileName,
223
+ });
224
+ if (sanitized.status === "redacted") {
225
+ collection.redacted.push({
226
+ kind: options.kind,
227
+ label: options.evidenceLabel,
228
+ redaction: sanitized.redaction,
229
+ completenessLabel: sanitized.completenessLabel,
230
+ });
231
+ console.error("[raw-evidence] text evidence sanitized", JSON.stringify({
232
+ kind: options.kind,
233
+ mode: sanitized.completenessLabel,
234
+ original_bytes: options.raw.byteLength,
235
+ uploaded_bytes: sanitized.bytes.byteLength,
236
+ }));
237
+ }
238
+ return sanitized;
239
+ }
240
+ /**
241
+ * Mask secret-like values in a diff and record that it happened. A diff carries
242
+ * whatever a person pasted into a config file, so this is the branch that most
243
+ * often fires — and an operator has to see that the bytes went up altered.
244
+ */
245
+ export function maskSecretsInGitDiff(collection, target) {
246
+ const sanitized = sanitizeTextEvidenceForUpload({
247
+ text: target.diffText,
248
+ redactedFields: [`git_diff.${target.label}`],
249
+ });
250
+ if (sanitized.status === "redacted") {
251
+ collection.redacted.push({
252
+ kind: "git_diff",
253
+ label: target.label,
254
+ redaction: sanitized.redaction,
255
+ completenessLabel: sanitized.completenessLabel,
256
+ });
257
+ console.error("[raw-evidence] git diff sanitized", JSON.stringify({
258
+ mode: sanitized.completenessLabel,
259
+ original_bytes: Buffer.byteLength(target.diffText, "utf8"),
260
+ uploaded_bytes: sanitized.bytes.byteLength,
261
+ }));
262
+ }
263
+ return sanitized;
208
264
  }