@ictechgy/context-guard 0.4.15 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.ko.md +46 -1
  3. package/README.md +58 -2
  4. package/package.json +1 -1
  5. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  6. package/plugins/context-guard/README.ko.md +18 -0
  7. package/plugins/context-guard/README.md +18 -0
  8. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  9. package/plugins/context-guard/bin/context-guard-audit +169 -66
  10. package/plugins/context-guard/bin/context-guard-bench +5765 -224
  11. package/plugins/context-guard/bin/context-guard-compress +90 -8
  12. package/plugins/context-guard/bin/context-guard-diet +1 -7
  13. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  14. package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
  15. package/plugins/context-guard/bin/context-guard-guard-read +490 -55
  16. package/plugins/context-guard/bin/context-guard-pack +110 -11
  17. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  18. package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
  19. package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
  20. package/plugins/context-guard/bin/context-guard-setup +1073 -147
  21. package/plugins/context-guard/bin/context-guard-statusline +131 -54
  22. package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
  23. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  24. package/plugins/context-guard/bin/context-guard-trim-output +89 -13
  25. package/plugins/context-guard/brief/README.md +19 -0
  26. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  27. package/plugins/context-guard/lib/context_guard_commands.py +6 -2
  28. package/plugins/context-guard/lib/credential_policy.py +177 -0
  29. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -11,6 +11,29 @@ if ! command -v python3 >/dev/null 2>&1; then
11
11
  exit 0
12
12
  fi
13
13
 
