@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.ko.md +32 -21
  3. package/README.md +38 -29
  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 +46 -11
  15. package/plugins/context-guard/bin/context-guard-artifact +342 -33
  16. package/plugins/context-guard/bin/context-guard-audit +33 -2
  17. package/plugins/context-guard/bin/context-guard-bench +1542 -31
  18. package/plugins/context-guard/bin/context-guard-cache-score +318 -33
  19. package/plugins/context-guard/bin/context-guard-cost +7 -2
  20. package/plugins/context-guard/bin/context-guard-experiments +364 -8
  21. package/plugins/context-guard/bin/context-guard-failed-nudge +6 -2
  22. package/plugins/context-guard/bin/context-guard-pack +301 -17
  23. package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
  24. package/plugins/context-guard/bin/context-guard-tool-prune +241 -54
  25. package/plugins/context-guard/bin/context-guard-trim-output +288 -41
  26. package/plugins/context-guard/brief/README.md +5 -5
  27. 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.run(
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 proc is not None and proc.returncode == 0:
1502
- raw = proc.stdout[: MAX_QUERY_SCAN_FILES * 512]
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 = min(
1928
- max(0, score_elbow_k),
1929
- max(0, budget_fit_k),
1930
- len(candidates),
1931
- MAX_SUGGEST_TOP,
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": 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
- ),
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[str],
524
- stdout: TextIO,
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 _read_stdout(self, stdout: TextIO) -> None:
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
- for line in stdout:
543
- self._queue.put(line)
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
- ) -> tuple[Iterable[str], subprocess.Popen[str] | None, int | None]:
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=True,
626
- bufsize=1,
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[str] | None = None
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(command, args.timeout_seconds)
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: