@ictechgy/context-guard 0.4.10 → 0.4.12

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 (32) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.ko.md +46 -28
  3. package/README.md +42 -33
  4. package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
  5. package/docs/benchmark-workflow-examples.md +3 -0
  6. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
  7. package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
  8. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
  9. package/docs/experimental-benchmark-fixtures.md +24 -7
  10. package/package.json +2 -1
  11. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  12. package/plugins/context-guard/README.ko.md +14 -11
  13. package/plugins/context-guard/README.md +15 -14
  14. package/plugins/context-guard/bin/context-guard +48 -17
  15. package/plugins/context-guard/bin/context-guard-artifact +342 -33
  16. package/plugins/context-guard/bin/context-guard-audit +36 -5
  17. package/plugins/context-guard/bin/context-guard-bench +1675 -44
  18. package/plugins/context-guard/bin/context-guard-cache-score +347 -35
  19. package/plugins/context-guard/bin/context-guard-compress +89 -27
  20. package/plugins/context-guard/bin/context-guard-cost +7 -2
  21. package/plugins/context-guard/bin/context-guard-experiments +364 -8
  22. package/plugins/context-guard/bin/context-guard-failed-nudge +6 -2
  23. package/plugins/context-guard/bin/context-guard-filter +88 -18
  24. package/plugins/context-guard/bin/context-guard-pack +329 -19
  25. package/plugins/context-guard/bin/context-guard-read-symbol +27 -0
  26. package/plugins/context-guard/bin/context-guard-sanitize-output +245 -18
  27. package/plugins/context-guard/bin/context-guard-setup +21 -5
  28. package/plugins/context-guard/bin/context-guard-tool-prune +287 -62
  29. package/plugins/context-guard/bin/context-guard-trim-output +394 -90
  30. package/plugins/context-guard/brief/README.md +5 -5
  31. package/plugins/context-guard/lib/context_guard_command_manifest_loader.py +123 -0
  32. package/plugins/context-guard/lib/context_guard_commands.py +217 -190
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  import argparse
11
11
  import codecs
12
+ import collections
13
+ import itertools
12
14
  from dataclasses import dataclass
13
15
  import json
14
16
  import os
@@ -455,26 +457,94 @@ def cap_line(line: str, max_chars: int) -> str:
455
457
  return line[: max(0, max_chars - len(marker) - len(suffix))] + marker + suffix
456
458
 
457
459
 
458
- def select_lines(lines: list[str], flt: CompiledFilter, max_line_chars: int) -> list[str]:
459
- selected = [cap_line(line, max_line_chars) for line in lines]
460
- if flt.include_regex:
461
- selected = [line for line in selected if any(pattern.search(line) for pattern in flt.include_regex)]
462
- if flt.exclude_regex:
463
- selected = [line for line in selected if not any(pattern.search(line) for pattern in flt.exclude_regex)]
460
+ LINE_BOUNDARY_CHARS = {"\n", "\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"}
461
+
462
+
463
+ @dataclass
464
+ class LineSelection:
465
+ lines: list[str]
466
+ input_lines: int
467
+ input_complete: bool
468
+
469
+
470
+ def iter_text_lines_keepends(text: str) -> Iterable[str]:
471
+ """Yield lines with Python splitlines(keepends=True) boundaries without a list."""
472
+ start = 0
473
+ index = 0
474
+ length = len(text)
475
+ while index < length:
476
+ char = text[index]
477
+ if char == "\r" and index + 1 < length and text[index + 1] == "\n":
478
+ yield text[start : index + 2]
479
+ index += 2
480
+ start = index
481
+ continue
482
+ if char in LINE_BOUNDARY_CHARS:
483
+ yield text[start : index + 1]
484
+ index += 1
485
+ start = index
486
+ continue
487
+ index += 1
488
+ if start < length:
489
+ yield text[start:]
490
+
491
+
492
+ def line_matches_filter(line: str, flt: CompiledFilter) -> bool:
493
+ if flt.include_regex and not any(pattern.search(line) for pattern in flt.include_regex):
494
+ return False
495
+ if flt.exclude_regex and any(pattern.search(line) for pattern in flt.exclude_regex):
496
+ return False
497
+ return True
498
+
499
+
500
+ def select_lines_with_stats(lines: Iterable[str], flt: CompiledFilter, max_line_chars: int) -> LineSelection:
501
+ source_count = 0
502
+ matched_count = 0
503
+ input_complete = True
464
504
  if flt.head_lines is not None or flt.tail_lines is not None:
465
505
  head_n = flt.head_lines if flt.head_lines is not None else 0