14
+ statusline_path=$0
15
+ for _context_guard_symlink_hop in {1..32}; do
16
+ [[ -L "$statusline_path" ]] || break
17
+ statusline_link_dir=$(CDPATH= cd -P -- "$(dirname -- "$statusline_path")" && pwd) || break
18
+ statusline_link_target=$(readlink "$statusline_path" 2>/dev/null) || break
19
+ if [[ "$statusline_link_target" = /* ]]; then
20
+ statusline_path=$statusline_link_target
21
+ else
22
+ statusline_path=$statusline_link_dir/$statusline_link_target
23
+ fi
24
+ done
25
+ statusline_dir=$(CDPATH= cd -P -- "$(dirname -- "$statusline_path")" && pwd)
26
+ usage_reducer_file=''
27
+ for candidate in \
28
+ "$statusline_dir/transcript_usage_reducer.py" \
29
+ "$statusline_dir/../lib/transcript_usage_reducer.py"; do
30
+ if [[ -f "$candidate" && ! -L "$candidate" ]]; then
31
+ usage_reducer_file=$(CDPATH= cd -P -- "$(dirname -- "$candidate")" && printf '%s/%s\n' "$PWD" "$(basename -- "$candidate")")
32
+ break
33
+ fi
34
+ done
35
+ export CONTEXT_GUARD_USAGE_REDUCER_FILE="$usage_reducer_file"
36
+
14
37
  read -r -d '' CONTEXT_GUARD_STATUSLINE_PY <<'PYEOF' || true
15
38
  from __future__ import annotations
16
39
 
@@ -22,14 +45,18 @@ import re
22
45
  import stat
23
46
  import sys
24
47
  import time
48
+ import types
25
49
  from typing import Any
26
50
 
27
51
  TAIL_BYTES = 1024 * 1024
28
52
  MAX_RECORDS = 300
29
- CACHE_SCHEMA_VERSION = 1
53
+ CACHE_SCHEMA_VERSION = 2
54
+ CACHE_REDUCER_SCHEMA = "usage-reducer-v2"
55
+ USAGE_METRIC_LABEL = "usage_tail_v2"
30
56
  DEFAULT_CACHE_TTL_SECONDS = 2.0
31
57
  MAX_CACHE_TTL_SECONDS = 30.0
32
58
  MAX_CACHE_BYTES = 4096
59
+ MAX_REDUCER_BYTES = 512 * 1024
33
60
  METRIC_RE = re.compile(r"^\d+(?:\.\d)?$")
34
61
  SECRET_RE = re.compile(
35
62
  r"(gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|sk-|npm_|AIza|Bearer\s|Basic\s)",
@@ -39,6 +66,44 @@ OSC_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
39
66
  CSI_RE = re.compile(r"\x1b[@-_][0-?]*[ -/]*[@-~]")
40
67
  CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
41
68
 
69
+ def _load_usage_reducer(path: str) -> tuple[str, Any | None]:
70
+ if not path or not os.path.isabs(path):
71
+ return CACHE_REDUCER_SCHEMA, None
72
+ fd = -1
73
+ try:
74
+ flags = os.O_RDONLY
75
+ if hasattr(os, "O_CLOEXEC"):
76
+ flags |= os.O_CLOEXEC
77
+ if hasattr(os, "O_NOFOLLOW"):
78
+ flags |= os.O_NOFOLLOW
79
+ fd = os.open(path, flags)
80
+ opened = os.fstat(fd)
81
+ if not stat.S_ISREG(opened.st_mode) or opened.st_size > MAX_REDUCER_BYTES:
82
+ return CACHE_REDUCER_SCHEMA, None
83
+ source = os.read(fd, opened.st_size + 1)
84
+ if len(source) != opened.st_size:
85
+ return CACHE_REDUCER_SCHEMA, None
86
+ module_name = "_context_guard_statusline_usage_reducer"
87
+ module = types.ModuleType(module_name)
88
+ module.__file__ = path
89
+ sys.modules[module_name] = module
90
+ exec(compile(source, path, "exec"), module.__dict__)
91
+ schema = getattr(module, "REDUCER_SCHEMA", "")
92
+ reducer = getattr(module, "UsageReducer", None)
93
+ if not isinstance(schema, str) or reducer is None:
94
+ return CACHE_REDUCER_SCHEMA, None
95
+ return schema, reducer
96
+ except Exception:
97
+ return CACHE_REDUCER_SCHEMA, None
98
+ finally:
99
+ if fd >= 0:
100
+ os.close(fd)
101
+
102
+
103
+ REDUCER_SCHEMA, UsageReducer = _load_usage_reducer(
104
+ os.environ.get("CONTEXT_GUARD_USAGE_REDUCER_FILE", "")
105
+ )
106
+
42
107
 
43
108
  def _bounded_int_env(primary: str, legacy: str, default: int, *, lower: int, upper: int) -> int:
44
109
  raw = os.environ.get(primary, os.environ.get(legacy, str(default)))
@@ -159,31 +224,6 @@ def git_head_branch(current: str) -> str | None:
159
224
  return None
160
225
 
161
226
 
162
- def _int_or_zero(value: Any) -> int:
163
- """Coerce transcript usage token values. bool is an int subclass, so block it."""
164
- if isinstance(value, bool):
165
- return 0
166
- if isinstance(value, int):
167
- return max(0, value)
168
- return 0
169
-
170
-
171
- def _extract_usage(record: Any) -> dict[str, Any] | None:
172
- """Extract one known transcript usage object without recursively double-counting copies."""
173
- if not isinstance(record, dict):
174
- return None
175
- for path_keys in (("usage",), ("message", "usage"), ("response", "usage")):
176
- cur: Any = record
177
- for key in path_keys:
178
- if not isinstance(cur, dict):
179
- cur = None
180
- break
181
- cur = cur.get(key)
182
- if isinstance(cur, dict):
183
- return cur
184
- return None
185
-
186
-
187
227
  def _open_regular_transcript(path: str) -> tuple[int, os.stat_result] | None:
188
228
  flags = os.O_RDONLY
189
229
  if hasattr(os, "O_CLOEXEC"):
@@ -278,6 +318,7 @@ def _identity(path: str, st: os.stat_result) -> dict[str, int | str]:
278
318
  absolute = os.path.abspath(path)
279
319
  path_hash = hashlib.sha256(os.fsencode(absolute)).hexdigest()
280
320
  return {
321
+ "reducer_schema": CACHE_REDUCER_SCHEMA,
281
322
  "path_hash": path_hash,
282
323
  "size": int(st.st_size),
283
324
  "mtime_ns": int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))),
@@ -326,7 +367,7 @@ def _validated_metric(value: Any, *, minimum: float, maximum: float) -> str | No
326
367
  return value
327
368
 
328
369
 
329
- def _metric_parts(cache_pct: Any, reuse_x: Any) -> str | None:
370
+ def _metric_parts(cache_pct: Any, reuse_x: Any, window_partial: Any) -> str | None:
330
371
  cache_pct = _validated_metric(cache_pct, minimum=0.0, maximum=100.0)
331
372
  if cache_pct is None:
332
373
  return None
@@ -334,7 +375,13 @@ def _metric_parts(cache_pct: Any, reuse_x: Any) -> str | None:
334
375
  reuse_x = _validated_metric(reuse_x, minimum=0.0, maximum=1_000_000.0)
335
376
  if reuse_x is None:
336
377
  return None
337
- parts = [f"cache_pct={cache_pct}"]
378
+ if not isinstance(window_partial, bool):
379
+ return None
380
+ parts = [
381
+ f"usage_scope={USAGE_METRIC_LABEL}",
382
+ f"window_partial={'true' if window_partial else 'false'}",
383
+ f"cache_pct={cache_pct}",
384
+ ]
338
385
  if reuse_x:
339
386
  parts.append(f"reuse_x={reuse_x}")
340
387
  return " ".join(parts)
@@ -360,6 +407,10 @@ def _read_cache(identity: dict[str, int | str], workspace_dir: str, ttl: float)
360
407
  return None
361
408
  if data.get("schema_version") != CACHE_SCHEMA_VERSION:
362
409
  return None
410
+ if data.get("reducer_schema") != CACHE_REDUCER_SCHEMA:
411
+ return None
412
+ if data.get("metric_label") != USAGE_METRIC_LABEL:
413
+ return None
363
414
  computed_at = float(data.get("computed_at", 0))
364
415
  now = time.time()
365
416
  if not math.isfinite(computed_at):
@@ -369,12 +420,22 @@ def _read_cache(identity: dict[str, int | str], workspace_dir: str, ttl: float)
369
420
  for key, value in identity.items():
370
421
  if data.get(key) != value:
371
422
  return None
372
- return _metric_parts(data.get("cache_pct"), data.get("reuse_x"))
423
+ return _metric_parts(
424
+ data.get("cache_pct"),
425
+ data.get("reuse_x"),
426
+ data.get("window_partial"),
427
+ )
373
428
  except Exception:
374
429
  return None
375
430
 
376
431
 
377
- def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct: str, reuse_x: str | None) -> None:
432
+ def _write_cache(
433
+ identity: dict[str, int | str],
434
+ workspace_dir: str,
435
+ cache_pct: str,
436
+ reuse_x: str | None,
437
+ window_partial: bool,
438
+ ) -> None:
378
439
  ttl = _cache_ttl_seconds()
379
440
  if ttl <= 0:
380
441
  return
@@ -383,6 +444,8 @@ def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct:
383
444
  return
384
445
  payload = {
385
446
  "schema_version": CACHE_SCHEMA_VERSION,
447
+ "metric_label": USAGE_METRIC_LABEL,
448
+ "window_partial": window_partial,
386
449
  **identity,
387
450
  "computed_at": time.time(),
388
451
  "cache_pct": cache_pct,
@@ -418,9 +481,12 @@ def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct:
418
481
 
419
482
 
420
483
  def transcript_metrics(path: str, workspace_dir: str) -> str | None:
421
- input_tokens = 0
422
- cache_read = 0
423
- cache_creation = 0
484
+ if UsageReducer is None or REDUCER_SCHEMA != CACHE_REDUCER_SCHEMA:
485
+ print(
486
+ "context-guard statusline: usage reducer unavailable; transcript metrics omitted",
487
+ file=sys.stderr,
488
+ )
489
+ return None
424
490
  try:
425
491
  opened = _open_regular_transcript(path)
426
492
  if opened is None:
@@ -437,38 +503,38 @@ def transcript_metrics(path: str, workspace_dir: str) -> str | None:
437
503
  finally:
438
504
  os.close(fd)
439
505
  lines = chunk.splitlines()
506
+ window_partial = size > read_size
440
507
  if size > read_size and lines:
441
508
  lines = lines[1:]
442
- for raw in lines[-MAX_RECORDS:]:
443
- if not raw.strip():
444
- continue
509
+ lines = [raw for raw in lines if raw.strip()]
510
+ if len(lines) > MAX_RECORDS:
511
+ window_partial = True
512
+ lines = lines[-MAX_RECORDS:]
513
+ reducer = UsageReducer()
514
+ for ordinal, raw in enumerate(lines):
445
515
  try:
446
516
  obj = json.loads(raw)
447
517
  except Exception:
518
+ reducer.note_invalid_row()
448
519
  continue
449
- usage = _extract_usage(obj)
450
- if not usage:
451
- continue
452
- input_tokens += _int_or_zero(usage.get("input_tokens"))
453
- cr = usage.get("cache_read_input_tokens")
454
- if cr is None:
455
- cr = usage.get("cacheRead")
456
- cache_read += _int_or_zero(cr)
457
- cc = usage.get("cache_creation_input_tokens")
458
- if cc is None:
459
- cc = usage.get("cacheCreation")
460
- cache_creation += _int_or_zero(cc)
520
+ reducer.observe(
521
+ obj,
522
+ file_identity=str(identity["path_hash"]),
523
+ row_ordinal=ordinal,
524
+ )
525
+ reduced = reducer.finalize()
526
+ window_partial = window_partial or reduced.partial
527
+ input_tokens = reduced.tokens.get("input", 0)
528
+ cache_read = reduced.tokens.get("cache_read", 0)
529
+ cache_creation = reduced.tokens.get("cache_creation", 0)
461
530
  denom = input_tokens + cache_read + cache_creation
462
531
  if denom <= 0 or cache_read <= 0:
463
532
  return None
464
533
  pct = max(0.0, min(100.0, cache_read / denom * 100))
465
534
  cache_pct = f"{pct:.0f}"
466
535
  reuse_x = f"{cache_read / cache_creation:.1f}" if cache_creation > 0 else None
467
- _write_cache(identity, workspace_dir, cache_pct, reuse_x)
468
- parts = [f"cache_pct={cache_pct}"]
469
- if reuse_x:
470
- parts.append(f"reuse_x={reuse_x}")
471
- return " ".join(parts)
536
+ _write_cache(identity, workspace_dir, cache_pct, reuse_x, window_partial)
537
+ return _metric_parts(cache_pct, reuse_x, window_partial)
472
538
  except Exception:
473
539
  return None
474
540
 
@@ -537,15 +603,26 @@ def render_statusline(payload: dict[str, Any]) -> str:
537
603
  if raw_metrics:
538
604
  cache_pct = ""
539
605
  reuse_x = ""
606
+ usage_scope = ""
607
+ window_partial = ""
540
608
  for metric in raw_metrics.split():
541
609
  if metric.startswith("cache_pct="):
542
610
  cache_pct = metric[len("cache_pct=") :]
543
611
  elif metric.startswith("reuse_x="):
544
612
  reuse_x = metric[len("reuse_x=") :]
613
+ elif metric.startswith("usage_scope="):
614
+ usage_scope = metric[len("usage_scope=") :]
615
+ elif metric.startswith("window_partial="):
616
+ window_partial = metric[len("window_partial=") :]
545
617
  if cache_pct:
546
618
  metrics_label = f" | cache {sanitize_status(cache_pct)}%"
547
619
  if reuse_x:
548
620
  metrics_label += f" | reuse {sanitize_status(reuse_x)}x"
621
+ if usage_scope == USAGE_METRIC_LABEL and window_partial in {"true", "false"}:
622
+ metrics_label += (
623
+ f" | {USAGE_METRIC_LABEL} "
624
+ f"window_partial={window_partial}"
625
+ )
549
626
 
550
627
  return f"[{model}] {dir_label}{branch_label} | ctx {context_label} | cost {cost}{metrics_label}"
551
628
 
@@ -566,4 +643,4 @@ except BrokenPipeError:
566
643
  raise SystemExit(0)
567
644
  PYEOF
568
645
 
569
- exec python3 -c "$CONTEXT_GUARD_STATUSLINE_PY" "$@"
646
+ exec python3 -I -c "$CONTEXT_GUARD_STATUSLINE_PY" "$@"
@@ -5,7 +5,7 @@
5
5
  # ─────────────────────────────────────────────────────────────────────────
6
6
  # OMC HUD 존재? │ token-statusline 존재? │ 출력
7
7
  # ─────────────────────────────────────────────────────────────────────────
8
- # yes │ yes │ OMC HUD + cost/cache/reuse 결합 (1줄)
8
+ # yes │ yes │ OMC HUD + cost/cache/reuse/scope 결합 (1줄)
9
9
  # yes │ no │ OMC HUD 단독
10
10
  # no │ yes │ token-statusline 단독
11
11
  # no │ no │ "[hud unavailable]"
@@ -137,8 +137,9 @@ fi
137
137
 
138
138
  # ── 3) 결합: OMC HUD 가 살아있을 때만 token 출력에서 compact extras 만 뽑아 붙임 ─
139
139
  # token-statusline 형식:
140
- # "[model] dir | branch | ctx N% | cost $N.NNN | cache N% | reuse N.Nx"
141
- # OMC HUD 와 중복되는 model/dir/branch/ctx 는 버리고 cost/cache/reuse 만 채택한다.
140
+ # "[model] dir | branch | ctx N% | cost $N.NNN | cache N% | reuse N.Nx | usage_tail_v2 window_partial=BOOL"
141
+ # OMC HUD 와 중복되는 model/dir/branch/ctx 는 버리고 cost/cache/reuse
142
+ # bounded-tail scope disclosure 만 채택한다.
142
143
  extras=''
143
144
  if [[ -n "$omc_out" && -n "$tok_out" ]]; then
144
145
  if [[ "$tok_out" =~ \|[[:space:]]+cost[[:space:]]+(\$[0-9.]+|n/a) ]]; then
@@ -150,6 +151,9 @@ if [[ -n "$omc_out" && -n "$tok_out" ]]; then
150
151
  if [[ "$tok_out" =~ \|[[:space:]]+reuse[[:space:]]+([0-9]+(\.[0-9]+)?x|n/a) ]]; then
151
152
  extras+=" | reuse ${BASH_REMATCH[1]}"
152
153
  fi
154
+ if [[ "$tok_out" =~ \|[[:space:]]+(usage_tail_v2)[[:space:]]+(window_partial=(true|false)) ]]; then
155
+ extras+=" | ${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"
156
+ fi
153
157
  fi
154
158
 
155
159
  # ── 4) 출력 ──────────────────────────────────────────────────────────────────
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import hashlib
13
+ import importlib.util
13
14
  import json
14
15
  import os
15
16
  import shlex
@@ -19,6 +20,7 @@ import stat
19
20
  import sys
20
21
  import time
21
22
  from dataclasses import dataclass
23
+ from types import ModuleType
22
24
  from typing import Any, NoReturn
23
25
 
24
26
  TOOL_NAME = "context-guard-tool-prune"
@@ -68,9 +70,6 @@ SECRET_RE = re.compile(
68
70
  r"(?<![A-Za-z0-9])(?:api[_-]?key|apikey|token|secret|password|client[_-]?secret|authorization|credential|signature|sig|private[_-]?key|privatekey|pgp[_-]?private[_-]?key|pgpprivatekey|ssh[_-]?key|sshkey|(?:aws[_-]?)?access[_-]?key(?:[_-]?id)?|awsaccesskeyid)\s*[:=]\s*[^\s,}\]]+"
69
71
  r")"
70
72
  )
71
- SENSITIVE_KEY_RE = re.compile(
72
- r"(?i)(authorization|api[_-]?key|apikey|token|secret|password|passwd|pwd|client[_-]?secret|credential|signature|sig|x-amz-signature|x-amz-credential|awsaccesskeyid|(?:aws[_-]?)?access[_-]?key(?:[_-]?id)?|private[_-]?key|privatekey|pgp[_-]?private[_-]?key|pgpprivatekey|ssh[_-]?key|sshkey)"
73
- )
74
73
  VALUE_BEARING_KEY_RE = re.compile(r"(?i)^(default|const|enum|example|examples|value|values)$")
75
74
 
76
75
 
@@ -135,20 +134,52 @@ def cap_text(value: object, limit: int = MAX_LABEL_CHARS) -> str:
135
134
  return text[: max(0, limit - len(marker))] + marker
136
135
 
137
136
 
137
+ def load_credential_policy() -> ModuleType:
138
+ script_dir = Path(__file__).resolve().parent
139
+ candidate = (
140
+ script_dir.parent / "lib" / "credential_policy.py"
141
+ if script_dir.name == "bin"
142
+ else script_dir / "credential_policy.py"
143
+ )
144
+ spec = importlib.util.spec_from_file_location(
145
+ "_context_guard_tool_prune_credential_policy",
146
+ candidate,
147
+ )
148
+ if spec is None or spec.loader is None:
149
+ raise RuntimeError(f"could not load credential policy: {candidate}")
150
+ module = importlib.util.module_from_spec(spec)
151
+ spec.loader.exec_module(module)
152
+ return module
153
+
154
+
155
+ _CREDENTIAL_POLICY = load_credential_policy()
156
+ normalize_sensitive_key = _CREDENTIAL_POLICY.normalize_sensitive_key
157
+ is_sensitive_key = _CREDENTIAL_POLICY.is_sensitive_key
158
+ redact_high_confidence_credentials = _CREDENTIAL_POLICY.redact_high_confidence_credentials
159
+
160
+
138
161
  def redact_string(value: str) -> tuple[str, int]:
162
+ value, redactions = redact_high_confidence_credentials(value)
163
+
139
164
  def repl(match: re.Match[str]) -> str:
165
+ nonlocal redactions
140
166
  text = match.group(0)
167
+ replacement = "[REDACTED]"
141
168
  if "=" in text:
142
169
  key = text.split("=", 1)[0]
143
- if SENSITIVE_KEY_RE.search(key):
144
- return key + "=[REDACTED]"
145
- if ":" in text:
170
+ if not is_sensitive_key(key):
171
+ return text
172
+ replacement = key + "=[REDACTED]"
173
+ elif ":" in text:
146
174
  key = text.split(":", 1)[0]
147
- if SENSITIVE_KEY_RE.search(key):
148
- return key + ": [REDACTED]"
149
- return "[REDACTED]"
175
+ if not is_sensitive_key(key):
176
+ return text
177
+ replacement = key + ": [REDACTED]"
178
+ if replacement != text:
179
+ redactions += 1
180
+ return replacement
150
181
 
151
- return SECRET_RE.subn(repl, value)
182
+ return SECRET_RE.sub(repl, value), redactions
152
183
 
153
184
 
154
185
  def redact_whole_value(value: Any) -> tuple[Any, int]:
@@ -169,6 +200,8 @@ def redact_whole_value(value: Any) -> tuple[Any, int]:
169
200
  out.append(sanitized)
170
201
  count += item_redactions
171
202
  return out, count
203
+ if value == "[REDACTED]":
204
+ return value, 0
172
205
  return "[REDACTED]", 1
173
206
 
174
207
 
@@ -191,7 +224,7 @@ def sanitize_value(value: Any, *, sensitive_context: bool = False, sensitive_sch
191
224
  for key, item in value.items():
192
225
  raw_key = str(key)
193
226
  safe_key, key_redactions = redact_string(raw_key)
194
- key_sensitive = bool(SENSITIVE_KEY_RE.search(raw_key))
227
+ key_sensitive = is_sensitive_key(raw_key)
195
228
  value_bearing = bool(VALUE_BEARING_KEY_RE.search(raw_key))
196
229
  if key_sensitive and not isinstance(item, dict):
197
230
  sanitized, item_redactions = sanitize_value(item, sensitive_context=True)
@@ -143,8 +143,15 @@ class UnsafeAdjacentModuleError(RuntimeError):
143
143
 
144
144
 
145
145
  class FallbackLineSanitizer:
146
- def __init__(self, *, show_paths: bool = False, diagnostic: str | None = None) -> None:
146
+ def __init__(
147
+ self,
148
+ *,
149
+ show_paths: bool = False,
150
+ context: str = "unknown_text",
151
+ diagnostic: str | None = None,
152
+ ) -> None:
147
153
  self.show_paths = show_paths
154
+ self.context = context
148
155
  self.diagnostic = diagnostic
149
156
  self.diagnostic_emitted = False
150
157
  self.redactions = 0
@@ -154,8 +161,6 @@ class FallbackLineSanitizer:
154
161
  print(f"context-guard-kit: sanitizer fallback active: {self.diagnostic}", file=sys.stderr)
155
162
  self.diagnostic_emitted = True
156
163
  line = strip_ansi(raw_line)
157
- if not self.show_paths:
158
- line = anonymize_absolute_paths(line)
159
164
  original = line
160
165
  auth_match = FALLBACK_AUTH_HEADER_RE.match(line)
161
166
  if auth_match:
@@ -223,11 +228,31 @@ def load_adjacent_python_module(script_dir: Path, name: str, *, module_prefix: s
223
228
  module = types.ModuleType(module_name)
224
229
  module.__file__ = str(script_dir / name)
225
230
  module.__package__ = ""
226
- exec(compile(source, str(script_dir / name), "exec"), module.__dict__)
231
+ sys.modules[module_name] = module
232
+ try:
233
+ exec(compile(source, str(script_dir / name), "exec"), module.__dict__)
234
+ except Exception:
235
+ sys.modules.pop(module_name, None)
236
+ raise
227
237
  return module
228
238
 
229
239
 
230
- def load_line_sanitizer(show_paths: bool) -> object:
240
+ def instantiate_line_sanitizer(factory: object, *, show_paths: bool, context: str) -> object:
241
+ try:
242
+ return factory(show_paths=show_paths, context=context) # type: ignore[operator]
243
+ except TypeError:
244
+ if context != "unknown_text":
245
+ raise RuntimeError(
246
+ "adjacent sanitizer does not support required explicit context"
247
+ )
248
+ # One compatibility window for unknown-text adjacent sanitizer stubs.
249
+ return factory(show_paths=show_paths) # type: ignore[operator]
250
+
251
+
252
+ def load_line_sanitizer(
253
+ show_paths: bool,
254
+ context: str = "unknown_text",
255
+ ) -> object:
231
256
  """Reuse the stronger sanitizer when it is shipped next to this wrapper."""
232
257
  script_dir = Path(__file__).resolve().parent
233
258
  load_errors: list[str] = []
@@ -240,14 +265,22 @@ def load_line_sanitizer(show_paths: bool) -> object:
240
265
  )
241
266
  if module is None:
242
267
  continue
243
- return module.LineSanitizer(show_paths=show_paths)
268
+ return instantiate_line_sanitizer(
269
+ module.LineSanitizer,
270
+ show_paths=show_paths,
271
+ context=context,
272
+ )
244
273
  except UnsafeAdjacentModuleError:
245
274
  raise
246
275
  except Exception as exc:
247
276
  load_errors.append(f"{name} failed to load: {exc.__class__.__name__}: {exc}")
248
277
  continue
249
278
  diagnostic = "; ".join(load_errors) if load_errors else "strong sanitizer not found next to trim wrapper"
250
- return FallbackLineSanitizer(show_paths=show_paths, diagnostic=diagnostic)
279
+ return FallbackLineSanitizer(
280
+ show_paths=show_paths,
281
+ context=context,
282
+ diagnostic=diagnostic,
283
+ )
251
284
 
252
285
 
253
286
  def load_artifact_store_module() -> object:
@@ -508,7 +541,7 @@ def compact_item(
508
541
  ) -> str:
509
542
  """Normalize a failure-summary item without letting one log line dominate memory/output."""
510
543
  if sanitizer is None:
511
- sanitizer = load_line_sanitizer(show_paths)
544
+ sanitizer = load_line_sanitizer(show_paths, context="unknown_text")
512
545
  sanitized, _ = sanitizer.sanitize(text) # type: ignore[attr-defined]
513
546
  item = re.sub(r"\s+", " ", strip_ansi(sanitized).strip())
514
547
  if len(item) <= limit:
@@ -529,7 +562,7 @@ class RunnerFailureSummary:
529
562
  def __init__(self, max_items_per_runner: int, *, show_paths: bool = False) -> None:
530
563
  self.max_items_per_runner = max(0, max_items_per_runner)
531
564
  self.show_paths = show_paths
532
- self.sanitizer = load_line_sanitizer(show_paths)
565
+ self.sanitizer = load_line_sanitizer(show_paths, context="unknown_text")
533
566
  self.items: dict[str, list[str]] = collections.defaultdict(list)
534
567
  self.seen: dict[str, set[str]] = collections.defaultdict(set)
535
568
  self.jest_active = False
@@ -1478,6 +1511,15 @@ def main() -> int:
1478
1511
  "(default: off; formats: markdown, json)"
1479
1512
  ),
1480
1513
  )
1514
+ parser.add_argument(
1515
+ "--digest-always",
1516
+ action="store_true",
1517
+ help=(
1518
+ "keep the digest even when the command output is smaller than the digest; "
1519
+ "by default a smaller output is passed through so the digest cannot inflate "
1520
+ "context"
1521
+ ),
1522
+ )
1481
1523
  parser.add_argument(
1482
1524
  "--artifact-receipt",
1483
1525
  action="store_true",
@@ -1533,7 +1575,10 @@ def main() -> int:
1533
1575
  return 2
1534
1576
 
1535
1577
  try:
1536
- line_sanitizer = load_line_sanitizer(args.show_paths)
1578
+ line_sanitizer = load_line_sanitizer(
1579
+ args.show_paths,
1580
+ context="unknown_text",
1581
+ )
1537
1582
  except UnsafeAdjacentModuleError as exc:
1538
1583
  print(f"context-guard-kit: unsafe adjacent helper: {exc}", file=sys.stderr)
1539
1584
  return 2
@@ -1693,10 +1738,41 @@ def main() -> int:
1693
1738
  )
1694
1739
  if guidance not in next_queries:
1695
1740
  next_queries.insert(0, guidance)
1696
- if args.digest == "json":
1697
- sys.stdout.write(render_digest_json(payload, args.max_chars))
1741
+ rendered = (
1742
+ render_digest_json(payload, args.max_chars)
1743
+ if args.digest == "json"
1744
+ else render_digest_markdown(payload, args.max_chars)
1745
+ )
1746
+ # digest 는 큰 출력을 줄이려는 기능이다. 출력이 작으면 digest 가 오히려 커져서
1747
+ # 컨텍스트를 늘리므로, 그럴 때는 원래 출력을 그대로 통과시킨다. artifact receipt 를
1748
+ # 저장한 경우에는 handle/재확장 명령이 digest 의 존재 이유이므로 폴백하지 않는다.
1749
+ passthrough = "".join(all_lines)
1750
+ marker = (
1751
+ "[context-guard-kit] digest skipped: it was larger than the command output\n"
1752
+ )
1753
+ digest_bytes = len(rendered.encode("utf-8"))
1754
+ passthrough_bytes = len(passthrough.encode("utf-8")) + len(marker.encode("utf-8"))
1755
+ # 폴백 조건은 보수적으로 둔다.
1756
+ # - 전체 출력이 예산 안에 들어와 all_lines 가 완전한 출력일 때만 통과시킨다.
1757
+ # 그렇지 않으면 잘린 출력을 원본처럼 내보내 정보를 잃는다.
1758
+ # - 실패한 명령에서는 digest 가 종료 코드/실패 signature 를 담으므로 유지한다.
1759
+ # - artifact receipt 를 요청했다면 handle/재확장 명령이 digest 의 존재 이유다.
1760
+ complete_output_available = (
1761
+ total <= args.max_lines
1762
+ and visible_chars <= args.max_chars
1763
+ and not any_line_capped
1764
+ )
1765
+ if (
1766
+ not args.digest_always
1767
+ and not args.artifact_receipt
1768
+ and rc == 0
1769
+ and complete_output_available
1770
+ and passthrough_bytes < digest_bytes
1771
+ ):
1772
+ sys.stdout.write(marker)
1773
+ sys.stdout.write(passthrough)
1698
1774
  else:
1699
- sys.stdout.write(render_digest_markdown(payload, args.max_chars))
1775
+ sys.stdout.write(rendered)
1700
1776
  artifact_capture.close()
1701
1777
  return rc
1702
1778
 
@@ -70,3 +70,22 @@ Each block is wrapped in stable markers:
70
70
  To remove brief mode, delete the block between (and including) those two marker lines. Only
71
71
  one brief-mode block should be present at a time; installing a different level replaces the
72
72
  existing block rather than stacking a second one.
73
+
74
+ ## Quiet narration is separate
75
+
76
+ [`narration-mode.quiet.md`](narration-mode.quiet.md) is a default-off, Claude-only rule for
77
+ reducing discretionary progress narration. It is not a brief-mode level and does not change
78
+ final-answer requirements or reasoning depth. Manage it only through the isolated,
79
+ project-scoped rules operation:
80
+
81
+ ```bash
82
+ context-guard setup --rules-only --agent claude --scope project --narration-mode quiet --plan
83
+ context-guard setup --rules-only --agent claude --scope project --narration-mode quiet --yes
84
+ context-guard setup --rules-only --agent claude --scope project --narration-mode default --yes
85
+ ```
86
+
87
+ This operation manages only the ContextGuard narration span in project `CLAUDE.md`; it does
88
+ not read or change Claude settings, hooks, permissions, statusline, model defaults, or other
89
+ agents' rule files. The rule preserves approvals and decisions, blockers, failures,
90
+ destructive or security warnings, required progress, final results, changed files, and
91
+ verification. It is best-effort guidance and does not guarantee token or cost savings.
@@ -0,0 +1,21 @@
1
+ <!-- BEGIN context-guard:narration-mode mode=quiet version=1 -->
2
+ ## ContextGuard quiet narration (advisory)
3
+
4
+ Best effort: reduce only discretionary intermediate narration. Skip routine preambles,
5
+ per-tool narration, filler, and repeated interim summaries when they add no useful
6
+ information.
7
+
8
+ Always preserve required user-facing communication:
9
+
10
+ - user approvals and decisions;
11
+ - blockers and failures;
12
+ - destructive-risk and security warnings;
13
+ - progress required by higher-priority instructions;
14
+ - the final result;
15
+ - changed files; and
16
+ - verification evidence.
17
+
18
+ This mode does not require a shorter final answer and does not change reasoning effort.
19
+ It asks Claude to reduce discretionary narration; it does not guarantee token or cost savings,
20
+ and no numeric savings should be claimed without matched provider evidence.
21
+ <!-- END context-guard:narration-mode -->