@ictechgy/context-guard 0.4.14 → 0.4.16

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 (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +72 -1
  3. package/README.md +85 -2
  4. package/docs/benchmark-fixtures/image-context-pack-full-evidence.prompt.example.md +28 -0
  5. package/docs/benchmark-fixtures/image-context-pack-packed-evidence.prompt.example.md +31 -0
  6. package/docs/benchmark-fixtures/image-context-pack.evidence.example.jsonl +2 -0
  7. package/docs/benchmark-fixtures/image-context-pack.tasks.example.json +18 -0
  8. package/docs/benchmark-fixtures/image-context-pack.variants.example.json +10 -0
  9. package/docs/benchmark-workflow-examples.md +16 -0
  10. package/docs/experimental-benchmark-fixtures.md +52 -1
  11. package/package.json +2 -1
  12. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  13. package/plugins/context-guard/README.ko.md +43 -0
  14. package/plugins/context-guard/README.md +43 -0
  15. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  16. package/plugins/context-guard/bin/context-guard-audit +169 -66
  17. package/plugins/context-guard/bin/context-guard-bench +7038 -307
  18. package/plugins/context-guard/bin/context-guard-compress +90 -8
  19. package/plugins/context-guard/bin/context-guard-diet +1 -7
  20. package/plugins/context-guard/bin/context-guard-experiments +3085 -134
  21. package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
  22. package/plugins/context-guard/bin/context-guard-guard-read +490 -55
  23. package/plugins/context-guard/bin/context-guard-mcp +999 -0
  24. package/plugins/context-guard/bin/context-guard-pack +744 -20
  25. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  26. package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
  27. package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
  28. package/plugins/context-guard/bin/context-guard-setup +1073 -147
  29. package/plugins/context-guard/bin/context-guard-statusline +131 -54
  30. package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
  31. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  32. package/plugins/context-guard/bin/context-guard-trim-output +89 -13
  33. package/plugins/context-guard/brief/README.md +19 -0
  34. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  35. package/plugins/context-guard/lib/context_guard_commands.py +14 -2
  36. package/plugins/context-guard/lib/credential_policy.py +177 -0
  37. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -11,8 +11,10 @@ from __future__ import annotations
11
11
 
12
12
  import argparse
13
13
  import ast
14
+ from collections import Counter, deque
14
15
  import copy
15
16
  import hashlib
17
+ import heapq
16
18
  import importlib.machinery
17
19
  import importlib.util
18
20
  import json
@@ -35,6 +37,8 @@ DEFAULT_BUDGET_BYTES = 12_000
35
37
  MIN_BUDGET_BYTES = 0
36
38
  MAX_BUDGET_BYTES = 2_000_000
37
39
  MAX_RECEIPT_BYTES = 64_000
40
+ ROLLING_DELTA_SAMPLE_BYTES = 65_536
41
+ ROLLING_DELTA_WINDOW_BYTES = 64
38
42
  MAX_MANIFEST_BYTES = 1_000_000
39
43
  MAX_LABEL_CHARS = 160
40
44
  MAX_REASON_CHARS = 120
@@ -45,6 +49,16 @@ AUTO_EXPLAIN_SCHEMA_VERSION = "contextguard.pack-auto-explain.v1"
45
49
  REPO_MAP_SCHEMA_VERSION = "contextguard.pack-repo-map.v1"
46
50
  ADAPTIVE_K_SCHEMA_VERSION = "contextguard.pack-adaptive-k.v1"
47
51
  SYMBOL_MEMORY_SCHEMA_VERSION = "contextguard.pack-symbol-memory.v1"
52
+ CONTENT_ADDRESS_SCHEMA_VERSION = "contextguard.pack-content-address.v1"
53
+ ROLLING_DELTA_SCHEMA_VERSION = "contextguard.pack-rolling-delta.v1"
54
+ SKETCH_DUPLICATE_SHINGLE_WIDTH = 5
55
+ SKETCH_DUPLICATE_RETAINED_DIGESTS = 64
56
+ SKETCH_DUPLICATE_MIN_CARDINALITY = 12
57
+ SKETCH_DUPLICATE_THRESHOLD_NUMERATOR = 9
58
+ SKETCH_DUPLICATE_THRESHOLD_DENOMINATOR = 10
59
+ SKETCH_DUPLICATE_COMPARISON_CAP = 100_000
60
+ SKETCH_DUPLICATE_SHINGLE_DOMAIN = b"context-guard-pack/sketch-duplicate-veto/shingle/v1\x00"
61
+ SKETCH_DUPLICATE_TOKEN_RE = re.compile(r"\w+")
48
62
  DEFAULT_SUGGEST_TOP = 8
49
63
  MAX_SUGGEST_TOP = 50
50
64
  DEFAULT_SUGGEST_CONTEXT_LINES = 20
@@ -151,6 +165,7 @@ class SourceSpec:
151
165
  label: str | None = None
152
166
  input_index: int = 0
153
167
  origin: str = "cli"
168
+ sanitization_context: str = "source_code"
154
169
 
155
170
 
156
171
  @dataclass
@@ -165,6 +180,18 @@ class ResolvedSource:
165
180
  redacted_lines: int
166
181
 
167
182
 
183
+ @dataclass
184
+ class _PairedCandidate:
185
+ source: ResolvedSource
186
+ canonical: dict[str, Any]
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class _DuplicateSignature:
191
+ exact_digest: bytes
192
+ sketch: frozenset[bytes] | None
193
+
194
+
168
195
  @dataclass
169
196
  class SuggestCandidate:
170
197
  path: str
@@ -179,9 +206,32 @@ class PackError(ValueError):
179
206
  pass
180
207
 
181
208
 
209
+ SANITIZATION_CONTEXTS = frozenset(
210
+ {
211
+ "unknown_text",
212
+ "command_search_diff",
213
+ "filesystem_listing",
214
+ "source_code",
215
+ }
216
+ )
217
+
218
+
219
+ def parse_sanitization_context(value: object) -> str:
220
+ context = str(value or "unknown_text")
221
+ if context not in SANITIZATION_CONTEXTS:
222
+ raise PackError(f"unsupported sanitization context: {context}")
223
+ return context
224
+
225
+
182
226
  class FallbackLineSanitizer:
183
- def __init__(self, *, show_paths: bool = False) -> None:
227
+ def __init__(
228
+ self,
229
+ *,
230
+ show_paths: bool = False,
231
+ context: str = "unknown_text",
232
+ ) -> None:
184
233
  self.show_paths = show_paths
234
+ self.context = context
185
235
  self.redactions = 0
186
236
 
187
237
  def sanitize(self, raw_line: str) -> tuple[str, bool]:
@@ -226,7 +276,12 @@ def load_line_sanitizer_factory() -> Any:
226
276
  if spec is None:
227
277
  raise RuntimeError("import spec unavailable")
228
278
  module = importlib.util.module_from_spec(spec)
229
- loader.exec_module(module)
279
+ sys.modules[loader.name] = module
280
+ try:
281
+ loader.exec_module(module)
282
+ except Exception:
283
+ sys.modules.pop(loader.name, None)
284
+ raise
230
285
  _LINE_SANITIZER_FACTORY_CACHE = module.LineSanitizer
231
286
  return _LINE_SANITIZER_FACTORY_CACHE
232
287
  except Exception as exc:
@@ -235,13 +290,53 @@ def load_line_sanitizer_factory() -> Any:
235
290
  return _LINE_SANITIZER_FACTORY_CACHE
236
291
 
237
292
 
238
- def load_line_sanitizer(show_paths: bool = False) -> object:
293
+ def instantiate_line_sanitizer(
294
+ factory: Any,
295
+ *,
296
+ show_paths: bool,
297
+ context: str,
298
+ private_roots: tuple[str, ...] = (),
299
+ ) -> object:
300
+ try:
301
+ return factory(
302
+ show_paths=show_paths,
303
+ context=context,
304
+ private_roots=private_roots,
305
+ )
306
+ except TypeError:
307
+ if context != "unknown_text" or private_roots:
308
+ raise RuntimeError(
309
+ "adjacent sanitizer does not support required explicit context"
310
+ )
311
+ return factory(show_paths=show_paths)
312
+
313
+
314
+ def load_line_sanitizer(
315
+ show_paths: bool = False,
316
+ context: str = "unknown_text",
317
+ private_roots: tuple[str, ...] = (),
318
+ ) -> object:
239
319
  sanitizer_factory = load_line_sanitizer_factory()
240
- return sanitizer_factory(show_paths=show_paths)
320
+ return instantiate_line_sanitizer(
321
+ sanitizer_factory,
322
+ show_paths=show_paths,
323
+ context=context,
324
+ private_roots=private_roots,
325
+ )
241
326
 
242
327
 
243
- def sanitize_text(text: str, *, show_paths: bool = False) -> tuple[str, int]:
244
- sanitizer = load_line_sanitizer(show_paths)
328
+ def sanitize_text(
329
+ text: str,
330
+ *,
331
+ show_paths: bool = False,
332
+ context: str = "unknown_text",
333
+ private_roots: tuple[str, ...] = (),
334
+ ) -> tuple[str, int]:
335
+ sanitizer = load_line_sanitizer(
336
+ show_paths,
337
+ context=context,
338
+ private_roots=private_roots,
339
+ )
245
340
  redacted = 0
246
341
  out: list[str] = []
247
342
  for line in text.splitlines(True):
@@ -252,7 +347,13 @@ def sanitize_text(text: str, *, show_paths: bool = False) -> tuple[str, int]:
252
347
  return "".join(out), redacted
253
348
 
254
349
 
255
- def sanitize_source_lines(handle: Any, requested: LineRange | None) -> tuple[list[str], int, int]:
350
+ def sanitize_source_lines(
351
+ handle: Any,
352
+ requested: LineRange | None,
353
+ *,
354
+ context: str = "source_code",
355
+ private_roots: tuple[str, ...] = (),
356
+ ) -> tuple[list[str], int, int]:
256
357
  """Sanitize a source stream while retaining only the requested line window.
257
358
 
258
359
  Explicit line-window retrieval still scans the complete file so global
@@ -260,7 +361,10 @@ def sanitize_source_lines(handle: Any, requested: LineRange | None) -> tuple[lis
260
361
  outputs, but it no longer materializes a sanitized all-lines list before
261
362
  slicing.
262
363
  """
263
- sanitizer = load_line_sanitizer()
364
+ sanitizer = load_line_sanitizer(
365
+ context=context,
366
+ private_roots=private_roots,
367
+ )
264
368
  selected: list[str] = []
265
369
  redacted = 0
266
370
  total_lines = 0
@@ -290,6 +394,303 @@ def sha256_text(text: str) -> str:
290
394
  return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
291
395
 
292
396
 
397
+ def _sketch_duplicate_shingle_digest(tokens: tuple[str, ...]) -> bytes:
398
+ if len(tokens) != SKETCH_DUPLICATE_SHINGLE_WIDTH:
399
+ raise ValueError("sketch duplicate shingles require exactly five tokens")
400
+ digest = hashlib.sha256()
401
+ digest.update(SKETCH_DUPLICATE_SHINGLE_DOMAIN)
402
+ for token in tokens:
403
+ encoded = token.encode("utf-8", errors="strict")
404
+ digest.update(len(encoded).to_bytes(8, "big", signed=False))
405
+ digest.update(encoded)
406
+ return digest.digest()
407
+
408
+
409
+ def _retain_bottom_sketch_digest(heap: list[tuple[int, bytes]], retained: set[bytes], digest: bytes) -> None:
410
+ if digest in retained:
411
+ return
412
+ number = int.from_bytes(digest, "big", signed=False)
413
+ if len(heap) < SKETCH_DUPLICATE_RETAINED_DIGESTS:
414
+ heapq.heappush(heap, (-number, digest))
415
+ retained.add(digest)
416
+ return
417
+ largest = heap[0][1]
418
+ if digest >= largest:
419
+ return
420
+ _negative, removed = heapq.heapreplace(heap, (-number, digest))
421
+ retained.remove(removed)
422
+ retained.add(digest)
423
+
424
+
425
+ def _sketch_duplicate_signature(lines: list[str], *, include_sketch: bool) -> _DuplicateSignature:
426
+ exact = hashlib.sha256()
427
+ if not include_sketch:
428
+ for line in lines:
429
+ exact.update(line.encode("utf-8", errors="replace"))
430
+ return _DuplicateSignature(exact.digest(), None)
431
+
432
+ token_window: deque[str] = deque(maxlen=SKETCH_DUPLICATE_SHINGLE_WIDTH)
433
+ heap: list[tuple[int, bytes]] = []
434
+ retained: set[bytes] = set()
435
+ for line in lines:
436
+ exact.update(line.encode("utf-8", errors="replace"))
437
+ for match in SKETCH_DUPLICATE_TOKEN_RE.finditer(line.casefold()):
438
+ token_window.append(match.group(0))
439
+ if len(token_window) == SKETCH_DUPLICATE_SHINGLE_WIDTH:
440
+ digest = _sketch_duplicate_shingle_digest(tuple(token_window))
441
+ _retain_bottom_sketch_digest(heap, retained, digest)
442
+ return _DuplicateSignature(exact.digest(), frozenset(retained))
443
+
444
+
445
+ def _sanitized_source_bytes_equal(left: ResolvedSource, right: ResolvedSource) -> bool:
446
+ if len(left.selected_lines) != len(right.selected_lines):
447
+ return False
448
+ return all(
449
+ left_line.encode("utf-8", errors="replace") == right_line.encode("utf-8", errors="replace")
450
+ for left_line, right_line in zip(left.selected_lines, right.selected_lines)
451
+ )
452
+
453
+
454
+ def _sketch_sets_match(left: frozenset[bytes], right: frozenset[bytes]) -> bool:
455
+ intersection = len(left & right)
456
+ union = len(left) + len(right) - intersection
457
+ return (
458
+ union > 0
459
+ and SKETCH_DUPLICATE_THRESHOLD_DENOMINATOR * intersection
460
+ >= SKETCH_DUPLICATE_THRESHOLD_NUMERATOR * union
461
+ )
462
+
463
+
464
+ def _ordered_sketch_winner_ids(
465
+ sketch: frozenset[bytes],
466
+ postings: dict[bytes, list[int]],
467
+ ) -> Any:
468
+ heap: list[tuple[int, int, int, list[int]]] = []
469
+ for ordinal, digest in enumerate(sorted(sketch)):
470
+ winner_ids = postings.get(digest)
471
+ if winner_ids:
472
+ heapq.heappush(heap, (winner_ids[0], ordinal, 0, winner_ids))
473
+ last_yielded: int | None = None
474
+ while heap:
475
+ winner_id, ordinal, index, winner_ids = heapq.heappop(heap)
476
+ next_index = index + 1
477
+ if next_index < len(winner_ids):
478
+ heapq.heappush(heap, (winner_ids[next_index], ordinal, next_index, winner_ids))
479
+ if winner_id != last_yielded:
480
+ last_yielded = winner_id
481
+ yield winner_id
482
+
483
+
484
+ def _sketch_duplicate_omission(source: ResolvedSource, *, root_arg: str) -> dict[str, Any]:
485
+ requested = source.requested_lines or LineRange(1, source.total_lines)
486
+ item = omission(
487
+ source.spec,
488
+ "sketch_duplicate_source",
489
+ path=source.display_path,
490
+ redacted_path=source.redacted_path,
491
+ )
492
+ item["requested_lines"] = requested.as_dict()
493
+ retrieval, retrieval_omitted_reason = retrieval_for(
494
+ root_arg,
495
+ source.display_path,
496
+ requested,
497
+ redacted_path=source.redacted_path,
498
+ )
499
+ if retrieval:
500
+ item["retrieval_cli"] = retrieval
501
+ item.pop("retrieval_omitted_reason", None)
502
+ elif retrieval_omitted_reason:
503
+ item["retrieval_omitted_reason"] = retrieval_omitted_reason
504
+ return item
505
+
506
+
507
+ def _apply_sketch_duplicate_veto(
508
+ candidates: list[_PairedCandidate],
509
+ omitted: list[dict[str, Any]],
510
+ *,
511
+ root_arg: str,
512
+ ) -> tuple[list[ResolvedSource], bool]:
513
+ winners: list[_PairedCandidate] = []
514
+ exact_winners: dict[bytes, list[int]] = {}
515
+ winner_sketches: dict[int, frozenset[bytes]] = {}
516
+ postings: dict[bytes, list[int]] = {}
517
+ comparisons_remaining = SKETCH_DUPLICATE_COMPARISON_CAP
518
+ comparison_cap_reached = False
519
+
520
+ for candidate in candidates:
521
+ signature = _sketch_duplicate_signature(
522
+ candidate.source.selected_lines,
523
+ include_sketch=not comparison_cap_reached,
524
+ )
525
+ duplicate = False
526
+ for winner_id in exact_winners.get(signature.exact_digest, ()):
527
+ if _sanitized_source_bytes_equal(candidate.source, winners[winner_id].source):
528
+ duplicate = True
529
+ break
530
+
531
+ skipped_pair = False
532
+ sketch = signature.sketch
533
+ if (
534
+ not duplicate
535
+ and not comparison_cap_reached
536
+ and sketch is not None
537
+ and len(sketch) >= SKETCH_DUPLICATE_MIN_CARDINALITY
538
+ ):
539
+ for winner_id in _ordered_sketch_winner_ids(sketch, postings):
540
+ if comparisons_remaining == 0:
541
+ comparison_cap_reached = True
542
+ skipped_pair = True
543
+ sketch = None
544
+ break
545
+ comparisons_remaining -= 1
546
+ if _sketch_sets_match(sketch, winner_sketches[winner_id]):
547
+ duplicate = True
548
+ break
549
+
550
+ if duplicate:
551
+ candidate.canonical["status"] = "sketch_duplicate_source"
552
+ omitted.append(_sketch_duplicate_omission(candidate.source, root_arg=root_arg))
553
+ continue
554
+
555
+ winner_id = len(winners)
556
+ winners.append(candidate)
557
+ exact_winners.setdefault(signature.exact_digest, []).append(winner_id)
558
+ if (
559
+ not skipped_pair
560
+ and not comparison_cap_reached
561
+ and sketch is not None
562
+ and len(sketch) >= SKETCH_DUPLICATE_MIN_CARDINALITY
563
+ ):
564
+ winner_sketches[winner_id] = sketch
565
+ for digest in sketch:
566
+ postings.setdefault(digest, []).append(winner_id)
567
+
568
+ return [candidate.source for candidate in winners], comparison_cap_reached
569
+
570
+
571
+ def pack_id_arg(value: str) -> str:
572
+ if re.fullmatch(r"[0-9a-f]{20}", value) is None:
573
+ raise argparse.ArgumentTypeError("PACK_ID must be exactly 20 lowercase hexadecimal characters")
574
+ return value
575
+
576
+
577
+ def content_address(digest: str, bytes_count: int) -> dict[str, Any]:
578
+ return {
579
+ "schema_version": CONTENT_ADDRESS_SCHEMA_VERSION,
580
+ "id": f"sha256:{digest}",
581
+ "algorithm": "sha256",
582
+ "digest": digest,
583
+ "bytes": bytes_count,
584
+ }
585
+
586
+
587
+ def rolling_sample_metadata(body: bytes) -> dict[str, Any]:
588
+ sampled_bytes = min(len(body), ROLLING_DELTA_SAMPLE_BYTES)
589
+ if sampled_bytes == 0:
590
+ window_count = 0
591
+ elif sampled_bytes < ROLLING_DELTA_WINDOW_BYTES:
592
+ window_count = 1
593
+ else:
594
+ window_count = sampled_bytes - ROLLING_DELTA_WINDOW_BYTES + 1
595
+ return {
596
+ "total_bytes": len(body),
597
+ "sampled_bytes": sampled_bytes,
598
+ "window_count": window_count,
599
+ "truncated": len(body) > ROLLING_DELTA_SAMPLE_BYTES,
600
+ }
601
+
602
+
603
+ def rolling_window_multiset(body: bytes) -> Counter[bytes]:
604
+ sample = body[:ROLLING_DELTA_SAMPLE_BYTES]
605
+ if not sample:
606
+ return Counter()
607
+ if len(sample) < ROLLING_DELTA_WINDOW_BYTES:
608
+ return Counter((hashlib.sha256(sample).digest(),))
609
+ return Counter(
610
+ hashlib.sha256(sample[index:index + ROLLING_DELTA_WINDOW_BYTES]).digest()
611
+ for index in range(len(sample) - ROLLING_DELTA_WINDOW_BYTES + 1)
612
+ )
613
+
614
+
615
+ def rolling_delta_algorithm() -> dict[str, Any]:
616
+ return {
617
+ "name": "sha256_sliding_window_multiset",
618
+ "window_bytes": ROLLING_DELTA_WINDOW_BYTES,
619
+ "stride_bytes": 1,
620
+ "max_sample_bytes_per_pack": ROLLING_DELTA_SAMPLE_BYTES,
621
+ }
622
+
623
+
624
+ def rolling_delta_claim_boundary() -> dict[str, bool]:
625
+ return {
626
+ "diagnostic_only": True,
627
+ "changes_manifest_selection_or_pack": False,
628
+ "provider_token_or_cost_savings_claim_allowed": False,
629
+ }
630
+
631
+
632
+ def build_rolling_delta(
633
+ current_pack: str,
634
+ previous_pack: str,
635
+ previous_pack_id: str,
636
+ current_address: str,
637
+ ) -> dict[str, Any]:
638
+ current_body = current_pack.encode("utf-8")
639
+ previous_body = previous_pack.encode("utf-8")
640
+ current_meta = rolling_sample_metadata(current_body)
641
+ previous_meta = rolling_sample_metadata(previous_body)
642
+ current_windows = rolling_window_multiset(current_body)
643
+ previous_windows = rolling_window_multiset(previous_body)
644
+ matched = sum((current_windows & previous_windows).values())
645
+ current_count = current_meta["window_count"]
646
+ previous_count = previous_meta["window_count"]
647
+ both_empty = current_count == 0 and previous_count == 0
648
+ if both_empty:
649
+ current_ratio = 1.0
650
+ previous_ratio = 1.0
651
+ else:
652
+ current_ratio = round(matched / current_count, 6) if current_count else 0.0
653
+ previous_ratio = round(matched / previous_count, 6) if previous_count else 0.0
654
+ return {
655
+ "schema_version": ROLLING_DELTA_SCHEMA_VERSION,
656
+ "status": "partial" if current_meta["truncated"] or previous_meta["truncated"] else "available",
657
+ "previous_pack_id": previous_pack_id,
658
+ "current_content_address": current_address,
659
+ "previous_content_address": f"sha256:{hashlib.sha256(previous_body).hexdigest()}",
660
+ "algorithm": rolling_delta_algorithm(),
661
+ "current": current_meta,
662
+ "previous": previous_meta,
663
+ "matched_window_count": matched,
664
+ "current_reuse_ratio_proxy": current_ratio,
665
+ "previous_retention_ratio_proxy": previous_ratio,
666
+ "reason": None,
667
+ "claim_boundary": rolling_delta_claim_boundary(),
668
+ }
669
+
670
+
671
+ def unavailable_rolling_delta(
672
+ current_pack: str,
673
+ previous_pack_id: str,
674
+ current_address: str,
675
+ reason: str,
676
+ ) -> dict[str, Any]:
677
+ return {
678
+ "schema_version": ROLLING_DELTA_SCHEMA_VERSION,
679
+ "status": "unavailable",
680
+ "previous_pack_id": previous_pack_id,
681
+ "current_content_address": current_address,
682
+ "previous_content_address": None,
683
+ "algorithm": rolling_delta_algorithm(),
684
+ "current": rolling_sample_metadata(current_pack.encode("utf-8")),
685
+ "previous": {"total_bytes": 0, "sampled_bytes": 0, "window_count": 0, "truncated": False},
686
+ "matched_window_count": 0,
687
+ "current_reuse_ratio_proxy": 0.0,
688
+ "previous_retention_ratio_proxy": 0.0,
689
+ "reason": reason,
690
+ "claim_boundary": rolling_delta_claim_boundary(),
691
+ }
692
+
693
+
293
694
  def path_hash(path: Path) -> str:
294
695
  return hashlib.sha256(str(path).encode("utf-8", "replace")).hexdigest()[:12]
295
696
 
@@ -563,6 +964,9 @@ def read_manifest(path: Path) -> list[SourceSpec]:
563
964
  lines=lines,
564
965
  label=cap_label(item.get("label")),
565
966
  origin="manifest",
967
+ sanitization_context=parse_sanitization_context(
968
+ item.get("sanitization_context", item.get("context"))
969
+ ),
566
970
  ))
