@ictechgy/context-guard 0.4.10 → 0.4.11
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 +13 -1
- package/README.ko.md +32 -21
- package/README.md +38 -29
- 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 +46 -11
- package/plugins/context-guard/bin/context-guard-artifact +342 -33
- package/plugins/context-guard/bin/context-guard-audit +33 -2
- package/plugins/context-guard/bin/context-guard-bench +1542 -31
- package/plugins/context-guard/bin/context-guard-cache-score +318 -33
- 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-pack +301 -17
- package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
- package/plugins/context-guard/bin/context-guard-tool-prune +241 -54
- package/plugins/context-guard/bin/context-guard-trim-output +288 -41
- package/plugins/context-guard/brief/README.md +5 -5
- package/plugins/context-guard/lib/context_guard_commands.py +214 -190
|
@@ -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
|
|
@@ -1488,19 +1505,81 @@ def collect_output_candidates(
|
|
|
1488
1505
|
|
|
1489
1506
|
|
|
1490
1507
|
def git_ls_files(root: Path) -> list[str]:
|
|
1508
|
+
def read_stdout_capped(proc: subprocess.Popen[bytes], limit: int, timeout_seconds: float) -> tuple[bytes, bool]:
|
|
1509
|
+
if proc.stdout is None:
|
|
1510
|
+
return b"", False
|
|
1511
|
+
chunks: list[bytes] = []
|
|
1512
|
+
total = 0
|
|
1513
|
+
capped = False
|
|
1514
|
+
timed_out = False
|
|
1515
|
+
|
|
1516
|
+
def reader() -> None:
|
|
1517
|
+
nonlocal total, capped
|
|
1518
|
+
try:
|
|
1519
|
+
while total <= limit:
|
|
1520
|
+
chunk = proc.stdout.read(min(GIT_LS_FILES_READ_CHUNK_BYTES, limit + 1 - total))
|
|
1521
|
+
if not chunk:
|
|
1522
|
+
break
|
|
1523
|
+
chunks.append(chunk)
|
|
1524
|
+
total += len(chunk)
|
|
1525
|
+
if total > limit:
|
|
1526
|
+
capped = True
|
|
1527
|
+
break
|
|
1528
|
+
finally:
|
|
1529
|
+
if capped and proc.poll() is None:
|
|
1530
|
+
try:
|
|
1531
|
+
proc.terminate()
|
|
1532
|
+
except OSError:
|
|
1533
|
+
pass
|
|
1534
|
+
try:
|
|
1535
|
+
proc.stdout.close()
|
|
1536
|
+
except OSError:
|
|
1537
|
+
pass
|
|
1538
|
+
|
|
1539
|
+
thread = threading.Thread(target=reader, daemon=True)
|
|
1540
|
+
thread.start()
|
|
1541
|
+
thread.join(timeout_seconds)
|
|
1542
|
+
if thread.is_alive() and proc.poll() is None:
|
|
1543
|
+
timed_out = True
|
|
1544
|
+
try:
|
|
1545
|
+
proc.kill()
|
|
1546
|
+
except OSError:
|
|
1547
|
+
pass
|
|
1548
|
+
try:
|
|
1549
|
+
proc.wait(timeout=2)
|
|
1550
|
+
except subprocess.TimeoutExpired:
|
|
1551
|
+
try:
|
|
1552
|
+
proc.kill()
|
|
1553
|
+
except OSError:
|
|
1554
|
+
pass
|
|
1555
|
+
try:
|
|
1556
|
+
proc.wait(timeout=2)
|
|
1557
|
+
except subprocess.TimeoutExpired:
|
|
1558
|
+
pass
|
|
1559
|
+
thread.join(0.2)
|
|
1560
|
+
raw_output = b"".join(chunks)[:limit]
|
|
1561
|
+
complete = proc.returncode == 0 and not capped and not timed_out and raw_output.endswith(b"\0")
|
|
1562
|
+
return raw_output, complete
|
|
1563
|
+
|
|
1564
|
+
raw = b""
|
|
1565
|
+
git_returncode: int | None = None
|
|
1491
1566
|
try:
|
|
1492
|
-
proc = subprocess.
|
|
1567
|
+
proc = subprocess.Popen(
|
|
1493
1568
|
["git", "-C", str(root), "ls-files", "-z"],
|
|
1569
|
+
stdout=subprocess.PIPE,
|
|
1570
|
+
stderr=subprocess.DEVNULL,
|
|
1494
1571
|
text=False,
|
|
1495
|
-
capture_output=True,
|
|
1496
|
-
timeout=10,
|
|
1497
|
-
check=False,
|
|
1498
1572
|
)
|
|
1573
|
+
raw, _git_complete = read_stdout_capped(proc, MAX_GIT_LS_FILES_OUTPUT_BYTES, 10)
|
|
1574
|
+
git_returncode = proc.returncode
|
|
1499
1575
|
except (OSError, subprocess.TimeoutExpired):
|
|
1500
1576
|
proc = None
|
|
1501
|
-
if
|
|
1502
|
-
|
|
1577
|
+
if raw:
|
|
1578
|
+
if not raw.endswith(b"\0"):
|
|
1579
|
+
raw = raw.rsplit(b"\0", 1)[0] if b"\0" in raw else b""
|
|
1503
1580
|
return [part.decode("utf-8", "replace") for part in raw.split(b"\0") if part][:MAX_QUERY_SCAN_FILES]
|
|
1581
|
+
if git_returncode == 0 or (git_returncode is not None and git_returncode < 0):
|
|
1582
|
+
return []
|
|
1504
1583
|
out: list[str] = []
|
|
1505
1584
|
skip_dirs = {".git", ".omx", ".context-guard", "node_modules", "dist", "build", "__pycache__"}
|
|
1506
1585
|
for current, dirs, files in os.walk(root):
|
|
@@ -1884,6 +1963,155 @@ def score_gap_advice(scores: list[int], requested_top: int) -> tuple[int, dict[s
|
|
|
1884
1963
|
return max(1, elbow_k), {"after_rank": gap_index + 1, "delta": max_gap, "ratio": ratio}, reasons
|
|
1885
1964
|
|
|
1886
1965
|
|
|
1966
|
+
def clamp_proxy(value: float) -> float:
|
|
1967
|
+
return min(1.0, max(0.0, round(value, 4)))
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def adaptive_policy_recommended_k(
|
|
1971
|
+
*,
|
|
1972
|
+
policy: str,
|
|
1973
|
+
requested_top: int,
|
|
1974
|
+
score_elbow_k: int,
|
|
1975
|
+
budget_fit_k: int,
|
|
1976
|
+
candidate_count: int,
|
|
1977
|
+
) -> int:
|
|
1978
|
+
candidate_limit = min(max(0, candidate_count), MAX_SUGGEST_TOP)
|
|
1979
|
+
if candidate_limit == 0 or budget_fit_k <= 0:
|
|
1980
|
+
return 0
|
|
1981
|
+
if policy == "recall":
|
|
1982
|
+
policy_k = max(requested_top, score_elbow_k)
|
|
1983
|
+
elif policy == "precision":
|
|
1984
|
+
policy_k = min(score_elbow_k, requested_top)
|
|
1985
|
+
else:
|
|
1986
|
+
policy_k = score_elbow_k
|
|
1987
|
+
return min(max(0, policy_k), max(0, budget_fit_k), candidate_limit)
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
def adaptive_path_label(value: object) -> str:
|
|
1991
|
+
raw = "" if value is None else str(value)
|
|
1992
|
+
if CONTROL_CHAR_RE.search(raw) or SECRET_CONTENT_RE.search(raw) or SECRET_PATH_COMPONENT_RE.search(raw):
|
|
1993
|
+
return f"redacted-path#path:{sha256_text(raw)[:12]}"
|
|
1994
|
+
rel, _reason = lexical_rel(raw)
|
|
1995
|
+
if rel is None:
|
|
1996
|
+
return safe_raw_path_label(raw)
|
|
1997
|
+
display, _redacted = display_rel_path(rel.as_posix())
|
|
1998
|
+
return display
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def actionable_adaptive_path(value: object) -> tuple[str | None, str | None]:
|
|
2002
|
+
raw = "" if value is None else str(value)
|
|
2003
|
+
if not raw:
|
|
2004
|
+
return None, "missing_path"
|
|
2005
|
+
if REDACTED_PATH_COMPONENT in raw or "[REDACTED" in raw:
|
|
2006
|
+
return None, "redacted_path"
|
|
2007
|
+
if CONTROL_CHAR_RE.search(raw) or SECRET_CONTENT_RE.search(raw) or SECRET_PATH_COMPONENT_RE.search(raw):
|
|
2008
|
+
return None, "unsafe_path"
|
|
2009
|
+
rel, reason = lexical_rel(raw)
|
|
2010
|
+
if rel is None:
|
|
2011
|
+
return None, reason or "unsafe_path"
|
|
2012
|
+
return rel.as_posix(), None
|
|
2013
|
+
|
|
2014
|
+
|
|
2015
|
+
def adaptive_lines(value: object) -> dict[str, int] | None:
|
|
2016
|
+
if not isinstance(value, dict):
|
|
2017
|
+
return None
|
|
2018
|
+
try:
|
|
2019
|
+
start = int(value.get("start"))
|
|
2020
|
+
end = int(value.get("end"))
|
|
2021
|
+
except (TypeError, ValueError, OverflowError):
|
|
2022
|
+
return None
|
|
2023
|
+
if start < 1 or end < start:
|
|
2024
|
+
return None
|
|
2025
|
+
return {"start": start, "end": end}
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
def adaptive_retrieval_hint(item: dict[str, Any]) -> dict[str, Any]:
|
|
2029
|
+
path, path_reason = actionable_adaptive_path(item.get("path"))
|
|
2030
|
+
lines = adaptive_lines(item.get("lines") or item.get("included_lines") or item.get("requested_lines"))
|
|
2031
|
+
omitted_reason = item.get("retrieval_omitted_reason")
|
|
2032
|
+
if path_reason:
|
|
2033
|
+
return {"type": "slice", "available": False, "reason": str(omitted_reason or path_reason)}
|
|
2034
|
+
if lines is None:
|
|
2035
|
+
return {"type": "slice", "available": False, "reason": "missing_lines"}
|
|
2036
|
+
if not item.get("retrieval_cli"):
|
|
2037
|
+
return {"type": "slice", "available": False, "reason": str(omitted_reason or "missing_retrieval_hint")}
|
|
2038
|
+
return {"type": "slice", "available": True, "path": path, "lines": lines}
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
def adaptive_selected_evidence(selected: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
2042
|
+
evidence: list[dict[str, Any]] = []
|
|
2043
|
+
for rank, item in enumerate(selected[:MAX_ADAPTIVE_K_SELECTED_EVIDENCE], start=1):
|
|
2044
|
+
entry: dict[str, Any] = {
|
|
2045
|
+
"rank": rank,
|
|
2046
|
+
"path": adaptive_path_label(item.get("path")),
|
|
2047
|
+
"score": max(0, int(item.get("score", item.get("priority", 0)) or 0)),
|
|
2048
|
+
"reason": cap_label(item.get("reason"), default="local heuristic", limit=MAX_REASON_CHARS),
|
|
2049
|
+
"retrieval_hint": adaptive_retrieval_hint(item),
|
|
2050
|
+
}
|
|
2051
|
+
lines = adaptive_lines(item.get("lines"))
|
|
2052
|
+
if lines is not None:
|
|
2053
|
+
entry["lines"] = lines
|
|
2054
|
+
evidence.append(entry)
|
|
2055
|
+
return evidence
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
def adaptive_omitted_evidence(omitted: list[dict[str, Any]]) -> dict[str, Any]:
|
|
2059
|
+
reason_counts: dict[str, int] = {}
|
|
2060
|
+
sources: list[dict[str, Any]] = []
|
|
2061
|
+
for item in omitted:
|
|
2062
|
+
reason = cap_label(item.get("reason"), default="unknown", limit=MAX_REASON_CHARS) or "unknown"
|
|
2063
|
+
reason_counts[reason] = reason_counts.get(reason, 0) + 1
|
|
2064
|
+
if len(sources) >= MAX_ADAPTIVE_K_OMITTED_EVIDENCE:
|
|
2065
|
+
continue
|
|
2066
|
+
source: dict[str, Any] = {
|
|
2067
|
+
"path": adaptive_path_label(item.get("path")),
|
|
2068
|
+
"reason": reason,
|
|
2069
|
+
"priority": max(0, int(item.get("priority", 0) or 0)),
|
|
2070
|
+
}
|
|
2071
|
+
lines = adaptive_lines(item.get("requested_lines") or item.get("lines"))
|
|
2072
|
+
if lines is not None:
|
|
2073
|
+
source["lines"] = lines
|
|
2074
|
+
hint = adaptive_retrieval_hint(item)
|
|
2075
|
+
if hint.get("available") or hint.get("reason") in {"redacted_path", "unsafe_root_path", "unsafe_path"}:
|
|
2076
|
+
source["retrieval_hint"] = hint
|
|
2077
|
+
sources.append(source)
|
|
2078
|
+
counts = [
|
|
2079
|
+
{"reason": reason, "count": count}
|
|
2080
|
+
for reason, count in sorted(reason_counts.items(), key=lambda pair: (-pair[1], pair[0]))[:MAX_ADAPTIVE_K_REASON_COUNTS]
|
|
2081
|
+
]
|
|
2082
|
+
return {
|
|
2083
|
+
"omitted_count": len(omitted),
|
|
2084
|
+
"sources_capped": len(omitted) > len(sources),
|
|
2085
|
+
"sources": sources,
|
|
2086
|
+
"reason_counts": counts,
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
|
|
2090
|
+
def adaptive_source_verification(selected: list[dict[str, Any]]) -> dict[str, Any]:
|
|
2091
|
+
hints: list[dict[str, Any]] = []
|
|
2092
|
+
available = 0
|
|
2093
|
+
for rank, item in enumerate(selected[:MAX_ADAPTIVE_K_VERIFICATION_HINTS], start=1):
|
|
2094
|
+
hint = adaptive_retrieval_hint(item)
|
|
2095
|
+
if hint.get("available"):
|
|
2096
|
+
available += 1
|
|
2097
|
+
record: dict[str, Any] = {
|
|
2098
|
+
"rank": rank,
|
|
2099
|
+
"path": adaptive_path_label(item.get("path")),
|
|
2100
|
+
"retrieval_hint": hint,
|
|
2101
|
+
}
|
|
2102
|
+
hints.append(record)
|
|
2103
|
+
return {
|
|
2104
|
+
"requires_exact_source_before_edits": True,
|
|
2105
|
+
"format": "structured_relative_slice_hints",
|
|
2106
|
+
"selected_count": len(selected),
|
|
2107
|
+
"hint_count": len(hints),
|
|
2108
|
+
"hints_capped": len(selected) > len(hints),
|
|
2109
|
+
"available_hint_count": available,
|
|
2110
|
+
"omitted_hint_count": len(hints) - available,
|
|
2111
|
+
"hints": hints,
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
|
|
1887
2115
|
def build_adaptive_k_advisory(
|
|
1888
2116
|
*,
|
|
1889
2117
|
candidates: list[SuggestCandidate],
|
|
@@ -1892,7 +2120,12 @@ def build_adaptive_k_advisory(
|
|
|
1892
2120
|
requested_top: int,
|
|
1893
2121
|
budget_bytes: int,
|
|
1894
2122
|
estimated_pack_bytes: int,
|
|
2123
|
+
policy: str = "balanced",
|
|
2124
|
+
min_recall_proxy: float = 0.0,
|
|
2125
|
+
min_precision_proxy: float = 0.0,
|
|
1895
2126
|
) -> dict[str, Any]:
|
|
2127
|
+
if policy not in ADAPTIVE_K_POLICIES:
|
|
2128
|
+
policy = "balanced"
|
|
1896
2129
|
sampled_candidates = candidates[:MAX_ADAPTIVE_K_SCORE_SAMPLES]
|
|
1897
2130
|
scores = [max(0, int(candidate.score)) for candidate in sampled_candidates]
|
|
1898
2131
|
score_elbow_k, max_gap_details, reason_codes = score_gap_advice(scores, requested_top)
|
|
@@ -1924,19 +2157,36 @@ def build_adaptive_k_advisory(
|
|
|
1924
2157
|
if not candidates:
|
|
1925
2158
|
recommended_k = 0
|
|
1926
2159
|
else:
|
|
1927
|
-
recommended_k =
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
2160
|
+
recommended_k = adaptive_policy_recommended_k(
|
|
2161
|
+
policy=policy,
|
|
2162
|
+
requested_top=requested_top,
|
|
2163
|
+
score_elbow_k=score_elbow_k,
|
|
2164
|
+
budget_fit_k=budget_fit_k,
|
|
2165
|
+
candidate_count=len(candidates),
|
|
1932
2166
|
)
|
|
1933
2167
|
score_values_asc = sorted(scores)
|
|
1934
2168
|
top_score = score_values_asc[-1] if score_values_asc else 0
|
|
2169
|
+
recall_proxy = clamp_proxy(selected_score_mass / analyzed_score_mass) if analyzed_score_mass else 0.0
|
|
2170
|
+
precision_proxy = (
|
|
2171
|
+
clamp_proxy((selected_score_mass / max(1, selected_count)) / max(1, top_score))
|
|
2172
|
+
if selected_count
|
|
2173
|
+
else 0.0
|
|
2174
|
+
)
|
|
2175
|
+
recall_gate_passed = recall_proxy >= min_recall_proxy
|
|
2176
|
+
precision_gate_passed = precision_proxy >= min_precision_proxy
|
|
2177
|
+
gate_status = "pass" if recall_gate_passed and precision_gate_passed else "failed"
|
|
1935
2178
|
return {
|
|
1936
2179
|
"schema_version": ADAPTIVE_K_SCHEMA_VERSION,
|
|
1937
2180
|
"mode": "advisory",
|
|
1938
2181
|
"requested_top": requested_top,
|
|
1939
2182
|
"recommended_k": recommended_k,
|
|
2183
|
+
"policy": {
|
|
2184
|
+
"name": policy,
|
|
2185
|
+
"available_policies": list(ADAPTIVE_K_POLICIES),
|
|
2186
|
+
"changes_manifest_or_pack": False,
|
|
2187
|
+
"measurement_basis": "current_selected_sources_not_policy_applied_rebuild",
|
|
2188
|
+
"status": "evaluated",
|
|
2189
|
+
},
|
|
1940
2190
|
"recommendation": {
|
|
1941
2191
|
"apply": False,
|
|
1942
2192
|
"reason_codes": sorted(set(reason_codes)),
|
|
@@ -1963,25 +2213,46 @@ def build_adaptive_k_advisory(
|
|
|
1963
2213
|
"average_selected_bytes": average_selected_bytes,
|
|
1964
2214
|
"budget_fit_k": budget_fit_k,
|
|
1965
2215
|
},
|
|
2216
|
+
"regression_gates": {
|
|
2217
|
+
"status": gate_status,
|
|
2218
|
+
"measurement_basis": "current_selected_sources_not_policy_applied_rebuild",
|
|
2219
|
+
"comparison": "observed_greater_than_or_equal_threshold",
|
|
2220
|
+
"recall_proxy": {
|
|
2221
|
+
"observed": recall_proxy,
|
|
2222
|
+
"minimum": min_recall_proxy,
|
|
2223
|
+
"passed": recall_gate_passed,
|
|
2224
|
+
},
|
|
2225
|
+
"precision_proxy": {
|
|
2226
|
+
"observed": precision_proxy,
|
|
2227
|
+
"minimum": min_precision_proxy,
|
|
2228
|
+
"passed": precision_gate_passed,
|
|
2229
|
+
},
|
|
2230
|
+
},
|
|
1966
2231
|
"recall_precision_proxy": {
|
|
1967
2232
|
"measurement": "local_score_mass_proxy",
|
|
2233
|
+
"range": "clamped_0_1",
|
|
2234
|
+
"measurement_basis": "current_selected_sources_not_policy_applied_rebuild",
|
|
1968
2235
|
"selected_score_mass": selected_score_mass,
|
|
1969
2236
|
"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
|
-
),
|
|
2237
|
+
"recall_proxy": recall_proxy,
|
|
2238
|
+
"precision_proxy": precision_proxy,
|
|
1976
2239
|
"selected_count": selected_count,
|
|
1977
2240
|
"candidate_count": len(candidates),
|
|
1978
2241
|
},
|
|
2242
|
+
"selected_evidence": {
|
|
2243
|
+
"selected_count": selected_count,
|
|
2244
|
+
"items_capped": selected_count > MAX_ADAPTIVE_K_SELECTED_EVIDENCE,
|
|
2245
|
+
"items": adaptive_selected_evidence(selected),
|
|
2246
|
+
},
|
|
2247
|
+
"omitted_evidence": adaptive_omitted_evidence(omitted),
|
|
2248
|
+
"source_verification": adaptive_source_verification(selected),
|
|
1979
2249
|
"claim_boundary": {
|
|
1980
2250
|
"deterministic_local_only": True,
|
|
1981
2251
|
"no_model_network_or_embedding": True,
|
|
1982
2252
|
"token_counts_are_estimated_proxies": True,
|
|
1983
2253
|
"provider_token_or_cost_savings_claim_allowed": False,
|
|
1984
2254
|
"advisory_does_not_change_manifest_or_pack": True,
|
|
2255
|
+
"selectable_policy_changes_manifest_or_pack": False,
|
|
1985
2256
|
},
|
|
1986
2257
|
}
|
|
1987
2258
|
|
|
@@ -2152,6 +2423,9 @@ def suggest_pack(root: Path, args: argparse.Namespace, *, root_arg: str) -> tupl
|
|
|
2152
2423
|
requested_top=top,
|
|
2153
2424
|
budget_bytes=budget,
|
|
2154
2425
|
estimated_pack_bytes=estimated_pack_bytes,
|
|
2426
|
+
policy=getattr(args, "adaptive_k_policy", "balanced"),
|
|
2427
|
+
min_recall_proxy=float(getattr(args, "adaptive_k_min_recall_proxy", 0.0) or 0.0),
|
|
2428
|
+
min_precision_proxy=float(getattr(args, "adaptive_k_min_precision_proxy", 0.0) or 0.0),
|
|
2155
2429
|
)
|
|
2156
2430
|
return payload, 0
|
|
2157
2431
|
|
|
@@ -3063,6 +3337,8 @@ def print_adaptive_k_text(payload: dict[str, Any]) -> None:
|
|
|
3063
3337
|
else {}
|
|
3064
3338
|
)
|
|
3065
3339
|
budget_fit = adaptive.get("budget_fit", {}) if isinstance(adaptive.get("budget_fit"), dict) else {}
|
|
3340
|
+
policy = adaptive.get("policy", {}) if isinstance(adaptive.get("policy"), dict) else {}
|
|
3341
|
+
regression_gates = adaptive.get("regression_gates", {}) if isinstance(adaptive.get("regression_gates"), dict) else {}
|
|
3066
3342
|
reason_codes = recommendation.get("reason_codes", [])
|
|
3067
3343
|
if isinstance(reason_codes, list):
|
|
3068
3344
|
reason_text = ",".join(str(item) for item in reason_codes[:5])
|
|
@@ -3071,6 +3347,8 @@ def print_adaptive_k_text(payload: dict[str, Any]) -> None:
|
|
|
3071
3347
|
print(
|
|
3072
3348
|
"adaptive-k: "
|
|
3073
3349
|
f"recommended={adaptive.get('recommended_k', 0)}/{adaptive.get('requested_top', 0)} "
|
|
3350
|
+
f"policy={policy.get('name', 'balanced')} "
|
|
3351
|
+
f"gates={regression_gates.get('status', 'pass')} "
|
|
3074
3352
|
f"candidates={score_distribution.get('candidate_count', 0)} "
|
|
3075
3353
|
f"budget_limited={budget_fit.get('budget_limited', False)} "
|
|
3076
3354
|
f"apply=false reasons={reason_text or 'none'}"
|
|
@@ -3190,6 +3468,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3190
3468
|
suggest.add_argument("--context-lines", type=int, default=DEFAULT_SUGGEST_CONTEXT_LINES, help="line context around diff/output hits")
|
|
3191
3469
|
suggest.add_argument("--manifest-out", help="write the suggested build manifest to this relative path under root")
|
|
3192
3470
|
suggest.add_argument("--adaptive-k", action="store_true", help="include local score/budget top-k advisory metadata without changing the manifest")
|
|
3471
|
+
suggest.add_argument("--adaptive-k-policy", choices=ADAPTIVE_K_POLICIES, default="balanced", help="local adaptive-k recommendation policy used when --adaptive-k is set")
|
|
3472
|
+
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")
|
|
3473
|
+
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
3474
|
suggest.add_argument("--json", action="store_true", help="emit JSON payload")
|
|
3194
3475
|
auto = sub.add_parser("auto", help="suggest a context pack manifest and build the budgeted pack in one local step")
|
|
3195
3476
|
auto.add_argument("--root", default=".", help="project root; must not be a symlink")
|
|
@@ -3207,6 +3488,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3207
3488
|
auto.add_argument("--no-artifact", action="store_true", help="do not write .context-guard/packs receipt")
|
|
3208
3489
|
auto.add_argument("--explain", action="store_true", help="include deterministic local selection/build explanation metadata")
|
|
3209
3490
|
auto.add_argument("--adaptive-k", action="store_true", help="include local score/budget top-k advisory metadata without changing the manifest or pack")
|
|
3491
|
+
auto.add_argument("--adaptive-k-policy", choices=ADAPTIVE_K_POLICIES, default="balanced", help="local adaptive-k recommendation policy used when --adaptive-k is set")
|
|
3492
|
+
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")
|
|
3493
|
+
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
3494
|
auto.add_argument("--symbol-memory", action="store_true", help="include repo-map derived symbol/graph advisory metadata with exact source verification hints")
|
|
3211
3495
|
return parser
|
|
3212
3496
|
|
|
@@ -8,6 +8,7 @@ keeps only bounded head/anchor/tail context when output is too large.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
|
+
import codecs
|
|
11
12
|
import collections
|
|
12
13
|
import hashlib
|
|
13
14
|
import os
|
|
@@ -19,7 +20,7 @@ import subprocess
|
|
|
19
20
|
import sys
|
|
20
21
|
import threading
|
|
21
22
|
import time
|
|
22
|
-
from typing import Iterable, Iterator, TextIO
|
|
23
|
+
from typing import BinaryIO, Iterable, Iterator, TextIO
|
|
23
24
|
|
|
24
25
|
TERMINAL_CONTROL_RE = re.compile(
|
|
25
26
|
r"(?:"
|
|
@@ -112,6 +113,9 @@ MAX_SECTION_LINES_LIMIT = 2_000
|
|
|
112
113
|
DEFAULT_TIMEOUT_SECONDS = 600
|
|
113
114
|
MAX_TIMEOUT_SECONDS = 86_400
|
|
114
115
|
TIMEOUT_EXIT_CODE = 124
|
|
116
|
+
COMMAND_READ_CHUNK_BYTES = 64 * 1024
|
|
117
|
+
COMMAND_MAX_UNTERMINATED_LINE_CHARS = 4_096
|
|
118
|
+
RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS = 1_024
|
|
115
119
|
|
|
116
120
|
|
|
117
121
|
def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
|
|
@@ -520,14 +524,16 @@ def terminate_process_tree(
|
|
|
520
524
|
class TimedCommandStream:
|
|
521
525
|
def __init__(
|
|
522
526
|
self,
|
|
523
|
-
proc: subprocess.Popen[
|
|
524
|
-
stdout:
|
|
527
|
+
proc: subprocess.Popen[bytes],
|
|
528
|
+
stdout: BinaryIO,
|
|
525
529
|
*,
|
|
526
530
|
timeout_seconds: int,
|
|
531
|
+
max_line_chars: int = MAX_LINE_CHARS_LIMIT,
|
|
527
532
|
process_group_id: int | None = None,
|
|
528
533
|
) -> None:
|
|
529
534
|
self.proc = proc
|
|
530
535
|
self.timeout_seconds = timeout_seconds
|
|
536
|
+
self.max_unterminated_line_chars = max(1, max_line_chars)
|
|
531
537
|
self.process_group_id = process_group_id
|
|
532
538
|
self.deadline = time.monotonic() + timeout_seconds
|
|
533
539
|
self.timed_out = False
|
|
@@ -537,10 +543,62 @@ class TimedCommandStream:
|
|
|
537
543
|
self._thread = threading.Thread(target=self._read_stdout, args=(stdout,), daemon=True)
|
|
538
544
|
self._thread.start()
|
|
539
545
|
|
|
540
|
-
def
|
|
546
|
+
def _truncated_raw_line(self, text: str) -> str:
|
|
547
|
+
holdback = min(RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS, self.max_unterminated_line_chars)
|
|
548
|
+
safe_keep = max(0, self.max_unterminated_line_chars - holdback)
|
|
549
|
+
return (
|
|
550
|
+
text[:safe_keep]
|
|
551
|
+
+ (
|
|
552
|
+
"...[context-guard-kit: raw line truncated before newline "
|
|
553
|
+
f"after {self.max_unterminated_line_chars} chars; "
|
|
554
|
+
f"withheld {holdback} boundary chars for redaction safety]\n"
|
|
555
|
+
)
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def _read_stdout(self, stdout: BinaryIO) -> None:
|
|
559
|
+
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
|
560
|
+
pending = ""
|
|
561
|
+
discarding_oversized_line = False
|
|
562
|
+
|
|
563
|
+
def feed(text: str) -> None:
|
|
564
|
+
nonlocal pending, discarding_oversized_line
|
|
565
|
+
if not text:
|
|
566
|
+
return
|
|
567
|
+
pending += text
|
|
568
|
+
while pending:
|
|
569
|
+
if discarding_oversized_line:
|
|
570
|
+
newline_index = pending.find("\n")
|
|
571
|
+
if newline_index == -1:
|
|
572
|
+
pending = ""
|
|
573
|
+
return
|
|
574
|
+
pending = pending[newline_index + 1 :]
|
|
575
|
+
discarding_oversized_line = False
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
newline_index = pending.find("\n")
|
|
579
|
+
if newline_index != -1:
|
|
580
|
+
if newline_index > self.max_unterminated_line_chars:
|
|
581
|
+
self._queue.put(self._truncated_raw_line(pending))
|
|
582
|
+
else:
|
|
583
|
+
self._queue.put(pending[: newline_index + 1])
|
|
584
|
+
pending = pending[newline_index + 1 :]
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
if len(pending) > self.max_unterminated_line_chars:
|
|
588
|
+
self._queue.put(self._truncated_raw_line(pending))
|
|
589
|
+
pending = ""
|
|
590
|
+
discarding_oversized_line = True
|
|
591
|
+
return
|
|
592
|
+
|
|
541
593
|
try:
|
|
542
|
-
|
|
543
|
-
|
|
594
|
+
while True:
|
|
595
|
+
chunk = stdout.read(COMMAND_READ_CHUNK_BYTES)
|
|
596
|
+
if not chunk:
|
|
597
|
+
break
|
|
598
|
+
feed(decoder.decode(chunk, final=False))
|
|
599
|
+
feed(decoder.decode(b"", final=True))
|
|
600
|
+
if pending and not discarding_oversized_line:
|
|
601
|
+
self._queue.put(pending)
|
|
544
602
|
finally:
|
|
545
603
|
self._stream_closed = True
|
|
546
604
|
self._queue.put(_STREAM_END)
|
|
@@ -613,7 +671,9 @@ def process_group_id_for(proc: subprocess.Popen[str]) -> int | None:
|
|
|
613
671
|
def run_command(
|
|
614
672
|
command: list[str],
|
|
615
673
|
timeout_seconds: int,
|
|
616
|
-
|
|
674
|
+
*,
|
|
675
|
+
max_line_chars: int = MAX_LINE_CHARS_LIMIT,
|
|
676
|
+
) -> tuple[Iterable[str], subprocess.Popen[bytes] | None, int | None]:
|
|
617
677
|
popen_kwargs: dict[str, object] = {}
|
|
618
678
|
if os.name != "nt":
|
|
619
679
|
popen_kwargs["start_new_session"] = True
|
|
@@ -622,9 +682,8 @@ def run_command(
|
|
|
622
682
|
command,
|
|
623
683
|
stdout=subprocess.PIPE,
|
|
624
684
|
stderr=subprocess.STDOUT,
|
|
625
|
-
text=
|
|
626
|
-
bufsize=
|
|
627
|
-
errors="replace",
|
|
685
|
+
text=False,
|
|
686
|
+
bufsize=0,
|
|
628
687
|
**popen_kwargs,
|
|
629
688
|
)
|
|
630
689
|
except OSError as exc:
|
|
@@ -638,6 +697,7 @@ def run_command(
|
|
|
638
697
|
proc,
|
|
639
698
|
proc.stdout,
|
|
640
699
|
timeout_seconds=timeout_seconds,
|
|
700
|
+
max_line_chars=max_line_chars,
|
|
641
701
|
process_group_id=process_group_id_for(proc),
|
|
642
702
|
),
|
|
643
703
|
proc,
|
|
@@ -685,11 +745,15 @@ def main() -> int:
|
|
|
685
745
|
if command and command[0] == "--":
|
|
686
746
|
command = command[1:]
|
|
687
747
|
|
|
688
|
-
proc: subprocess.Popen[
|
|
748
|
+
proc: subprocess.Popen[bytes] | None = None
|
|
689
749
|
command_stream: TimedCommandStream | None = None
|
|
690
750
|
early_rc: int | None = None
|
|
691
751
|
if command:
|
|
692
|
-
stream, proc, early_rc = run_command(
|
|
752
|
+
stream, proc, early_rc = run_command(
|
|
753
|
+
command,
|
|
754
|
+
args.timeout_seconds,
|
|
755
|
+
max_line_chars=COMMAND_MAX_UNTERMINATED_LINE_CHARS,
|
|
756
|
+
)
|
|
693
757
|
if isinstance(stream, TimedCommandStream):
|
|
694
758
|
command_stream = stream
|
|
695
759
|
if early_rc is not None and proc is None:
|