@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.
- package/CHANGELOG.md +17 -1
- package/README.ko.md +46 -28
- package/README.md +42 -33
- package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
- package/docs/benchmark-workflow-examples.md +3 -0
- package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
- package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
- package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
- package/docs/experimental-benchmark-fixtures.md +24 -7
- package/package.json +2 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +14 -11
- package/plugins/context-guard/README.md +15 -14
- package/plugins/context-guard/bin/context-guard +48 -17
- package/plugins/context-guard/bin/context-guard-artifact +342 -33
- package/plugins/context-guard/bin/context-guard-audit +36 -5
- package/plugins/context-guard/bin/context-guard-bench +1675 -44
- package/plugins/context-guard/bin/context-guard-cache-score +347 -35
- package/plugins/context-guard/bin/context-guard-compress +89 -27
- package/plugins/context-guard/bin/context-guard-cost +7 -2
- package/plugins/context-guard/bin/context-guard-experiments +364 -8
- package/plugins/context-guard/bin/context-guard-failed-nudge +6 -2
- package/plugins/context-guard/bin/context-guard-filter +88 -18
- package/plugins/context-guard/bin/context-guard-pack +329 -19
- package/plugins/context-guard/bin/context-guard-read-symbol +27 -0
- package/plugins/context-guard/bin/context-guard-sanitize-output +245 -18
- package/plugins/context-guard/bin/context-guard-setup +21 -5
- package/plugins/context-guard/bin/context-guard-tool-prune +287 -62
- package/plugins/context-guard/bin/context-guard-trim-output +394 -90
- package/plugins/context-guard/brief/README.md +5 -5
- package/plugins/context-guard/lib/context_guard_command_manifest_loader.py +123 -0
- 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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
468
|
-
tail
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
750
|
-
|
|
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
|
|
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":
|
|
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 =
|
|
1100
|
-
capped =
|
|
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.
|
|
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
|
|
1502
|
-
|
|
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 =
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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":
|
|
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
|
|