567
971
  return out
568
972
 
@@ -594,6 +998,9 @@ def parse_source_spec(raw: str) -> SourceSpec:
594
998
  lines=lines,
595
999
  label=cap_label(values.get("label")),
596
1000
  origin="cli",
1001
+ sanitization_context=parse_sanitization_context(
1002
+ values.get("sanitization_context", values.get("context"))
1003
+ ),
597
1004
  )
598
1005
 
599
1006
 
@@ -771,7 +1178,16 @@ def resolve_source(root: Path, spec: SourceSpec) -> tuple[ResolvedSource | None,
771
1178
  try:
772
1179
  with handle:
773
1180
  requested = spec.lines
774
- selected, total_lines, redacted_lines = sanitize_source_lines(handle, requested)
1181
+ selected, total_lines, redacted_lines = sanitize_source_lines(
1182
+ handle,
1183
+ requested,
1184
+ context=spec.sanitization_context,
1185
+ private_roots=(
1186
+ (str(root),)
1187
+ if spec.sanitization_context == "filesystem_listing"
1188
+ else ()
1189
+ ),
1190
+ )
775
1191
  except OSError:
776
1192
  return None, omission(spec, "unsafe_path", path=display, redacted_path=redacted_path)
777
1193
  if total_lines <= 0:
@@ -1168,6 +1584,211 @@ def shrink_receipt_for_write(data: dict[str, Any]) -> tuple[dict[str, Any], bool
1168
1584
  return receipt, capped
1169
1585
 
1170
1586
 
1587
+ class ReceiptJSONError(ValueError):
1588
+ pass
1589
+
1590
+
1591
+ def strict_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
1592
+ result: dict[str, Any] = {}
1593
+ for key, value in pairs:
1594
+ if key in result:
1595
+ raise ReceiptJSONError("duplicate key")
1596
+ result[key] = value
1597
+ return result
1598
+
1599
+
1600
+ def reject_json_constant(_value: str) -> Any:
1601
+ raise ReceiptJSONError("non-finite number")
1602
+
1603
+
1604
+ def parse_receipt_int(value: str) -> int:
1605
+ digits = value.removeprefix("-")
1606
+ if len(digits) > 20:
1607
+ raise ReceiptJSONError("integer too large")
1608
+ return int(value)
1609
+
1610
+
1611
+ def json_depth(value: Any, depth: int = 1) -> int:
1612
+ if depth > 100:
1613
+ raise ReceiptJSONError("maximum depth exceeded")
1614
+ if isinstance(value, dict):
1615
+ for item in value.values():
1616
+ json_depth(item, depth + 1)
1617
+ elif isinstance(value, list):
1618
+ for item in value:
1619
+ json_depth(item, depth + 1)
1620
+ return depth
1621
+
1622
+
1623
+ def prior_read_capabilities_available() -> bool:
1624
+ return (
1625
+ hasattr(os, "O_NOFOLLOW")
1626
+ and hasattr(os, "geteuid")
1627
+ and os.open in getattr(os, "supports_dir_fd", set())
1628
+ and os.stat in getattr(os, "supports_dir_fd", set())
1629
+ and os.stat in getattr(os, "supports_follow_symlinks", set())
1630
+ )
1631
+
1632
+
1633
+ def private_receipt_stat_safe(st: os.stat_result, *, directory: bool) -> bool:
1634
+ expected_mode = 0o700 if directory else 0o600
1635
+ expected_type = stat.S_ISDIR(st.st_mode) if directory else stat.S_ISREG(st.st_mode)
1636
+ return (
1637
+ expected_type
1638
+ and st.st_uid == os.geteuid()
1639
+ and stat.S_IMODE(st.st_mode) == expected_mode
1640
+ and (directory or st.st_nlink == 1)
1641
+ )
1642
+
1643
+
1644
+ def stat_identity(st: os.stat_result) -> tuple[int, int, int, int, int, int]:
1645
+ return (st.st_dev, st.st_ino, st.st_mode, st.st_uid, st.st_nlink, st.st_size)
1646
+
1647
+
1648
+ def read_previous_receipt(root: Path, requested_id: str) -> tuple[str | None, str | None]:
1649
+ if not prior_read_capabilities_available():
1650
+ return None, "previous_receipt_unsafe"
1651
+ current_fd: int | None = None
1652
+ file_fd: int | None = None
1653
+ parent_stats: list[os.stat_result] = []
1654
+ try:
1655
+ current_fd = open_dir_no_follow(root)
1656
+ for part in (".context-guard", "packs"):
1657
+ try:
1658
+ next_fd = open_dir_no_follow(part, dir_fd=current_fd)
1659
+ except FileNotFoundError:
1660
+ return None, "previous_receipt_not_found"
1661
+ except (OSError, PackError, NotImplementedError):
1662
+ return None, "previous_receipt_unsafe"
1663
+ os.close(current_fd)
1664
+ current_fd = next_fd
1665
+ try:
1666
+ parent_stat = os.fstat(current_fd)
1667
+ except OSError:
1668
+ return None, "previous_receipt_unsafe"
1669
+ parent_stats.append(parent_stat)
1670
+
1671
+ filename = f"{requested_id}.json"
1672
+ try:
1673
+ before = os.stat(filename, dir_fd=current_fd, follow_symlinks=False)
1674
+ except FileNotFoundError:
1675
+ return None, "previous_receipt_not_found"
1676
+ except (OSError, NotImplementedError):
1677
+ return None, "previous_receipt_unsafe"
1678
+ unsafe_parent = any(not private_receipt_stat_safe(item, directory=True) for item in parent_stats)
1679
+ if unsafe_parent or not private_receipt_stat_safe(before, directory=False):
1680
+ return None, "previous_receipt_unsafe"
1681
+ if before.st_size > MAX_RECEIPT_BYTES:
1682
+ return None, "previous_receipt_too_large"
1683
+
1684
+ flags = os.O_RDONLY | os.O_NOFOLLOW
1685
+ for name in ("O_CLOEXEC", "O_NONBLOCK", "O_NOCTTY"):
1686
+ flags |= getattr(os, name, 0)
1687
+ try:
1688
+ file_fd = os.open(filename, flags, dir_fd=current_fd)
1689
+ except FileNotFoundError:
1690
+ return None, "previous_receipt_invalid"
1691
+ except (OSError, NotImplementedError):
1692
+ return None, "previous_receipt_unsafe"
1693
+ opened = os.fstat(file_fd)
1694
+ if not private_receipt_stat_safe(opened, directory=False):
1695
+ return None, "previous_receipt_unsafe"
1696
+ if opened.st_size > MAX_RECEIPT_BYTES:
1697
+ return None, "previous_receipt_too_large"
1698
+ chunks: list[bytes] = []
1699
+ observed = 0
1700
+ while observed < MAX_RECEIPT_BYTES + 1:
1701
+ chunk = os.read(file_fd, min(16 * 1024, MAX_RECEIPT_BYTES + 1 - observed))
1702
+ if not chunk:
1703
+ break
1704
+ chunks.append(chunk)
1705
+ observed += len(chunk)
1706
+ raw = b"".join(chunks)
1707
+ after = os.fstat(file_fd)
1708
+ try:
1709
+ path_after = os.stat(filename, dir_fd=current_fd, follow_symlinks=False)
1710
+ except FileNotFoundError:
1711
+ return None, "previous_receipt_invalid"
1712
+ except (OSError, NotImplementedError):
1713
+ return None, "previous_receipt_unsafe"
1714
+ if not private_receipt_stat_safe(after, directory=False) or not private_receipt_stat_safe(path_after, directory=False):
1715
+ return None, "previous_receipt_unsafe"
1716
+ if len(raw) > MAX_RECEIPT_BYTES or after.st_size > MAX_RECEIPT_BYTES or path_after.st_size > MAX_RECEIPT_BYTES:
1717
+ return None, "previous_receipt_too_large"
1718
+ identities = (stat_identity(before), stat_identity(opened), stat_identity(after), stat_identity(path_after))
1719
+ if len(set(identities)) != 1 or len(raw) != after.st_size:
1720
+ return None, "previous_receipt_invalid"
1721
+ except OSError:
1722
+ return None, "previous_receipt_unsafe"
1723
+ finally:
1724
+ if file_fd is not None:
1725
+ try:
1726
+ os.close(file_fd)
1727
+ except OSError:
1728
+ pass
1729
+ if current_fd is not None:
1730
+ try:
1731
+ os.close(current_fd)
1732
+ except OSError:
1733
+ pass
1734
+
1735
+ try:
1736
+ receipt = json.loads(
1737
+ raw.decode("utf-8", errors="strict"),
1738
+ object_pairs_hook=strict_json_object,
1739
+ parse_constant=reject_json_constant,
1740
+ parse_int=parse_receipt_int,
1741
+ )
1742
+ json_depth(receipt)
1743
+ except (UnicodeDecodeError, ValueError, RecursionError):
1744
+ return None, "previous_receipt_invalid"
1745
+ if not isinstance(receipt, dict):
1746
+ return None, "previous_receipt_invalid"
1747
+ prior_id = receipt.get("pack_id")
1748
+ prior_bytes = receipt.get("pack_bytes")
1749
+ pack_present = "pack" in receipt
1750
+ address_present = "content_address" in receipt
1751
+ prior_pack = receipt.get("pack")
1752
+ prior_address = receipt.get("content_address")
1753
+ if not isinstance(prior_id, str) or re.fullmatch(r"[0-9a-f]{20}", prior_id) is None:
1754
+ return None, "previous_receipt_invalid"
1755
+ if not isinstance(prior_bytes, int) or isinstance(prior_bytes, bool) or prior_bytes < 0:
1756
+ return None, "previous_receipt_invalid"
1757
+ if pack_present and not isinstance(prior_pack, str):
1758
+ return None, "previous_receipt_invalid"
1759
+ if address_present and not isinstance(prior_address, dict):
1760
+ return None, "previous_receipt_invalid"
1761
+ if prior_id != requested_id:
1762
+ return None, "previous_pack_integrity_mismatch"
1763
+ if not pack_present:
1764
+ if receipt.get("pack_omitted_from_receipt") is True:
1765
+ return None, "previous_pack_body_unavailable"
1766
+ return None, "previous_receipt_invalid"
1767
+ assert isinstance(prior_pack, str)
1768
+ try:
1769
+ prior_body = prior_pack.encode("utf-8", errors="strict")
1770
+ except UnicodeEncodeError:
1771
+ return None, "previous_receipt_invalid"
1772
+ digest = hashlib.sha256(prior_body).hexdigest()
1773
+ if len(prior_body) != prior_bytes:
1774
+ return None, "previous_pack_integrity_mismatch"
1775
+ if address_present and prior_address != content_address(digest, prior_bytes):
1776
+ return None, "previous_pack_integrity_mismatch"
1777
+ return prior_pack, None
1778
+
1779
+
1780
+ def rolling_delta_from_receipt(
1781
+ root: Path,
1782
+ current_pack: str,
1783
+ previous_pack_id: str,
1784
+ current_address: str,
1785
+ ) -> dict[str, Any]:
1786
+ previous_pack, reason = read_previous_receipt(root, previous_pack_id)
1787
+ if reason is not None or previous_pack is None:
1788
+ return unavailable_rolling_delta(current_pack, previous_pack_id, current_address, reason or "previous_receipt_invalid")
1789
+ return build_rolling_delta(current_pack, previous_pack, previous_pack_id, current_address)
1790
+
1791
+
1171
1792
  def store_receipt(root: Path, result: dict[str, Any]) -> dict[str, Any]:
1172
1793
  out_dir, dir_fd, dir_error = ensure_private_pack_dir(root)
1173
1794
  if out_dir is None or dir_fd is None:
@@ -1204,9 +1825,19 @@ def store_receipt(root: Path, result: dict[str, Any]) -> dict[str, Any]:
1204
1825
  }
1205
1826
 
1206
1827
 
1207
- def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_arg: str, store_artifact: bool) -> dict[str, Any]:
1828
+ def build_pack(
1829
+ root: Path,
1830
+ specs: list[SourceSpec],
1831
+ *,
1832
+ budget_bytes: int,
1833
+ root_arg: str,
1834
+ store_artifact: bool,
1835
+ delta_from_pack_id: str | None = None,
1836
+ sketch_duplicate_veto: bool = False,
1837
+ ) -> dict[str, Any]:
1208
1838
  seen: set[tuple[str, str]] = set()