466
506
  tail_n = flt.tail_lines if flt.tail_lines is not None else 0
467
- head = selected[:head_n] if head_n else []
468
- tail = selected[-tail_n:] if tail_n else []
469
- if head and tail:
470
- seen_head_count = len(head)
471
- tail = tail[max(0, seen_head_count + len(tail) - len(selected)):]
472
- selected = head + tail
507
+ head: list[str] = []
508
+ tail: collections.deque[str] = collections.deque(maxlen=tail_n)
509
+ for source_line in lines:
510
+ source_count += 1
511
+ line = cap_line(source_line, max_line_chars)
512
+ if not line_matches_filter(line, flt):
513
+ continue
514
+ matched_count += 1
515
+ if head_n and len(head) < head_n:
516
+ head.append(line)
517
+ if tail_n:
518
+ tail.append(line)
519
+ elif head_n and len(head) >= head_n:
520
+ input_complete = False
521
+ break
522
+ tail_list = list(tail)
523
+ if head and tail_list:
524
+ tail_list = tail_list[max(0, len(head) + len(tail_list) - matched_count):]
525
+ selected = head + tail_list
526
+ else:
527
+ limit = min(flt.max_lines if flt.max_lines is not None else MAX_EMIT_LINES, MAX_EMIT_LINES)
528
+ selected = []
529
+ for source_line in lines:
530
+ source_count += 1
531
+ line = cap_line(source_line, max_line_chars)
532
+ if not line_matches_filter(line, flt):
533
+ continue
534
+ matched_count += 1
535
+ selected.append(line)
536
+ if len(selected) >= limit:
537
+ input_complete = False
538
+ break
473
539
  if flt.max_lines is not None and len(selected) > flt.max_lines:
474
540
  selected = selected[:flt.max_lines]
475
541
  if len(selected) > MAX_EMIT_LINES:
476
542
  selected = selected[:MAX_EMIT_LINES]
477
- return selected
543
+ return LineSelection(selected, source_count, input_complete)
544
+
545
+
546
+ def select_lines(lines: Iterable[str], flt: CompiledFilter, max_line_chars: int) -> list[str]:
547
+ return select_lines_with_stats(lines, flt, max_line_chars).lines
478
548
 
479
549
 
480
550
  def validation_payload(valid: bool, errors: list[str], count: int = 0) -> dict[str, Any]:
@@ -720,7 +790,6 @@ def cmd_run(args: argparse.Namespace) -> int:
720
790
  filters, errors = load_filters(Path(args.config).expanduser())
721
791
  result = run_command(command, timeout_seconds, max_capture)
722
792
  rc = result.returncode
723
- output = result.stdout_text + result.stderr_text
724
793
  protected_nonzero = rc != 0 and is_protected_command(command)
725
794
  report: dict[str, Any] = {"tool": TOOL_NAME, "schema_version": SCHEMA_VERSION, "mode": "run", "command_exit_code": rc, "decision": "passthrough", "reason": "unclassified", "protected_nonzero": protected_nonzero}
726
795
  if result.timed_out:
@@ -746,18 +815,19 @@ def cmd_run(args: argparse.Namespace) -> int:
746
815
  report["filter_id"] = matched.id
747
816
  else:
748
817
  try:
749
- lines = output.splitlines(keepends=True)
750
- filtered = select_lines(lines, matched, max_line_chars)
818
+ source_lines = itertools.chain(iter_text_lines_keepends(result.stdout_text), iter_text_lines_keepends(result.stderr_text))
819
+ selection = select_lines_with_stats(source_lines, matched, max_line_chars)
820
+ filtered = selection.lines
751
821
  except re.error as exc:
752
822
  report["reason"] = f"filter-error:{compact(str(exc), 80)}"
753
823
  report["filter_id"] = matched.id
754
824
  else:
755
- if output and not filtered:
825
+ if (result.stdout_text or result.stderr_text) and not filtered:
756
826
  report["reason"] = "empty-output-fallback"
757
827
  report["filter_id"] = matched.id
758
828
  else:
759
829
  sys.stdout.write("".join(filtered))
760
- report.update({"decision": "filtered", "reason": "matched", "filter_id": matched.id, "input_lines": len(lines), "output_lines": len(filtered)})
830
+ report.update({"decision": "filtered", "reason": "matched", "filter_id": matched.id, "input_lines": selection.input_lines, "input_lines_complete": selection.input_complete, "output_lines": len(filtered)})
761
831
  emit_run_report(args, report)
762
832
  return rc
763
833
  if not result.passthrough_emitted:
