@bli-cockpit/cli 0.1.19 → 0.1.21

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.
@@ -1,16 +1,15 @@
1
- import { SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
- import { execFile } from "node:child_process";
1
+ import { EvidenceCompletenessPayloadSchema, SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
+ import { spawn } from "node:child_process";
3
3
  import crypto from "node:crypto";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { promisify } from "node:util";
8
7
  import { makeSourceAdapterIdentity, } from "./common.js";
9
8
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
10
- const execFileAsync = promisify(execFile);
11
9
  const DEFAULT_SINCE_MINUTES = 24 * 60;
12
10
  const DEFAULT_SESSION_LIMIT = 50;
13
11
  const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
12
+ const GIT_DIFF_TIMEOUT_MS = 3_000;
14
13
  export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
15
14
  export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
16
15
  // Per-sync upload budgets enforced at COLLECTION time (D7b). With sidecars a
@@ -28,39 +27,97 @@ export async function collectRawEvidencePack(context, options) {
28
27
  const filesDir = path.join(evidenceDir, "files");
29
28
  const entries = [];
30
29
  const skipped = [];
30
+ const truncated = [];
31
+ const failed = [];
31
32
  const reused = [];
33
+ const sinceMinutes = options.sinceMinutes ?? DEFAULT_SINCE_MINUTES;
34
+ const sessionLimit = options.sessionLimit ?? DEFAULT_SESSION_LIMIT;
35
+ const byteBudget = options.byteBudget ?? RAW_EVIDENCE_DEFAULT_BYTE_BUDGET;
36
+ const objectBudget = options.objectBudget ?? RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET;
32
37
  const collection = {
33
38
  context,
34
39
  filesDir,
35
40
  packId,
36
41
  entries,
37
42
  skipped,
43
+ truncated,
44
+ failed,
38
45
  reused,
46
+ scanned: new Map(),
47
+ caps: [
48
+ {
49
+ source: "raw_evidence",
50
+ cap_type: "byte_budget",
51
+ limit: byteBudget,
52
+ observed: options.budget?.remainingBytes ?? byteBudget,
53
+ applied: false,
54
+ },
55
+ {
56
+ source: "raw_evidence",
57
+ cap_type: "object_budget",
58
+ limit: objectBudget,
59
+ observed: options.budget?.remainingObjects ?? objectBudget,
60
+ applied: false,
61
+ },
62
+ {
63
+ source: "git_diff",
64
+ cap_type: "max_bytes_per_diff",
65
+ limit: MAX_GIT_DIFF_BYTES,
66
+ applied: false,
67
+ },
68
+ {
69
+ source: "git_diff",
70
+ cap_type: "timeout_ms",
71
+ limit: GIT_DIFF_TIMEOUT_MS,
72
+ applied: false,
73
+ },
74
+ {
75
+ source: "claude_jsonl",
76
+ cap_type: "max_file_bytes",
77
+ limit: CLAUDE_MAX_COLLECT_FILE_BYTES,
78
+ applied: false,
79
+ },
80
+ {
81
+ source: "claude_jsonl_sidecar",
82
+ cap_type: "max_file_bytes",
83
+ limit: CLAUDE_MAX_COLLECT_FILE_BYTES,
84
+ applied: false,
85
+ },
86
+ ],
39
87
  skipContentHashes: options.skipContentHashes ?? new Set(),
40
88
  budget: options.budget ?? {
41
- remainingBytes: options.byteBudget ?? RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
42
- remainingObjects: options.objectBudget ?? RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
89
+ remainingBytes: byteBudget,
90
+ remainingObjects: objectBudget,
43
91
  },
44
92
  index: { value: 0 },
45
93
  };
46
94
  try {
47
95
  await ensurePrivateDir(evidenceDir);
48
96
  await ensurePrivateDir(filesDir);
97
+ recordAttributionCompleteness(collection, {
98
+ codex: options.codexAttributionScan,
99
+ claude: options.claudeAttributionScan,
100
+ });
49
101
  if (options.includeCodexJsonl !== false) {
50
102
  await collectCodexJsonlFiles(collection, {
51
103
  codexSessionFiles: options.codexSessionFiles,
52
104
  sessionsDir: options.sessionsDir,
53
- sinceMinutes: options.sinceMinutes ?? DEFAULT_SINCE_MINUTES,
54
- limit: options.sessionLimit ?? DEFAULT_SESSION_LIMIT,
105
+ sinceMinutes,
106
+ limit: sessionLimit,
55
107
  });
56
108
  }
57
109
  if (options.includeClaudeJsonl !== false && options.claudeSessionFiles) {
58
110
  await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
59
111
  }
60
112
  await collectGitDiffFiles(collection, options.repoRoot);
61
- const deferredByteBudgetCount = skipped.filter((entry) => entry.reason === "deferred_byte_budget").length;
62
- const deferredObjectBudgetCount = skipped.filter((entry) => entry.reason === "deferred_object_budget").length;
113
+ const deferredByteBudgetCount = skipped.filter((entry) => entry.reason === "deferred_byte_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
114
+ const deferredObjectBudgetCount = skipped.filter((entry) => entry.reason === "deferred_object_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
63
115
  if (entries.length === 0) {
116
+ const evidenceCompleteness = makeEvidenceCompleteness(collection, {
117
+ startedAt,
118
+ finishedAt: context.now.toISOString(),
119
+ sinceMinutes,
120
+ });
64
121
  const facts = {
65
122
  pack_id: packId,
66
123
  manifest_path: path.join(evidenceDir, "manifest.json"),
@@ -68,11 +125,12 @@ export async function collectRawEvidencePack(context, options) {
68
125
  storage_bucket: RAW_EVIDENCE_BUCKET,
69
126
  file_count: 0,
70
127
  byte_size: 0,
71
- skipped_count: skipped.length,
128
+ skipped_count: countEvidenceEntries(skipped),
72
129
  reused_count: reused.length,
73
130
  deferred_byte_budget_count: deferredByteBudgetCount,
74
131
  deferred_object_budget_count: deferredObjectBudgetCount,
75
132
  content_kinds: [],
133
+ evidence_completeness: evidenceCompleteness,
76
134
  pointers: [],
77
135
  upload_files: [],
78
136
  reused,
@@ -109,6 +167,11 @@ export async function collectRawEvidencePack(context, options) {
109
167
  bytes: manifestBytes,
110
168
  });
111
169
  entries.push(manifestEntry);
170
+ const evidenceCompleteness = makeEvidenceCompleteness(collection, {
171
+ startedAt,
172
+ finishedAt: context.now.toISOString(),
173
+ sinceMinutes,
174
+ });
112
175
  const facts = {
113
176
  pack_id: packId,
114
177
  manifest_path: manifestPath,
@@ -116,11 +179,12 @@ export async function collectRawEvidencePack(context, options) {
116
179
  storage_bucket: RAW_EVIDENCE_BUCKET,
117
180
  file_count: entries.length,
118
181
  byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
119
- skipped_count: skipped.length,
182
+ skipped_count: countEvidenceEntries(skipped),
120
183
  reused_count: reused.length,
121
184
  deferred_byte_budget_count: deferredByteBudgetCount,
122
185
  deferred_object_budget_count: deferredObjectBudgetCount,
123
186
  content_kinds: [...new Set(entries.map((entry) => entry.kind))],
187
+ evidence_completeness: evidenceCompleteness,
124
188
  pointers: entries.map(pointerFromEntry),
125
189
  upload_files: entries.map((entry) => ({
126
190
  pointer: pointerFromEntry(entry),
@@ -143,18 +207,46 @@ export async function collectRawEvidencePack(context, options) {
143
207
  }),
144
208
  };
145
209
  }
146
- catch (error) {
147
- const scan = SourceScanResultSchema.parse({
148
- adapter: makeSourceAdapterIdentity("collector_runtime", "raw-evidence-pack"),
149
- work_context_id: context.workContextId,
150
- status: "failed",
151
- started_at: startedAt,
152
- finished_at: context.now.toISOString(),
153
- diagnostic_labels: [
154
- `raw_evidence_failed:${error instanceof Error ? error.message : String(error)}`,
155
- ],
210
+ catch {
211
+ failed.push({
212
+ kind: "raw_evidence",
213
+ reason: "collection_failed",
156
214
  });
157
- return { scan, facts: null };
215
+ const evidenceCompleteness = makeEvidenceCompleteness(collection, {
216
+ startedAt,
217
+ finishedAt: context.now.toISOString(),
218
+ sinceMinutes,
219
+ });
220
+ const facts = {
221
+ pack_id: packId,
222
+ manifest_path: path.join(evidenceDir, "manifest.json"),
223
+ evidence_dir: evidenceDir,
224
+ storage_bucket: RAW_EVIDENCE_BUCKET,
225
+ file_count: 0,
226
+ byte_size: 0,
227
+ skipped_count: countEvidenceEntries(skipped),
228
+ reused_count: reused.length,
229
+ deferred_byte_budget_count: skipped
230
+ .filter((entry) => entry.reason === "deferred_byte_budget")
231
+ .reduce((sum, entry) => sum + evidenceEntryCount(entry), 0),
232
+ deferred_object_budget_count: skipped
233
+ .filter((entry) => entry.reason === "deferred_object_budget")
234
+ .reduce((sum, entry) => sum + evidenceEntryCount(entry), 0),
235
+ content_kinds: [],
236
+ evidence_completeness: evidenceCompleteness,
237
+ pointers: [],
238
+ upload_files: [],
239
+ reused,
240
+ };
241
+ return {
242
+ facts,
243
+ scan: makeRawEvidenceScan({
244
+ context,
245
+ startedAt,
246
+ status: "failed",
247
+ facts,
248
+ }),
249
+ };
158
250
  }
159
251
  }
160
252
  function makeRawEvidenceScan(options) {
@@ -185,20 +277,128 @@ function makeRawEvidenceScan(options) {
185
277
  `bytes:${options.facts.byte_size}`,
186
278
  `skipped:${options.facts.skipped_count}`,
187
279
  `reused:${options.facts.reused_count}`,
280
+ `completeness:${options.facts.evidence_completeness.status}`,
281
+ `truncated:${options.facts.evidence_completeness.totals.truncated_count}`,
282
+ `deferred:${options.facts.evidence_completeness.totals.deferred_count}`,
283
+ `failed:${options.facts.evidence_completeness.totals.failed_count}`,
188
284
  ...options.facts.content_kinds.map((kind) => `kind:${kind}`),
189
285
  ],
190
286
  });
191
287
  }
288
+ function recordAttributionCompleteness(collection, scans) {
289
+ if (scans.codex)
290
+ recordCodexAttributionCompleteness(collection, scans.codex);
291
+ if (scans.claude)
292
+ recordClaudeAttributionCompleteness(collection, scans.claude);
293
+ }
294
+ function recordCodexAttributionCompleteness(collection, scan) {
295
+ recordScanned(collection, "codex_attribution", scan.scanned_file_count);
296
+ collection.caps.push({
297
+ source: "codex_attribution",
298
+ cap_type: "scan_window_minutes",
299
+ limit: scan.since_minutes,
300
+ observed: scan.since_minutes,
301
+ applied: false,
302
+ }, {
303
+ source: "codex_attribution",
304
+ cap_type: "session_limit",
305
+ limit: scan.session_limit,
306
+ observed: scan.discovered_file_count,
307
+ applied: scan.session_limit_applied,
308
+ }, {
309
+ source: "codex_attribution",
310
+ cap_type: "max_file_bytes",
311
+ limit: scan.max_file_bytes,
312
+ applied: scan.results.some((result) => result.reason === "file_too_large"),
313
+ });
314
+ recordSkipCount(collection, "codex_attribution", "session_limit_overflow", Math.max(0, scan.discovered_file_count - scan.scanned_file_count));
315
+ recordSkipCount(collection, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
316
+ recordSkipCount(collection, "codex_attribution", "file_stat_failed", scan.stat_failed_count);
317
+ recordSkipCount(collection, "codex_attribution", "secret_like_directory", scan.secret_path_skipped_count);
318
+ recordAttributionResultSkips(collection, "codex_attribution", scan.results);
319
+ }
320
+ function recordClaudeAttributionCompleteness(collection, scan) {
321
+ recordScanned(collection, "claude_attribution", scan.scanned_session_count);
322
+ if (scan.disabled_reason) {
323
+ recordSkipCount(collection, "claude_attribution", scan.disabled_reason, 1);
324
+ }
325
+ collection.caps.push({
326
+ source: "claude_attribution",
327
+ cap_type: "scan_window_minutes",
328
+ limit: scan.since_minutes,
329
+ observed: scan.since_minutes,
330
+ applied: false,
331
+ }, {
332
+ source: "claude_attribution",
333
+ cap_type: "session_limit",
334
+ limit: scan.session_limit,
335
+ observed: scan.discovered_session_count,
336
+ applied: scan.session_limit_applied,
337
+ }, {
338
+ source: "claude_attribution",
339
+ cap_type: "max_file_bytes",
340
+ limit: scan.max_file_bytes,
341
+ applied: scan.counts.mains_oversized > 0 ||
342
+ scan.results.some((result) => result.reason === "file_too_large"),
343
+ }, {
344
+ source: "claude_attribution",
345
+ cap_type: "max_sidecar_files",
346
+ limit: scan.max_sidecar_files,
347
+ observed: scan.max_sidecar_files + scan.counts.sidecars_capped,
348
+ applied: scan.counts.sidecars_capped > 0,
349
+ }, {
350
+ source: "claude_attribution",
351
+ cap_type: "max_line_buffer_bytes",
352
+ limit: scan.max_line_buffer_bytes,
353
+ applied: scan.counts.oversized_lines_skipped > 0,
354
+ });
355
+ recordSkipCount(collection, "claude_attribution", "session_limit_overflow", Math.max(0, scan.discovered_session_count - scan.scanned_session_count));
356
+ recordSkipCount(collection, "claude_attribution", "secret_like_project_dir", scan.project_dirs_skipped);
357
+ recordSkipCount(collection, "claude_attribution", "project_dir_read_failed", scan.project_dir_read_failed_count);
358
+ recordSkipCount(collection, "claude_attribution", "session_stat_failed", scan.session_stat_failed_count);
359
+ recordSkipCount(collection, "claude_attribution", "sidecar_dir_read_failed", scan.sidecar_dir_read_failed_count);
360
+ recordSkipCount(collection, "claude_attribution", "sidecar_stat_failed", scan.sidecar_stat_failed_count);
361
+ recordSkipCount(collection, "claude_attribution", "sidecar_limit_overflow", scan.counts.sidecars_capped);
362
+ recordTruncationCount(collection, "claude_attribution", "oversized_jsonl_line", scan.counts.oversized_lines_skipped, { max_bytes: scan.max_line_buffer_bytes });
363
+ recordAttributionResultSkips(collection, "claude_attribution", scan.results);
364
+ for (const result of scan.results) {
365
+ for (const sidecar of result.sidecar_files) {
366
+ if (!sidecar.skipped_reason)
367
+ continue;
368
+ recordSkipCount(collection, "claude_attribution", `sidecar_${sidecar.skipped_reason}`, 1);
369
+ }
370
+ }
371
+ }
372
+ function recordAttributionResultSkips(collection, source, results) {
373
+ for (const result of results) {
374
+ if (result.state === "attributed")
375
+ continue;
376
+ const reason = result.state === "skipped"
377
+ ? result.reason
378
+ : `attribution_${result.state}:${result.reason}`;
379
+ recordSkipCount(collection, source, reason, 1);
380
+ }
381
+ }
192
382
  async function collectCodexJsonlFiles(collection, options) {
193
- const candidates = options.codexSessionFiles
383
+ const resolvedCandidates = options.codexSessionFiles
194
384
  ? options.codexSessionFiles.map((file) => ({
195
385
  filePath: file.local_path,
196
386
  codexSessionId: file.codex_session_id,
197
387
  }))
198
388
  : (await walkJsonlFiles(options.sessionsDir ?? path.join(os.homedir(), ".codex", "sessions"), collection.context.now.getTime() - options.sinceMinutes * 60 * 1000))
199
- .slice(0, options.limit)
200
389
  .map((filePath) => ({ filePath, codexSessionId: null }));
390
+ collection.caps.push({
391
+ source: "codex_jsonl",
392
+ cap_type: "session_limit",
393
+ limit: options.limit,
394
+ observed: resolvedCandidates.length,
395
+ applied: !options.codexSessionFiles && resolvedCandidates.length > options.limit,
396
+ });
397
+ const candidates = options.codexSessionFiles
398
+ ? resolvedCandidates
399
+ : resolvedCandidates.slice(0, options.limit);
201
400
  for (const candidate of candidates) {
401
+ recordScanned(collection, "codex_jsonl");
202
402
  const codexSessionId = candidate.codexSessionId ?? shortHash(candidate.filePath);
203
403
  const transcriptAccepted = await collectOneEvidenceFile(collection, {
204
404
  filePath: candidate.filePath,
@@ -220,6 +420,7 @@ async function collectCodexJsonlFiles(collection, options) {
220
420
  }
221
421
  }
222
422
  async function collectClaudeJsonlFiles(collection, sessions) {
423
+ recordScanned(collection, "claude_jsonl", sessions.reduce((count, session) => count + 1 + session.sidecar_files.length, 0));
223
424
  for (const session of sessions) {
224
425
  const sessionId = session.claude_session_id;
225
426
  if (session.main_file_oversized) {
@@ -300,6 +501,7 @@ async function collectAgentImagesFromTranscript(collection, options) {
300
501
  sessionId: options.sessionId,
301
502
  sidecarId: options.sidecarId,
302
503
  });
504
+ recordScanned(collection, options.kind, result.images.length + result.skipped.length);
303
505
  for (const skipped of result.skipped) {
304
506
  collection.skipped.push({
305
507
  kind: options.kind,
@@ -336,6 +538,7 @@ async function collectOneAgentImageFile(collection, options) {
336
538
  }
337
539
  const deferReason = admitToBudget(collection.budget, raw.byteLength);
338
540
  if (deferReason) {
541
+ markBudgetCapApplied(collection, deferReason);
339
542
  collection.skipped.push({
340
543
  kind: options.kind,
341
544
  label: options.image.label,
@@ -392,6 +595,7 @@ async function collectOneEvidenceFile(collection, options) {
392
595
  return false;
393
596
  }
394
597
  if (options.maxFileBytes && raw.byteLength > options.maxFileBytes) {
598
+ markCapApplied(collection, options.kind, "max_file_bytes");
395
599
  collection.skipped.push({
396
600
  kind: options.kind,
397
601
  label: fileName,
@@ -419,6 +623,7 @@ async function collectOneEvidenceFile(collection, options) {
419
623
  }
420
624
  const deferReason = admitToBudget(collection.budget, raw.byteLength);
421
625
  if (deferReason) {
626
+ markBudgetCapApplied(collection, deferReason);
422
627
  collection.skipped.push({
423
628
  kind: options.kind,
424
629
  label: fileName,
@@ -465,10 +670,34 @@ async function collectGitDiffFiles(collection, repoRoot) {
465
670
  { label: "staged", args: ["diff", "--cached", "--no-ext-diff", "--"] },
466
671
  ];
467
672
  for (const target of diffTargets) {
468
- const diff = await runGitDiff(target.args, repoRoot);
469
- if (!diff.trim())
673
+ recordScanned(collection, "git_diff");
674
+ let diff;
675
+ try {
676
+ diff = await runGitDiff(target.args, repoRoot);
677
+ }
678
+ catch {
679
+ collection.skipped.push({
680
+ kind: "git_diff",
681
+ label: target.label,
682
+ reason: "git_diff_failed",
683
+ });
684
+ continue;
685
+ }
686
+ if (diff.truncated) {
687
+ markCapApplied(collection, "git_diff", diff.truncationCapType);
688
+ collection.truncated.push({
689
+ kind: "git_diff",
690
+ reason: diff.truncationReason,
691
+ ...(diff.truncationCapType === "max_bytes_per_diff"
692
+ ? { max_bytes: MAX_GIT_DIFF_BYTES }
693
+ : {}),
694
+ observed_bytes: diff.observedBytes,
695
+ included_bytes: Buffer.byteLength(diff.stdout, "utf8"),
696
+ });
697
+ }
698
+ if (!diff.stdout.trim())
470
699
  continue;
471
- if (containsSecretLikeContent(diff)) {
700
+ if (containsSecretLikeContent(diff.stdout)) {
472
701
  collection.skipped.push({
473
702
  kind: "git_diff",
474
703
  label: target.label,
@@ -476,7 +705,7 @@ async function collectGitDiffFiles(collection, repoRoot) {
476
705
  });
477
706
  continue;
478
707
  }
479
- const raw = Buffer.from(diff.slice(0, MAX_GIT_DIFF_BYTES), "utf8");
708
+ const raw = Buffer.from(diff.stdout, "utf8");
480
709
  const contentHash = sha256(raw);
481
710
  if (collection.skipContentHashes.has(contentHash)) {
482
711
  collection.reused.push({
@@ -489,6 +718,7 @@ async function collectGitDiffFiles(collection, repoRoot) {
489
718
  }
490
719
  const deferReason = admitToBudget(collection.budget, raw.byteLength);
491
720
  if (deferReason) {
721
+ markBudgetCapApplied(collection, deferReason);
492
722
  collection.skipped.push({
493
723
  kind: "git_diff",
494
724
  label: target.label,
@@ -507,7 +737,9 @@ async function collectGitDiffFiles(collection, repoRoot) {
507
737
  localPath: destination,
508
738
  relativePath,
509
739
  mediaType: "text/x-diff",
510
- redactedSummary: `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
740
+ redactedSummary: diff.truncated
741
+ ? `Raw git ${target.label} diff truncated to the capture cap and preserved locally with env/secret paths excluded.`
742
+ : `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
511
743
  bytes: raw,
512
744
  contentAddress: `git-diff/${target.label}-${contentHash.slice(0, 16)}.diff`,
513
745
  }));
@@ -526,12 +758,232 @@ async function runGitDiff(args, repoRoot) {
526
758
  ":(exclude)**/*.pem",
527
759
  ":(exclude)**/*.key",
528
760
  ];
529
- const { stdout } = await execFileAsync("git", [...args, ...pathspec], {
530
- cwd: repoRoot,
531
- timeout: 3_000,
532
- maxBuffer: MAX_GIT_DIFF_BYTES + 1024,
761
+ return new Promise((resolve, reject) => {
762
+ const child = spawn("git", [...args, ...pathspec], {
763
+ cwd: repoRoot,
764
+ stdio: ["ignore", "pipe", "pipe"],
765
+ });
766
+ const stdoutChunks = [];
767
+ const stderrChunks = [];
768
+ let observedBytes = 0;
769
+ let includedBytes = 0;
770
+ let truncated = false;
771
+ let timedOut = false;
772
+ const timeout = setTimeout(() => {
773
+ timedOut = true;
774
+ truncated = true;
775
+ child.kill("SIGTERM");
776
+ }, GIT_DIFF_TIMEOUT_MS);
777
+ child.stdout.on("data", (chunk) => {
778
+ observedBytes += chunk.byteLength;
779
+ if (includedBytes < MAX_GIT_DIFF_BYTES) {
780
+ const remaining = MAX_GIT_DIFF_BYTES - includedBytes;
781
+ const next = chunk.subarray(0, remaining);
782
+ stdoutChunks.push(next);
783
+ includedBytes += next.byteLength;
784
+ }
785
+ if (observedBytes > MAX_GIT_DIFF_BYTES) {
786
+ truncated = true;
787
+ child.kill("SIGTERM");
788
+ }
789
+ });
790
+ child.stderr.on("data", (chunk) => {
791
+ if (stderrChunks.reduce((sum, item) => sum + item.byteLength, 0) < 4096) {
792
+ stderrChunks.push(chunk.subarray(0, 4096));
793
+ }
794
+ });
795
+ child.on("error", (error) => {
796
+ clearTimeout(timeout);
797
+ reject(error);
798
+ });
799
+ child.on("close", (code, signal) => {
800
+ clearTimeout(timeout);
801
+ if (code === 0 || truncated || signal === "SIGTERM") {
802
+ resolve({
803
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
804
+ truncated,
805
+ observedBytes,
806
+ truncationReason: timedOut
807
+ ? "git_diff_timeout"
808
+ : "max_git_diff_bytes",
809
+ truncationCapType: timedOut ? "timeout_ms" : "max_bytes_per_diff",
810
+ });
811
+ return;
812
+ }
813
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
814
+ reject(new Error(stderr || `git diff failed with code ${code ?? signal}`));
815
+ });
816
+ });
817
+ }
818
+ function recordScanned(collection, source, count = 1) {
819
+ collection.scanned.set(source, (collection.scanned.get(source) ?? 0) + count);
820
+ }
821
+ function markBudgetCapApplied(collection, reason) {
822
+ markCapApplied(collection, "raw_evidence", reason === "deferred_object_budget" ? "object_budget" : "byte_budget");
823
+ }
824
+ function markCapApplied(collection, source, capType) {
825
+ const cap = collection.caps.find((candidate) => candidate.source === source && candidate.cap_type === capType);
826
+ if (cap)
827
+ cap.applied = true;
828
+ }
829
+ function recordSkipCount(collection, source, reason, count) {
830
+ if (count <= 0)
831
+ return;
832
+ collection.skipped.push({
833
+ kind: source,
834
+ label: reason,
835
+ reason,
836
+ count,
837
+ });
838
+ }
839
+ function recordTruncationCount(collection, source, reason, count, details = {}) {
840
+ if (count <= 0)
841
+ return;
842
+ collection.truncated.push({
843
+ kind: source,
844
+ reason,
845
+ count,
846
+ ...details,
847
+ });
848
+ }
849
+ function evidenceEntryCount(entry) {
850
+ return entry.count ?? 1;
851
+ }
852
+ function countEvidenceEntries(entries, predicate = () => true) {
853
+ return entries.reduce((sum, entry) => sum + (predicate(entry) ? evidenceEntryCount(entry) : 0), 0);
854
+ }
855
+ function makeEvidenceCompleteness(collection, options) {
856
+ const sources = new Set(collection.scanned.keys());
857
+ for (const entry of collection.entries)
858
+ sources.add(entry.kind);
859
+ for (const entry of collection.skipped)
860
+ sources.add(entry.kind);
861
+ for (const entry of collection.reused)
862
+ sources.add(entry.kind);
863
+ for (const entry of collection.truncated)
864
+ sources.add(entry.kind);
865
+ for (const entry of collection.failed)
866
+ sources.add(entry.kind);
867
+ const sourceCounts = [...sources].sort().map((source) => {
868
+ const skipped = collection.skipped.filter((entry) => entry.kind === source);
869
+ return {
870
+ source,
871
+ scanned_count: collection.scanned.get(source) ?? 0,
872
+ included_count: collection.entries.filter((entry) => entry.kind === source).length,
873
+ skipped_count: countEvidenceEntries(skipped),
874
+ truncated_count: countEvidenceEntries(collection.truncated, (entry) => entry.kind === source),
875
+ deferred_count: countEvidenceEntries(skipped, (entry) => entry.reason.startsWith("deferred_")),
876
+ reused_count: collection.reused.filter((entry) => entry.kind === source).length,
877
+ failed_count: countEvidenceEntries(collection.failed, (entry) => entry.kind === source),
878
+ };
879
+ });
880
+ const totals = sourceCounts.reduce((sum, count) => ({
881
+ scanned_count: sum.scanned_count + count.scanned_count,
882
+ included_count: sum.included_count + count.included_count,
883
+ skipped_count: sum.skipped_count + count.skipped_count,
884
+ truncated_count: sum.truncated_count + count.truncated_count,
885
+ deferred_count: sum.deferred_count + count.deferred_count,
886
+ reused_count: sum.reused_count + count.reused_count,
887
+ failed_count: sum.failed_count + count.failed_count,
888
+ }), {
889
+ scanned_count: 0,
890
+ included_count: 0,
891
+ skipped_count: 0,
892
+ truncated_count: 0,
893
+ deferred_count: 0,
894
+ reused_count: 0,
895
+ failed_count: 0,
896
+ });
897
+ const skipReasonCounts = new Map();
898
+ for (const skipped of collection.skipped) {
899
+ const key = `${skipped.kind}:${skipped.reason}`;
900
+ const existing = skipReasonCounts.get(key);
901
+ if (existing) {
902
+ existing.count += evidenceEntryCount(skipped);
903
+ }
904
+ else {
905
+ skipReasonCounts.set(key, {
906
+ source: skipped.kind,
907
+ reason: skipped.reason,
908
+ count: evidenceEntryCount(skipped),
909
+ });
910
+ }
911
+ }
912
+ const failureReasonCounts = new Map();
913
+ for (const failed of collection.failed) {
914
+ const key = `${failed.kind}:${failed.reason}`;
915
+ const existing = failureReasonCounts.get(key);
916
+ if (existing) {
917
+ existing.count += evidenceEntryCount(failed);
918
+ }
919
+ else {
920
+ failureReasonCounts.set(key, {
921
+ source: failed.kind,
922
+ reason: failed.reason,
923
+ count: evidenceEntryCount(failed),
924
+ });
925
+ }
926
+ }
927
+ const truncationCounts = new Map();
928
+ for (const truncated of collection.truncated) {
929
+ const key = `${truncated.kind}:${truncated.reason}`;
930
+ const existing = truncationCounts.get(key);
931
+ if (existing) {
932
+ existing.count += evidenceEntryCount(truncated);
933
+ existing.observed_bytes = Math.max(existing.observed_bytes ?? 0, truncated.observed_bytes ?? 0);
934
+ existing.included_bytes = Math.max(existing.included_bytes ?? 0, truncated.included_bytes ?? 0);
935
+ }
936
+ else {
937
+ truncationCounts.set(key, {
938
+ source: truncated.kind,
939
+ reason: truncated.reason,
940
+ count: evidenceEntryCount(truncated),
941
+ ...(truncated.max_bytes !== undefined
942
+ ? { max_bytes: truncated.max_bytes }
943
+ : {}),
944
+ ...(truncated.observed_bytes !== undefined
945
+ ? { observed_bytes: truncated.observed_bytes }
946
+ : {}),
947
+ ...(truncated.included_bytes !== undefined
948
+ ? { included_bytes: truncated.included_bytes }
949
+ : {}),
950
+ });
951
+ }
952
+ }
953
+ const hasGaps = totals.skipped_count > 0 ||
954
+ totals.truncated_count > 0 ||
955
+ totals.deferred_count > 0 ||
956
+ totals.failed_count > 0 ||
957
+ collection.caps.some((cap) => cap.applied);
958
+ const status = totals.failed_count > 0 &&
959
+ totals.included_count + totals.reused_count === 0
960
+ ? "failed"
961
+ : totals.included_count + totals.reused_count === 0 && !hasGaps
962
+ ? "empty"
963
+ : hasGaps
964
+ ? "partial"
965
+ : "complete";
966
+ return EvidenceCompletenessPayloadSchema.parse({
967
+ schema_version: "evidence-completeness.v1",
968
+ status,
969
+ generated_at: options.finishedAt,
970
+ scan_window: {
971
+ started_at: options.startedAt,
972
+ finished_at: options.finishedAt,
973
+ since_minutes: options.sinceMinutes,
974
+ },
975
+ source_counts: sourceCounts,
976
+ totals,
977
+ caps: collection.caps,
978
+ skip_reasons: [...skipReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
979
+ failure_reasons: [...failureReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
980
+ truncation_markers: [...truncationCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
981
+ notes: totals.failed_count > 0
982
+ ? ["Evidence collection failed; downstream analysis should not infer confidence."]
983
+ : hasGaps
984
+ ? ["Evidence is incomplete; downstream analysis should lower confidence."]
985
+ : [],
533
986
  });
534
- return stdout;
535
987
  }
536
988
  async function walkJsonlFiles(dir, cutoffMs) {
537
989
  const out = [];