1209
1839
  resolved: list[ResolvedSource] = []
1840
+ paired_candidates: list[_PairedCandidate] = []
1210
1841
  omitted: list[dict[str, Any]] = []
1211
1842
  canonical_specs: list[dict[str, Any]] = []
1212
1843
  for spec in specs:
@@ -1237,8 +1868,20 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
1237
1868
  continue
1238
1869
  assert source is not None
1239
1870
  resolved.append(source)
1240
- canonical_specs.append({"path": source.display_path, "priority": spec.priority, "lines": identity_lines, "status": "candidate"})
1241
- resolved.sort(key=lambda item: (-item.spec.priority, item.spec.input_index, item.display_path))
1871
+ canonical = {"path": source.display_path, "priority": spec.priority, "lines": identity_lines, "status": "candidate"}
1872
+ canonical_specs.append(canonical)
1873
+ paired_candidates.append(_PairedCandidate(source, canonical))
1874
+ paired_candidates.sort(key=lambda item: (-item.source.spec.priority, item.source.spec.input_index, item.source.display_path))
1875
+ all_resolved = resolved
1876
+ comparison_cap_reached = False
1877
+ if sketch_duplicate_veto:
1878
+ resolved, comparison_cap_reached = _apply_sketch_duplicate_veto(
1879
+ paired_candidates,
1880
+ omitted,
1881
+ root_arg=root_arg,
1882
+ )
1883
+ else:
1884
+ resolved = [item.source for item in paired_candidates]
1242
1885
  header = "# Context Pack\n\nGenerated by context-guard-pack. Token counts are estimated proxies; byte counts are observed.\n\n"