@@ -53,6 +53,8 @@ SUGGEST_WHOLE_FILE_MAX_LINES = 120
53
53
  MAX_SUGGEST_INPUT_BYTES = 256_000
54
54
  MAX_QUERY_SCAN_FILES = 2_000
55
55
  MAX_QUERY_SCAN_BYTES_PER_FILE = 200_000
56
+ MAX_GIT_LS_FILES_OUTPUT_BYTES = MAX_QUERY_SCAN_FILES * 512
57
+ GIT_LS_FILES_READ_CHUNK_BYTES = 64 * 1024
56
58
  MAX_REPO_MAP_FILES = 1_000
57
59
  MAX_REPO_MAP_SCAN_FILES = 160
58
60
  MAX_REPO_MAP_BYTES_PER_FILE = 120_000
@@ -62,6 +64,11 @@ MAX_REPO_MAP_GRAPH_RANK_ENTRIES = 30
62
64
  MAX_REPO_MAP_RETRIEVAL_HINTS = 30
63
65
  MAX_REPO_MAP_SECRET_RISK_FILES = 20
64
66
  MAX_ADAPTIVE_K_SCORE_SAMPLES = 200
67
+ MAX_ADAPTIVE_K_SELECTED_EVIDENCE = 12
68
+ MAX_ADAPTIVE_K_OMITTED_EVIDENCE = 12
69
+ MAX_ADAPTIVE_K_REASON_COUNTS = 12
70
+ MAX_ADAPTIVE_K_VERIFICATION_HINTS = 12
71
+ ADAPTIVE_K_POLICIES = ("balanced", "recall", "precision")
65
72
  MAX_SYMBOL_MEMORY_ITEMS = 12
66
73
  MAX_SYMBOL_MEMORY_GRAPH_ITEMS = 12
67
74
  PACK_DIR = ".context-guard/packs"
@@ -364,6 +371,16 @@ def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
364
371
  return min(max(number, minimum), maximum)
365
372
 
366
373
 
374
+ def adaptive_k_threshold(value: object) -> float:
375
+ try:
376
+ number = float(value)
377
+ except (TypeError, ValueError, OverflowError) as exc:
378
+ raise argparse.ArgumentTypeError("adaptive-k threshold must be a number between 0.0 and 1.0") from exc
379
+ if not 0.0 <= number <= 1.0:
380
+ raise argparse.ArgumentTypeError("adaptive-k threshold must be between 0.0 and 1.0")
381
+ return number
382
+
383
+
367
384
  def cap_label(value: object, default: str | None = None, limit: int = MAX_LABEL_CHARS) -> str | None:
368
385
  if value is None:
369
386
  return default
@@ -940,6 +957,29 @@ def metadata_size(data: dict[str, Any]) -> int:
940
957
  return len(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8", errors="replace")) + 1
941
958
 
942
959
 
960
+ def receipt_working_copy(data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
961
+ """Copy receipt metadata without deep-copying or serializing an oversized pack body.
962
+
963
+ The pack body is already an immutable string in normal builds and stdout remains
964
+ authoritative for it. When it cannot possibly fit under the receipt cap by
965
+ itself, omit it before the first receipt-size probe so capping work only touches
966
+ metadata previews.
967
+ """
968
+ receipt: dict[str, Any] = {}
969
+ pack_omitted = False
970
+ for key, value in data.items():
971
+ if key == "pack" and isinstance(value, str):
972
+ if len(value.encode("utf-8", errors="replace")) > MAX_RECEIPT_BYTES:
973
+ pack_omitted = True
974
+ continue
975
+ receipt[key] = value
976
+ continue
977
+ receipt[key] = copy.deepcopy(value)
978
+ if pack_omitted:
979
+ receipt["pack_omitted_from_receipt"] = True
980
+ return receipt, pack_omitted
981
+
982
+
943
983
  def artifact_failure(error: str, *, bytes_count: int = 0, capped: bool = False) -> dict[str, Any]:
944
984
  return {
945
985
  "stored": False,
@@ -1096,8 +1136,11 @@ def finalize_receipt_size(receipt: dict[str, Any]) -> int:
1096
1136
 
1097
1137
 
1098
1138
  def shrink_receipt_for_write(data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
1099
- receipt = copy.deepcopy(data)
1100
- capped = False
1139
+ receipt, pack_omitted = receipt_working_copy(data)
1140
+ capped = pack_omitted
1141
+ if pack_omitted:
1142
+ receipt.setdefault("artifact", {})["capped"] = True
1143
+ receipt.setdefault("artifact", {})["cap_bytes"] = MAX_RECEIPT_BYTES
1101
1144
  if metadata_size(receipt) <= MAX_RECEIPT_BYTES:
1102
1145
  return receipt, capped
1103
1146
  capped = True
@@ -1488,19 +1531,81 @@ def collect_output_candidates(
1488
1531
 
1489
1532
 
1490
1533
  def git_ls_files(root: Path) -> list[str]:
1534
+ def read_stdout_capped(proc: subprocess.Popen[bytes], limit: int, timeout_seconds: float) -> tuple[bytes, bool]:
1535
+ if proc.stdout is None:
1536
+ return b"", False
1537
+ chunks: list[bytes] = []
1538
+ total = 0
1539
+ capped = False
1540
+ timed_out = False
1541
+
1542
+ def reader() -> None:
1543
+ nonlocal total, capped
1544
+ try:
1545
+ while total <= limit:
1546
+ chunk = proc.stdout.read(min(GIT_LS_FILES_READ_CHUNK_BYTES, limit + 1 - total))
1547
+ if not chunk:
1548
+ break
1549
+ chunks.append(chunk)
1550
+ total += len(chunk)
1551
+ if total > limit:
1552
+ capped = True
1553
+ break
1554
+ finally:
1555
+ if capped and proc.poll() is None:
1556
+ try:
1557
+ proc.terminate()
1558
+ except OSError:
1559
+ pass
1560
+ try:
1561
+ proc.stdout.close()
1562
+ except OSError:
1563
+ pass
1564
+
1565
+ thread = threading.Thread(target=reader, daemon=True)
1566
+ thread.start()
1567
+ thread.join(timeout_seconds)
1568
+ if thread.is_alive() and proc.poll() is None:
1569
+ timed_out = True
1570
+ try:
1571
+ proc.kill()
1572
+ except OSError:
1573
+ pass
1574
+ try:
1575
+ proc.wait(timeout=2)
1576
+ except subprocess.TimeoutExpired:
1577
+ try:
1578
+ proc.kill()
1579
+ except OSError:
1580
+ pass
1581
+ try:
1582
+ proc.wait(timeout=2)
1583
+ except subprocess.TimeoutExpired:
1584
+ pass
1585
+ thread.join(0.2)
1586
+ raw_output = b"".join(chunks)[:limit]
1587
+ complete = proc.returncode == 0 and not capped and not timed_out and raw_output.endswith(b"\0")
1588
+ return raw_output, complete
1589
+
1590
+ raw = b""
1591
+ git_returncode: int | None = None
1491
1592
  try:
1492
- proc = subprocess.run(
1593
+ proc = subprocess.Popen(
1493
1594
  ["git", "-C", str(root), "ls-files", "-z"],
1595
+ stdout=subprocess.PIPE,
1596
+ stderr=subprocess.DEVNULL,
1494
1597
  text=False,
1495
- capture_output=True,
1496
- timeout=10,
1497
- check=False,
1498
1598
  )
1599
+ raw, _git_complete = read_stdout_capped(proc, MAX_GIT_LS_FILES_OUTPUT_BYTES, 10)
1600
+ git_returncode = proc.returncode
1499
1601
  except (OSError, subprocess.TimeoutExpired):
1500
1602
  proc = None
1501
- if proc is not None and proc.returncode == 0:
1502
- raw = proc.stdout[: MAX_QUERY_SCAN_FILES * 512]
1603
+ if raw:
1604
+ if not raw.endswith(b"\0"):
1605
+ raw = raw.rsplit(b"\0", 1)[0] if b"\0" in raw else b""
1503
1606
  return [part.decode("utf-8", "replace") for part in raw.split(b"\0") if part][:MAX_QUERY_SCAN_FILES]
1607
+ if git_returncode == 0 or (git_returncode is not None and git_returncode < 0):
1608
+ return []
1504
1609
  out: list[str] = []
1505
1610
  skip_dirs = {".git", ".omx", ".context-guard", "node_modules", "dist", "build", "__pycache__"}
1506
1611
  for current, dirs, files in os.walk(root):
@@ -1884,6 +1989,155 @@ def score_gap_advice(scores: list[int], requested_top: int) -> tuple[int, dict[s
1884
1989
  return max(1, elbow_k), {"after_rank": gap_index + 1, "delta": max_gap, "ratio": ratio}, reasons
1885
1990
 
1886
1991
 
1992
+ def clamp_proxy(value: float) -> float:
1993
+ return min(1.0, max(0.0, round(value, 4)))
1994
+
1995
+
1996
+ def adaptive_policy_recommended_k(
1997
+ *,
1998
+ policy: str,
1999
+ requested_top: int,
2000
+ score_elbow_k: int,
2001
+ budget_fit_k: int,
2002
+ candidate_count: int,
2003
+ ) -> int:
2004
+ candidate_limit = min(max(0, candidate_count), MAX_SUGGEST_TOP)
2005
+ if candidate_limit == 0 or budget_fit_k <= 0:
2006
+ return 0
2007
+ if policy == "recall":
2008
+ policy_k = max(requested_top, score_elbow_k)
2009
+ elif policy == "precision":
2010
+ policy_k = min(score_elbow_k, requested_top)
2011
+ else:
2012
+ policy_k = score_elbow_k
2013
+ return min(max(0, policy_k), max(0, budget_fit_k), candidate_limit)
2014
+
2015
+
2016
+ def adaptive_path_label(value: object) -> str:
2017
+ raw = "" if value is None else str(value)
2018
+ if CONTROL_CHAR_RE.search(raw) or SECRET_CONTENT_RE.search(raw) or SECRET_PATH_COMPONENT_RE.search(raw):
2019
+ return f"redacted-path#path:{sha256_text(raw)[:12]}"
2020
+ rel, _reason = lexical_rel(raw)
2021
+ if rel is None:
2022
+ return safe_raw_path_label(raw)
2023
+ display, _redacted = display_rel_path(rel.as_posix())
2024
+ return display
2025
+
2026
+
2027
+ def actionable_adaptive_path(value: object) -> tuple[str | None, str | None]:
2028
+ raw = "" if value is None else str(value)
2029
+ if not raw:
2030
+ return None, "missing_path"
2031
+ if REDACTED_PATH_COMPONENT in raw or "[REDACTED" in raw:
2032
+ return None, "redacted_path"
2033
+ if CONTROL_CHAR_RE.search(raw) or SECRET_CONTENT_RE.search(raw) or SECRET_PATH_COMPONENT_RE.search(raw):
2034
+ return None, "unsafe_path"
2035
+ rel, reason = lexical_rel(raw)
2036
+ if rel is None:
2037
+ return None, reason or "unsafe_path"
2038
+ return rel.as_posix(), None
2039
+
2040
+
2041
+ def adaptive_lines(value: object) -> dict[str, int] | None:
2042
+ if not isinstance(value, dict):
2043
+ return None
2044
+ try:
2045
+ start = int(value.get("start"))
2046
+ end = int(value.get("end"))
2047
+ except (TypeError, ValueError, OverflowError):
2048
+ return None
2049
+ if start < 1 or end < start:
2050
+ return None
2051
+ return {"start": start, "end": end}
2052
+
2053
+
2054
+ def adaptive_retrieval_hint(item: dict[str, Any]) -> dict[str, Any]:
2055
+ path, path_reason = actionable_adaptive_path(item.get("path"))
2056
+ lines = adaptive_lines(item.get("lines") or item.get("included_lines") or item.get("requested_lines"))
2057
+ omitted_reason = item.get("retrieval_omitted_reason")
2058
+ if path_reason:
2059
+ return {"type": "slice", "available": False, "reason": str(omitted_reason or path_reason)}
2060
+ if lines is None:
2061
+ return {"type": "slice", "available": False, "reason": "missing_lines"}
2062
+ if not item.get("retrieval_cli"):
2063
+ return {"type": "slice", "available": False, "reason": str(omitted_reason or "missing_retrieval_hint")}
2064
+ return {"type": "slice", "available": True, "path": path, "lines": lines}
2065
+
2066
+
2067
+ def adaptive_selected_evidence(selected: list[dict[str, Any]]) -> list[dict[str, Any]]:
2068
+ evidence: list[dict[str, Any]] = []
2069
+ for rank, item in enumerate(selected[:MAX_ADAPTIVE_K_SELECTED_EVIDENCE], start=1):
2070
+ entry: dict[str, Any] = {
2071
+ "rank": rank,
2072
+ "path": adaptive_path_label(item.get("path")),
2073
+ "score": max(0, int(item.get("score", item.get("priority", 0)) or 0)),
2074
+ "reason": cap_label(item.get("reason"), default="local heuristic", limit=MAX_REASON_CHARS),
2075
+ "retrieval_hint": adaptive_retrieval_hint(item),
2076
+ }
2077
+ lines = adaptive_lines(item.get("lines"))
2078
+ if lines is not None:
2079
+ entry["lines"] = lines
2080
+ evidence.append(entry)
2081
+ return evidence
2082
+
2083
+
2084
+ def adaptive_omitted_evidence(omitted: list[dict[str, Any]]) -> dict[str, Any]:
2085
+ reason_counts: dict[str, int] = {}
2086
+ sources: list[dict[str, Any]] = []
2087
+ for item in omitted:
2088
+ reason = cap_label(item.get("reason"), default="unknown", limit=MAX_REASON_CHARS) or "unknown"
2089
+ reason_counts[reason] = reason_counts.get(reason, 0) + 1
2090
+ if len(sources) >= MAX_ADAPTIVE_K_OMITTED_EVIDENCE:
2091
+ continue
2092
+ source: dict[str, Any] = {
2093
+ "path": adaptive_path_label(item.get("path")),
2094
+ "reason": reason,
2095
+ "priority": max(0, int(item.get("priority", 0) or 0)),
2096
+ }
2097
+ lines = adaptive_lines(item.get("requested_lines") or item.get("lines"))
2098
+ if lines is not None:
2099
+ source["lines"] = lines
2100
+ hint = adaptive_retrieval_hint(item)
2101
+ if hint.get("available") or hint.get("reason") in {"redacted_path", "unsafe_root_path", "unsafe_path"}:
2102
+ source["retrieval_hint"] = hint
2103
+ sources.append(source)
2104
+ counts = [
2105
+ {"reason": reason, "count": count}
2106
+ for reason, count in sorted(reason_counts.items(), key=lambda pair: (-pair[1], pair[0]))[:MAX_ADAPTIVE_K_REASON_COUNTS]
2107
+ ]
2108
+ return {
2109
+ "omitted_count": len(omitted),
2110
+ "sources_capped": len(omitted) > len(sources),
2111
+ "sources": sources,
2112
+ "reason_counts": counts,
2113
+ }
2114
+
2115
+
2116
+ def adaptive_source_verification(selected: list[dict[str, Any]]) -> dict[str, Any]:
2117
+ hints: list[dict[str, Any]] = []
2118
+ available = 0
2119
+ for rank, item in enumerate(selected[:MAX_ADAPTIVE_K_VERIFICATION_HINTS], start=1):
2120
+ hint = adaptive_retrieval_hint(item)
2121
+ if hint.get("available"):
2122
+ available += 1
2123
+ record: dict[str, Any] = {
2124
+ "rank": rank,
2125
+ "path": adaptive_path_label(item.get("path")),
2126
+ "retrieval_hint": hint,
2127
+ }
2128
+ hints.append(record)
2129
+ return {
2130
+ "requires_exact_source_before_edits": True,
2131
+ "format": "structured_relative_slice_hints",
2132
+ "selected_count": len(selected),
2133
+ "hint_count": len(hints),
2134
+ "hints_capped": len(selected) > len(hints),
2135
+ "available_hint_count": available,
2136
+ "omitted_hint_count": len(hints) - available,
2137
+ "hints": hints,
2138
+ }
2139
+
2140
+
1887
2141
  def build_adaptive_k_advisory(
1888
2142
  *,
1889
2143
  candidates: list[SuggestCandidate],
@@ -1892,7 +2146,12 @@ def build_adaptive_k_advisory(
1892
2146
  requested_top: int,
1893
2147
  budget_bytes: int,
1894
2148
  estimated_pack_bytes: int,
2149
+ policy: str = "balanced",
2150
+ min_recall_proxy: float = 0.0,
2151
+ min_precision_proxy: float = 0.0,
1895
2152
  ) -> dict[str, Any]:
2153
+ if policy not in ADAPTIVE_K_POLICIES:
2154
+ policy = "balanced"
1896
2155
  sampled_candidates = candidates[:MAX_ADAPTIVE_K_SCORE_SAMPLES]
1897
2156
  scores = [max(0, int(candidate.score)) for candidate in sampled_candidates]
1898
2157
  score_elbow_k, max_gap_details, reason_codes = score_gap_advice(scores, requested_top)
@@ -1924,19 +2183,36 @@ def build_adaptive_k_advisory(
1924
2183
  if not candidates:
1925
2184
  recommended_k = 0
1926
2185
  else:
1927
- recommended_k = min(
1928
- max(0, score_elbow_k),
1929
- max(0, budget_fit_k),
1930
- len(candidates),
1931
- MAX_SUGGEST_TOP,
2186
+ recommended_k = adaptive_policy_recommended_k(
2187
+ policy=policy,
2188
+ requested_top=requested_top,
2189
+ score_elbow_k=score_elbow_k,
2190
+ budget_fit_k=budget_fit_k,
2191
+ candidate_count=len(candidates),
1932
2192
  )
1933
2193
  score_values_asc = sorted(scores)
1934
2194
  top_score = score_values_asc[-1] if score_values_asc else 0
2195
+ recall_proxy = clamp_proxy(selected_score_mass / analyzed_score_mass) if analyzed_score_mass else 0.0
2196
+ precision_proxy = (
2197
+ clamp_proxy((selected_score_mass / max(1, selected_count)) / max(1, top_score))
2198
+ if selected_count
2199
+ else 0.0
2200
+ )
2201
+ recall_gate_passed = recall_proxy >= min_recall_proxy
2202
+ precision_gate_passed = precision_proxy >= min_precision_proxy
2203
+ gate_status = "pass" if recall_gate_passed and precision_gate_passed else "failed"
1935
2204
  return {
1936
2205
  "schema_version": ADAPTIVE_K_SCHEMA_VERSION,
1937
2206
  "mode": "advisory",
1938
2207
  "requested_top": requested_top,
1939
2208
  "recommended_k": recommended_k,
2209
+ "policy": {
2210
+ "name": policy,
2211
+ "available_policies": list(ADAPTIVE_K_POLICIES),
2212
+ "changes_manifest_or_pack": False,
2213
+ "measurement_basis": "current_selected_sources_not_policy_applied_rebuild",
2214
+ "status": "evaluated",
2215
+ },
1940
2216
  "recommendation": {
1941
2217
  "apply": False,
1942
2218
  "reason_codes": sorted(set(reason_codes)),
@@ -1963,25 +2239,46 @@ def build_adaptive_k_advisory(
1963
2239
  "average_selected_bytes": average_selected_bytes,
1964
2240
  "budget_fit_k": budget_fit_k,
1965
2241
  },
2242
+ "regression_gates": {
2243
+ "status": gate_status,
2244
+ "measurement_basis": "current_selected_sources_not_policy_applied_rebuild",
2245
+ "comparison": "observed_greater_than_or_equal_threshold",
2246
+ "recall_proxy": {
2247
+ "observed": recall_proxy,
2248
+ "minimum": min_recall_proxy,
2249
+ "passed": recall_gate_passed,
2250
+ },
2251
+ "precision_proxy": {
2252
+ "observed": precision_proxy,
2253
+ "minimum": min_precision_proxy,
2254
+ "passed": precision_gate_passed,
2255
+ },
2256
+ },
1966
2257
  "recall_precision_proxy": {
1967
2258
  "measurement": "local_score_mass_proxy",
2259
+ "range": "clamped_0_1",
2260
+ "measurement_basis": "current_selected_sources_not_policy_applied_rebuild",
1968
2261
  "selected_score_mass": selected_score_mass,
1969
2262
  "analyzed_score_mass": analyzed_score_mass,
1970
- "recall_proxy": round(selected_score_mass / analyzed_score_mass, 4) if analyzed_score_mass else 0.0,
1971
- "precision_proxy": (
1972
- round((selected_score_mass / max(1, selected_count)) / max(1, top_score), 4)
1973
- if selected_count
1974
- else 0.0
1975
- ),
2263
+ "recall_proxy": recall_proxy,
2264
+ "precision_proxy": precision_proxy,
1976
2265
  "selected_count": selected_count,
1977
2266
  "candidate_count": len(candidates),
1978
2267
  },
2268
+ "selected_evidence": {
2269
+ "selected_count": selected_count,
2270
+ "items_capped": selected_count > MAX_ADAPTIVE_K_SELECTED_EVIDENCE,
2271
+ "items": adaptive_selected_evidence(selected),
2272
+ },
2273
+ "omitted_evidence": adaptive_omitted_evidence(omitted),
2274
+ "source_verification": adaptive_source_verification(selected),
1979
2275
  "claim_boundary": {
1980
2276
  "deterministic_local_only": True,
1981
2277
  "no_model_network_or_embedding": True,
1982
2278
  "token_counts_are_estimated_proxies": True,
1983
2279
  "provider_token_or_cost_savings_claim_allowed": False,
1984
2280
  "advisory_does_not_change_manifest_or_pack": True,
2281
+ "selectable_policy_changes_manifest_or_pack": False,
1985
2282
  },
1986
2283
  }
1987
2284
 
@@ -2152,6 +2449,9 @@ def suggest_pack(root: Path, args: argparse.Namespace, *, root_arg: str) -> tupl
2152
2449
  requested_top=top,
2153
2450
  budget_bytes=budget,
2154
2451
  estimated_pack_bytes=estimated_pack_bytes,
2452
+ policy=getattr(args, "adaptive_k_policy", "balanced"),
2453
+ min_recall_proxy=float(getattr(args, "adaptive_k_min_recall_proxy", 0.0) or 0.0),
2454
+ min_precision_proxy=float(getattr(args, "adaptive_k_min_precision_proxy", 0.0) or 0.0),
2155
2455
  )
2156
2456
  return payload, 0
2157
2457
 
@@ -3063,6 +3363,8 @@ def print_adaptive_k_text(payload: dict[str, Any]) -> None:
3063
3363
  else {}
3064
3364
  )
3065
3365
  budget_fit = adaptive.get("budget_fit", {}) if isinstance(adaptive.get("budget_fit"), dict) else {}
3366
+ policy = adaptive.get("policy", {}) if isinstance(adaptive.get("policy"), dict) else {}
3367
+ regression_gates = adaptive.get("regression_gates", {}) if isinstance(adaptive.get("regression_gates"), dict) else {}
3066
3368
  reason_codes = recommendation.get("reason_codes", [])
3067
3369
  if isinstance(reason_codes, list):
3068
3370
  reason_text = ",".join(str(item) for item in reason_codes[:5])
@@ -3071,6 +3373,8 @@ def print_adaptive_k_text(payload: dict[str, Any]) -> None:
3071
3373
  print(
3072
3374
  "adaptive-k: "
3073
3375
  f"recommended={adaptive.get('recommended_k', 0)}/{adaptive.get('requested_top', 0)} "
3376
+ f"policy={policy.get('name', 'balanced')} "
3377
+ f"gates={regression_gates.get('status', 'pass')} "
3074
3378
  f"candidates={score_distribution.get('candidate_count', 0)} "
3075
3379
  f"budget_limited={budget_fit.get('budget_limited', False)} "
3076
3380
  f"apply=false reasons={reason_text or 'none'}"
@@ -3190,6 +3494,9 @@ def build_parser() -> argparse.ArgumentParser:
3190
3494
  suggest.add_argument("--context-lines", type=int, default=DEFAULT_SUGGEST_CONTEXT_LINES, help="line context around diff/output hits")
3191
3495
  suggest.add_argument("--manifest-out", help="write the suggested build manifest to this relative path under root")
3192
3496
  suggest.add_argument("--adaptive-k", action="store_true", help="include local score/budget top-k advisory metadata without changing the manifest")
3497
+ suggest.add_argument("--adaptive-k-policy", choices=ADAPTIVE_K_POLICIES, default="balanced", help="local adaptive-k recommendation policy used when --adaptive-k is set")
3498
+ suggest.add_argument("--adaptive-k-min-recall-proxy", type=adaptive_k_threshold, default=0.0, help="metadata-only minimum recall proxy gate for --adaptive-k")
3499
+ suggest.add_argument("--adaptive-k-min-precision-proxy", type=adaptive_k_threshold, default=0.0, help="metadata-only minimum precision proxy gate for --adaptive-k")
3193
3500
  suggest.add_argument("--json", action="store_true", help="emit JSON payload")
3194
3501
  auto = sub.add_parser("auto", help="suggest a context pack manifest and build the budgeted pack in one local step")
3195
3502
  auto.add_argument("--root", default=".", help="project root; must not be a symlink")
@@ -3207,6 +3514,9 @@ def build_parser() -> argparse.ArgumentParser:
3207
3514
  auto.add_argument("--no-artifact", action="store_true", help="do not write .context-guard/packs receipt")
3208
3515
  auto.add_argument("--explain", action="store_true", help="include deterministic local selection/build explanation metadata")
3209
3516
  auto.add_argument("--adaptive-k", action="store_true", help="include local score/budget top-k advisory metadata without changing the manifest or pack")
3517
+ auto.add_argument("--adaptive-k-policy", choices=ADAPTIVE_K_POLICIES, default="balanced", help="local adaptive-k recommendation policy used when --adaptive-k is set")
3518
+ auto.add_argument("--adaptive-k-min-recall-proxy", type=adaptive_k_threshold, default=0.0, help="metadata-only minimum recall proxy gate for --adaptive-k")
3519
+ auto.add_argument("--adaptive-k-min-precision-proxy", type=adaptive_k_threshold, default=0.0, help="metadata-only minimum precision proxy gate for --adaptive-k")
3210
3520
  auto.add_argument("--symbol-memory", action="store_true", help="include repo-map derived symbol/graph advisory metadata with exact source verification hints")
3211
3521
  return parser
3212
3522