1243
1886
  parts: list[str] = []
1244
1887
  included: list[dict[str, Any]] = []
@@ -1267,7 +1910,8 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
1267
1910
  omitted.append(budget_omission(source, root_arg=root_arg))
1268
1911
  pack = "".join(parts)
1269
1912
  pack_bytes = current_pack_bytes
1270
- redacted_lines = sum(source.redacted_lines for source in resolved)
1913
+ pack_digest = sha256_text(pack)
1914
+ redacted_lines = sum(source.redacted_lines for source in all_resolved)
1271
1915
  partial_count = sum(1 for item in included if item.get("status") == "partial")
1272
1916
  omitted_sorted = sorted(omitted, key=lambda item: (item.get("input_index", 0), str(item.get("path", "")), str(item.get("reason", ""))))
1273
1917
  canonical = {
@@ -1275,7 +1919,7 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
1275
1919
  "root": display_root(root),
1276
1920
  "budget_bytes": budget_bytes,
1277
1921
  "sources": canonical_specs,
1278
- "pack_sha256": sha256_text(pack),
1922
+ "pack_sha256": pack_digest,
1279
1923
  "omission_summary": sorted({str(item.get("reason")) for item in omitted_sorted}),
1280
1924
  }
1281
1925
  pack_id = hashlib.sha256(json.dumps(canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:20]
@@ -1287,6 +1931,7 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
1287
1931
  "budget_bytes": budget_bytes,
1288
1932
  "pack_bytes": pack_bytes,
1289
1933
  "pack": pack,
1934
+ "content_address": content_address(pack_digest, pack_bytes),
1290
1935
  "token_proxy": {"measurement": "estimated", "method": f"chars_div_{TOKEN_PROXY_CHARS_PER_TOKEN}", "pack": token_proxy(pack)},
1291
1936
  "sources": {"total": len(specs), "included": len(included) - partial_count, "partial": partial_count, "omitted": len(omitted_sorted)},
1292
1937
  "included_sources": included,
@@ -1295,6 +1940,15 @@ def build_pack(root: Path, specs: list[SourceSpec], *, budget_bytes: int, root_a
1295
1940
  "artifact": {"stored": False, "path": None, "bytes": 0, "capped": False, "cap_bytes": MAX_RECEIPT_BYTES},
1296
1941
  "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1297
1942
  }
1943
+ if sketch_duplicate_veto:
1944
+ result["sketch_duplicate_veto"] = {"comparison_cap_reached": comparison_cap_reached}
1945
+ if delta_from_pack_id is not None:
1946
+ result["rolling_delta"] = rolling_delta_from_receipt(
1947
+ root,
1948
+ pack,
1949
+ delta_from_pack_id,
1950
+ result["content_address"]["id"],
1951
+ )
1298
1952
  if store_artifact:
1299
1953
  artifact = store_receipt(root, result)
1300
1954
  result["artifact"] = artifact
@@ -1416,10 +2070,16 @@ def run_git_diff(root: Path, diff_ref: str) -> str:
1416
2070
  except (OSError, UnicodeError, subprocess.TimeoutExpired) as exc:
1417
2071
  raise PackError(f"could not read diff: {exc.__class__.__name__}") from exc
1418
2072
  if proc.returncode != 0:
1419
- detail = sanitize_text(proc.stderr or proc.stdout or "git diff failed")[0].strip().splitlines()
2073
+ detail = sanitize_text(
2074
+ proc.stderr or proc.stdout or "git diff failed",
2075
+ context="command_search_diff",
2076
+ )[0].strip().splitlines()
1420
2077
  message = detail[0] if detail else "git diff failed"
1421
2078
  raise PackError(f"could not read diff: {cap_label(message, default='git diff failed', limit=160)}")
1422
- return sanitize_text(proc.stdout[:MAX_SUGGEST_INPUT_BYTES])[0]
2079
+ return sanitize_text(
2080
+ proc.stdout[:MAX_SUGGEST_INPUT_BYTES],
2081
+ context="command_search_diff",
2082
+ )[0]
1423
2083
 
1424
2084
 
1425
2085
  def collect_diff_candidates(root: Path, diff_ref: str, query_terms: set[str], context_lines: int) -> list[SuggestCandidate]:
@@ -3267,7 +3927,15 @@ def auto_pack(root: Path, args: argparse.Namespace, *, root_arg: str) -> tuple[d
3267
3927
  manifest = suggest_payload["manifest"]
3268
3928
  specs = manifest_to_source_specs(manifest)
3269
3929
  budget = bounded_int(args.budget_bytes, DEFAULT_BUDGET_BYTES, MIN_BUDGET_BYTES, MAX_BUDGET_BYTES)
3270
- build_payload = build_pack(root, specs, budget_bytes=budget, root_arg=root_arg, store_artifact=False)
3930
+ build_payload = build_pack(
3931
+ root,
3932
+ specs,
3933
+ budget_bytes=budget,
3934
+ root_arg=root_arg,
3935
+ store_artifact=False,
3936
+ delta_from_pack_id=args.delta_from_pack_id,
3937
+ sketch_duplicate_veto=getattr(args, "sketch_duplicate_veto", False),
3938
+ )
3271
3939
  if not args.no_artifact:
3272
3940
  receipt_rel = Path(PACK_DIR) / f"{build_payload['pack_id']}.json"
3273
3941
  if manifest_rel is not None:
@@ -3415,9 +4083,15 @@ def print_suggest_text(payload: dict[str, Any]) -> None:
3415
4083
 
3416
4084
 
3417
4085
  def print_auto_text(payload: dict[str, Any]) -> None:
4086
+ build_payload = payload.get("build", {}) if isinstance(payload.get("build"), dict) else {}
4087
+ sketch_duplicate = build_payload.get("sketch_duplicate_veto")
4088
+ sketch_suffix = ""
4089
+ if isinstance(sketch_duplicate, dict):
4090
+ cap_reached = str(bool(sketch_duplicate.get("comparison_cap_reached"))).lower()
4091
+ sketch_suffix = f" sketch_comparison_cap_reached={cap_reached}"
3418
4092
  print(
3419
4093
  f"context-guard-pack auto: {payload['sources']['suggested']} suggested source(s), "
3420
- f"pack {payload['pack_bytes']}/{payload['budget_bytes']} bytes"
4094
+ f"pack {payload['pack_bytes']}/{payload['budget_bytes']} bytes{sketch_suffix}"
3421
4095
  )
3422
4096
  explain = payload.get("explain")
3423
4097
  if isinstance(explain, dict):
@@ -3477,6 +4151,24 @@ def build_parser() -> argparse.ArgumentParser:
3477
4151
  build.add_argument("--budget-bytes", type=int, default=DEFAULT_BUDGET_BYTES)
3478
4152
  build.add_argument("--json", action="store_true", help="emit JSON payload")
3479
4153
  build.add_argument("--no-artifact", action="store_true", help="do not write .context-guard/packs receipt")
4154
+ build.add_argument(
4155
+ "--sketch-duplicate-veto",
4156
+ action="store_true",
4157
+ help=(
4158
+ "omit later rank-stable sanitized exact/sketch-set duplicates; use a fixed 100,000 verified-pair cap, "
4159
+ "then fail open; when enabled report sketch_comparison_cap_reached=true|false in text and "
4160
+ "sketch_duplicate_veto.comparison_cap_reached in JSON"
4161
+ ),
4162
+ )
4163
+ build.add_argument(
4164
+ "--delta-from-pack-id",
4165
+ type=pack_id_arg,
4166
+ metavar="PACK_ID",
4167
+ help=(
4168
+ "compare against one private local pack receipt using bounded rolling diagnostics; "
4169
+ "visible only in --json output or a stored receipt (--no-artifact requires --json)"
4170
+ ),
4171
+ )
3480
4172
  slice_cmd = sub.add_parser("slice", help="retrieve an exact sanitized file slice")
3481
4173
  slice_cmd.add_argument("--root", default=".", help="project root; must not be a symlink")
3482
4174
  slice_cmd.add_argument("--path", required=True, help="relative file path under root")
@@ -3512,6 +4204,24 @@ def build_parser() -> argparse.ArgumentParser:
3512
4204
  auto.add_argument("--pack-out", help="write the built Markdown pack to this relative path under root")
3513
4205
  auto.add_argument("--json", action="store_true", help="emit JSON payload")
3514
4206
  auto.add_argument("--no-artifact", action="store_true", help="do not write .context-guard/packs receipt")
4207
+ auto.add_argument(
4208
+ "--sketch-duplicate-veto",
4209
+ action="store_true",
4210
+ help=(
4211
+ "omit later rank-stable sanitized exact/sketch-set duplicates; use a fixed 100,000 verified-pair cap, "
4212
+ "then fail open; when enabled report sketch_comparison_cap_reached=true|false in text and "
4213
+ "sketch_duplicate_veto.comparison_cap_reached in JSON"
4214
+ ),
4215
+ )
4216
+ auto.add_argument(
4217
+ "--delta-from-pack-id",
4218
+ type=pack_id_arg,
4219
+ metavar="PACK_ID",
4220
+ help=(
4221
+ "compare against one private local pack receipt using bounded rolling diagnostics; "
4222
+ "visible only in --json output or a stored receipt (--no-artifact requires --json)"
4223
+ ),
4224
+ )
3515
4225
  auto.add_argument("--explain", action="store_true", help="include deterministic local selection/build explanation metadata")
3516
4226
  auto.add_argument("--adaptive-k", action="store_true", help="include local score/budget top-k advisory metadata without changing the manifest or pack")
3517
4227
  auto.add_argument("--adaptive-k-policy", choices=ADAPTIVE_K_POLICIES, default="balanced", help="local adaptive-k recommendation policy used when --adaptive-k is set")
@@ -3531,15 +4241,29 @@ def main(argv: list[str] | None = None) -> int:
3531
4241
  if not specs:
3532
4242
  raise PackError("provide --manifest or --source")
3533
4243
  budget = bounded_int(args.budget_bytes, DEFAULT_BUDGET_BYTES, MIN_BUDGET_BYTES, MAX_BUDGET_BYTES)
3534
- result = build_pack(root, specs, budget_bytes=budget, root_arg=str(args.root), store_artifact=not args.no_artifact)
4244
+ result = build_pack(
4245
+ root,
4246
+ specs,
4247
+ budget_bytes=budget,
4248
+ root_arg=str(args.root),
4249
+ store_artifact=not args.no_artifact,
4250
+ delta_from_pack_id=args.delta_from_pack_id,
4251
+ sketch_duplicate_veto=args.sketch_duplicate_veto,
4252
+ )
3535
4253
  if args.json:
3536
4254
  json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
3537
4255
  sys.stdout.write("\n")
3538
4256
  else:
3539
4257
  sys.stdout.write(str(result["pack"]))
4258
+ sketch_suffix = ""
4259
+ sketch_duplicate = result.get("sketch_duplicate_veto")
4260
+ if isinstance(sketch_duplicate, dict):
4261
+ cap_reached = str(bool(sketch_duplicate.get("comparison_cap_reached"))).lower()
4262
+ sketch_suffix = f" sketch_comparison_cap_reached={cap_reached}"
3540
4263
  print(
3541
4264
  f"[context-guard-pack] pack_id={result['pack_id']} bytes={result['pack_bytes']}/{result['budget_bytes']} "
3542
- f"included={result['sources']['included']} partial={result['sources']['partial']} omitted={result['sources']['omitted']}",
4265
+ f"included={result['sources']['included']} partial={result['sources']['partial']} omitted={result['sources']['omitted']}"
4266
+ f"{sketch_suffix}",
3543
4267
  file=sys.stderr,
3544
4268
  )
3545
4269
  return 0