@ictechgy/context-guard 0.4.14 → 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 (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +72 -1
  3. package/README.md +85 -2
  4. package/docs/benchmark-fixtures/image-context-pack-full-evidence.prompt.example.md +28 -0
  5. package/docs/benchmark-fixtures/image-context-pack-packed-evidence.prompt.example.md +31 -0
  6. package/docs/benchmark-fixtures/image-context-pack.evidence.example.jsonl +2 -0
  7. package/docs/benchmark-fixtures/image-context-pack.tasks.example.json +18 -0
  8. package/docs/benchmark-fixtures/image-context-pack.variants.example.json +10 -0
  9. package/docs/benchmark-workflow-examples.md +16 -0
  10. package/docs/experimental-benchmark-fixtures.md +52 -1
  11. package/package.json +2 -1
  12. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  13. package/plugins/context-guard/README.ko.md +43 -0
  14. package/plugins/context-guard/README.md +43 -0
  15. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  16. package/plugins/context-guard/bin/context-guard-audit +169 -66
  17. package/plugins/context-guard/bin/context-guard-bench +7038 -307
  18. package/plugins/context-guard/bin/context-guard-compress +90 -8
  19. package/plugins/context-guard/bin/context-guard-diet +1 -7
  20. package/plugins/context-guard/bin/context-guard-experiments +3085 -134
  21. package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
  22. package/plugins/context-guard/bin/context-guard-guard-read +490 -55
  23. package/plugins/context-guard/bin/context-guard-mcp +999 -0
  24. package/plugins/context-guard/bin/context-guard-pack +744 -20
  25. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  26. package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
  27. package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
  28. package/plugins/context-guard/bin/context-guard-setup +1073 -147
  29. package/plugins/context-guard/bin/context-guard-statusline +131 -54
  30. package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
  31. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  32. package/plugins/context-guard/bin/context-guard-trim-output +89 -13
  33. package/plugins/context-guard/brief/README.md +19 -0
  34. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  35. package/plugins/context-guard/lib/context_guard_commands.py +14 -2
  36. package/plugins/context-guard/lib/credential_policy.py +177 -0
  37. package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
@@ -1,31 +1,31 @@
1
1
  #!/usr/bin/env python3
2
- """Claude Code PostToolUse hook: 동일 Bash 명령이 연속 실패하면 /clear 권유.
2
+ """Claude Code Bash terminal-hook feedback with privacy-safe accounting.
3
3
 
4
- 같은 명령으로 연속 실패하면 흐름은 컨텍스트 오염을 일으키고 prompt cache 도
5
- retry 마다 재워밍된다. hook 패턴을 감지해 다음 turn 의 추가 컨텍스트로
6
- 짧은 모델 힌트를 주입한다 (블록하지 않음).
4
+ The hook accepts both ``PostToolUse`` and ``PostToolUseFailure`` payloads. It
5
+ groups only exact ContextGuard wrapper envelopes, stores full SHA-256 identity
6
+ components instead of raw commands/session/tool IDs, and emits one bounded
7
+ strategy nudge on the second unique failure in an episode.
7
8
 
8
- PostToolUse `hookSpecificOutput.additionalContext` Claude Code 공식 hook 명세상
9
- 모델 컨텍스트로 surfacing 되는 키이다 (https://code.claude.com/docs/en/hooks 참조).
10
-
11
- 상태 저장: 프로젝트 로컬 `.context-guard/failures-<session>.json`.
12
- session_id 가 없으면 cross-session 오염을 피하기 위해 hook 자체를 noop 한다.
13
- 같은 fingerprint 가 한 번이라도 성공하면 카운트를 리셋한다 (false-positive 방지).
14
- 트래킹 깊이는 5 회로 제한해 디스크 사용을 무시할 수 있게 한다.
15
-
16
- Install via `.claude/settings.json` PostToolUse hook with matcher "Bash".
9
+ State is project-local at ``.context-guard/failures-v2.json``. A
10
+ symlink-safe advisory lock covers read/modify/durable-write so concurrent hook
11
+ processes cannot emit the same episode twice.
17
12
  """
18
13
  from __future__ import annotations
19
14
 
15
+ from contextlib import contextmanager
16
+ from dataclasses import dataclass
20
17
  import errno
18
+ import fcntl
21
19
  import hashlib
22
20
  import importlib.util
23
21
  import json
22
+ import math
24
23
  import os
25
24
  import re
26
25
  import shlex
27
26
  import stat
28
27
  import sys
28
+ import time
29
29
  import uuid
30
30
  from pathlib import Path
31
31
 
@@ -52,7 +52,51 @@ _hook_secret_patterns = _load_hook_secret_patterns()
52
52
  redact_sensitive_hook_text = _hook_secret_patterns.redact_sensitive_hook_text
53
53
 
54
54
  STATE_DIR = Path(".context-guard")
55
- STATE_FILE_TEMPLATE = "failures-{session}.json"
55
+ STATE_PATH = STATE_DIR / "failures-v2.json"
56
+ STATE_LOCK_PATH = STATE_DIR / "failures-v2.lock"
57
+ STATE_VERSION = 2
58
+ STATE_TTL_SECONDS = 30 * 60
59
+ MAX_EPISODES = 256
60
+ MAX_EVENT_IDS = 512
61
+ MAX_STATE_BYTES = 1_000_000
62
+ MAX_COUNTER = (1 << 63) - 1
63
+ STATE_LOCK_TIMEOUT_SECONDS = 2.0
64
+ STATE_LOCK_POLL_SECONDS = 0.01
65
+ CGW1_SENTINEL = "--context-guard-wrapper-v1"
66
+ CGW1_COMMAND_SEARCH_DIFF = "command_search_diff"
67
+ CGW1_SHELL_ARGV = ("bash", "-lc")
68
+ LEGACY_V0_MAX_LINES = "220"
69
+ PROTOCOL_CGW1 = "cgw1"
70
+ PROTOCOL_LEGACY_V0 = "legacy-v0"
71
+ PROTOCOL_DIRECT = "direct"
72
+ PROTOCOL_FOREIGN = "legacy-or-foreign"
73
+ PROTOCOLS = frozenset({
74
+ PROTOCOL_CGW1,
75
+ PROTOCOL_LEGACY_V0,
76
+ PROTOCOL_DIRECT,
77
+ PROTOCOL_FOREIGN,
78
+ })
79
+ COUNTER_NAMES = frozenset({
80
+ "dedupe",
81
+ "conflict",
82
+ "episode_expired",
83
+ "event_expired",
84
+ "episode_evicted",
85
+ "event_evicted",
86
+ "tracking_started",
87
+ "nudge_emitted",
88
+ "failure_after_emit",
89
+ "success_reset",
90
+ "interrupted",
91
+ "missing_exit",
92
+ "ambiguous_exit",
93
+ "malformed_event",
94
+ "missing_session",
95
+ "missing_tool_id",
96
+ })
97
+
98
+ # Retained compatibility helpers are exercised by the legacy aggregate suite.
99
+ # The v2 runtime below does not use their truncated fingerprints or tail list.
56
100
  MAX_TRACKED = 5
57
101
  MIN_CONSECUTIVE = 2
58
102
  STRATEGY_SWITCH_MIN_CONSECUTIVE = 3
@@ -84,18 +128,45 @@ class UnsafeStatePathError(OSError):
84
128
  """state path 가 symlink/비정규 파일/부적절한 경로 형태라 거부됨."""
85
129
 
86
130
 
131
+ class InvalidStateError(OSError):
132
+ """Persisted v2 state is malformed, unsupported, or oversized."""
133
+
134
+
135
+ class StateLockTimeoutError(OSError):
136
+ """The bounded state lock deadline elapsed."""
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class CommandIdentity:
141
+ protocol: str
142
+ digest: str
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class TerminalEvent:
147
+ hook_event_name: str
148
+ outcome: str
149
+ session_digest: str
150
+ tool_id_digest: str
151
+ command_identity: CommandIdentity
152
+
153
+ @property
154
+ def episode_key(self) -> str:
155
+ components = (
156
+ self.session_digest,
157
+ self.command_identity.protocol,
158
+ self.command_identity.digest,
159
+ )
160
+ return sha256_text(json.dumps(components, separators=(",", ":"), ensure_ascii=True))
161
+
162
+
87
163
  # additionalContext 는 모델에게 주입되므로 사용자에게 직접 명령하는 톤보다 모델이 행동을
88
164
  # 결정할 때 참고할 힌트 형태가 자연스럽다. 모델이 사용자에게 안내하도록 유도한다.
89
165
  NUDGE_TEXT = (
90
- "AI 힌트: 동일 Bash 명령이 이 세션에서 연속 두 번 실패했습니다. "
91
- "이는 현재 접근 방식이 같은 방향으로 막혀 있고, 실패 시도가 누적될수록 컨텍스트가 오염되며 "
92
- "prompt cache retry 마다 재워밍됨을 의미합니다. "
93
- "재시도 전에 사용자에게 `/clear` 또는 `/compact focus on …` 으로 세션을 정리한 뒤 "
94
- "재현 명령·기대 결과·금지 사항을 더 좁혀 다시 prompt 하도록 안내하거나, "
95
- "근본적으로 다른 방향(다른 모듈 / 검증 명령 / 더 작은 재현)을 제안하세요. "
96
- "직전 출력에 artifact_receipt 또는 contextguard-artifact:<id> 핸들이 있으면, 전체 로그를 다시 붙여넣거나 "
97
- "동일한 broad 명령을 재실행하기 전에 context-guard-artifact receipt/get/search 로 필요한 줄·패턴만 "
98
- "정확히 rehydrate 하도록 우선 제안하세요."
166
+ "AI 힌트: 같은 Bash 작업이 이 세션에서 두 번 실패했습니다. 동일 경로를 다시 실행하지 말고 "
167
+ "실패의 공통 조건을 요약한 다른 가설, 작은 재현, 또는 수정 후의 좁은 검증으로 전환하세요. "
168
+ " 출력이 artifact receipt로 저장되었다면 전체 로그를 재주입하지 말고 필요한 줄만 조회하세요. "
169
+ "컨텍스트가 오염되었다면 사용자에게 `/compact` 또는 `/clear` 선택지를 짧게 안내하세요."
99
170
  )
100
171
  STRATEGY_SWITCH_TEXT = (
101
172
  " Strategy-switch signal: the same failure direction has now repeated at least three times. "
@@ -148,6 +219,94 @@ def fingerprint(normalized: str) -> str:
148
219
  return hashlib.sha256(normalized.encode("utf-8", errors="replace")).hexdigest()[:16]
149
220
 
150
221
 
222
+ def sha256_text(value: str) -> str:
223
+ return hashlib.sha256(value.encode("utf-8", errors="surrogatepass")).hexdigest()
224
+
225
+
226
+ def _wrapper_prefixes() -> dict[str, tuple[str, ...]]:
227
+ """Return only the wrapper identities emitted beside this nudge helper."""
228
+ if Path(__file__).name == "failed_attempt_nudge.py":
229
+ return {
230
+ "sanitize": ("python3", str(SCRIPT_DIR / "sanitize_output.py")),
231
+ "trim": ("python3", str(SCRIPT_DIR / "trim_command_output.py")),
232
+ }
233
+ return {
234
+ "sanitize": (str(SCRIPT_DIR / "context-guard-sanitize-output"),),
235
+ "trim": (str(SCRIPT_DIR / "context-guard-trim-output"),),
236
+ }
237
+
238
+
239
+ def _argv(command: str) -> tuple[str, ...] | None:
240
+ try:
241
+ values = shlex.split(command, posix=True)
242
+ except (ValueError, TypeError):
243
+ return None
244
+ if not values or any("\x00" in value for value in values):
245
+ return None
246
+ return tuple(values)
247
+
248
+
249
+ def _is_wrapper_shaped(argv: tuple[str, ...]) -> bool:
250
+ known = {
251
+ "sanitize_output.py",
252
+ "trim_command_output.py",
253
+ "context-guard-sanitize-output",
254
+ "context-guard-trim-output",
255
+ "claude-sanitize-output",
256
+ "claude-trim-output",
257
+ }
258
+ if not argv:
259
+ return False
260
+ first = os.path.basename(argv[0])
261
+ if first in known:
262
+ return True
263
+ return (
264
+ re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", first) is not None
265
+ and len(argv) > 1
266
+ and os.path.basename(argv[1]) in known
267
+ )
268
+
269
+
270
+ def command_identity(command: str) -> CommandIdentity:
271
+ """Structurally unwrap only the frozen current or allowlisted v0 shapes."""
272
+ argv = _argv(command)
273
+ if argv is None:
274
+ return CommandIdentity(PROTOCOL_FOREIGN, sha256_text(command))
275
+
276
+ prefixes = _wrapper_prefixes()
277
+ sanitize_cgw1 = prefixes["sanitize"] + (
278
+ CGW1_SENTINEL,
279
+ CGW1_COMMAND_SEARCH_DIFF,
280
+ "--",
281
+ *CGW1_SHELL_ARGV,
282
+ )
283
+ if len(argv) == len(sanitize_cgw1) + 1 and argv[:-1] == sanitize_cgw1:
284
+ logical = argv[-1]
285
+ if logical and not _is_wrapper_shaped(_argv(logical) or ()):
286
+ return CommandIdentity(PROTOCOL_CGW1, sha256_text(logical))
287
+
288
+ # The frozen A1 producer still emits this exact v0 envelope for trim.
289
+ # The historical producer emitted it for sanitize as well, so both known
290
+ # adjacent wrapper identities remain allowlisted for one compatibility
291
+ # window. No other --/bash/-lc spelling is unwrapped.
292
+ for prefix in prefixes.values():
293
+ legacy_v0 = prefix + (
294
+ "--max-lines",
295
+ LEGACY_V0_MAX_LINES,
296
+ "--",
297
+ *CGW1_SHELL_ARGV,
298
+ )
299
+ if len(argv) == len(legacy_v0) + 1 and argv[:-1] == legacy_v0:
300
+ logical = argv[-1]
301
+ if logical and not _is_wrapper_shaped(_argv(logical) or ()):
302
+ return CommandIdentity(PROTOCOL_LEGACY_V0, sha256_text(logical))
303
+
304
+ protocol = PROTOCOL_FOREIGN if (
305
+ _is_wrapper_shaped(argv) or CGW1_SENTINEL in argv
306
+ ) else PROTOCOL_DIRECT
307
+ return CommandIdentity(protocol, sha256_text(command))
308
+
309
+
151
310
  def _base_open_flags() -> int:
152
311
  flags = os.O_RDONLY
153
312
  if hasattr(os, "O_CLOEXEC"):
@@ -407,6 +566,277 @@ def save_entries(path: Path, entries: list[dict]) -> None:
407
566
  pass
408
567
 
409
568
 
569
+ def _read_bytes_no_follow(path: Path, limit: int = MAX_STATE_BYTES) -> bytes:
570
+ fd = _open_regular_no_symlink(path)
571
+ chunks: list[bytes] = []
572
+ remaining = limit + 1
573
+ try:
574
+ while remaining > 0:
575
+ chunk = os.read(fd, min(64 * 1024, remaining))
576
+ if not chunk:
577
+ break
578
+ chunks.append(chunk)
579
+ remaining -= len(chunk)
580
+ finally:
581
+ os.close(fd)
582
+ data = b"".join(chunks)
583
+ if len(data) > limit:
584
+ raise InvalidStateError(errno.EFBIG, "oversized v2 nudge state")
585
+ return data
586
+
587
+
588
+ def _atomic_write_json(path: Path, value: object) -> None:
589
+ """Write private JSON durably using only directory-relative no-follow IO."""
590
+ encoded = json.dumps(
591
+ value,
592
+ ensure_ascii=True,
593
+ sort_keys=True,
594
+ separators=(",", ":"),
595
+ ).encode("utf-8")
596
+ if len(encoded) > MAX_STATE_BYTES:
597
+ raise InvalidStateError(errno.EFBIG, "v2 nudge state exceeds size bound")
598
+
599
+ parent_fd = -1
600
+ tmp_fd = -1
601
+ tmp_name = f".nudge-v2-{os.getpid()}-{uuid.uuid4().hex}.tmp"
602
+ try:
603
+ parent_fd = _ensure_directory_no_symlink(path.parent, create=True)
604
+ tmp_fd = os.open(
605
+ tmp_name,
606
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY | _no_follow_flag(),
607
+ 0o600,
608
+ dir_fd=parent_fd,
609
+ )
610
+ if not stat.S_ISREG(os.fstat(tmp_fd).st_mode):
611
+ raise UnsafeStatePathError(errno.EINVAL, "temporary state is not regular")
612
+ if hasattr(os, "fchmod"):
613
+ os.fchmod(tmp_fd, 0o600)
614
+ view = memoryview(encoded)
615
+ written = 0
616
+ while written < len(view):
617
+ count = os.write(tmp_fd, view[written:])
618
+ if count <= 0:
619
+ raise OSError(errno.EIO, "short state write")
620
+ written += count
621
+ os.fsync(tmp_fd)
622
+ os.close(tmp_fd)
623
+ tmp_fd = -1
624
+
625
+ try:
626
+ existing_fd = os.open(
627
+ path.name,
628
+ _base_open_flags() | _no_follow_flag(),
629
+ dir_fd=parent_fd,
630
+ )
631
+ except FileNotFoundError:
632
+ existing_fd = -1
633
+ else:
634
+ try:
635
+ if not stat.S_ISREG(os.fstat(existing_fd).st_mode):
636
+ raise UnsafeStatePathError(errno.EINVAL, "state is not regular")
637
+ finally:
638
+ os.close(existing_fd)
639
+
640
+ _rename_state_entry(tmp_name, path.name, parent_fd)
641
+ tmp_name = ""
642
+ os.fsync(parent_fd)
643
+ finally:
644
+ if tmp_fd != -1:
645
+ try:
646
+ os.close(tmp_fd)
647
+ except OSError:
648
+ pass
649
+ if parent_fd != -1:
650
+ if tmp_name:
651
+ try:
652
+ os.unlink(tmp_name, dir_fd=parent_fd)
653
+ except OSError:
654
+ pass
655
+ try:
656
+ os.close(parent_fd)
657
+ except OSError:
658
+ pass
659
+
660
+
661
+ @contextmanager
662
+ def state_lock(
663
+ path: Path = STATE_LOCK_PATH,
664
+ *,
665
+ timeout: float = STATE_LOCK_TIMEOUT_SECONDS,
666
+ ):
667
+ """Acquire a bounded private sibling lock without following symlinks."""
668
+ parent_fd = _ensure_directory_no_symlink(path.parent, create=True)
669
+ lock_fd = -1
670
+ try:
671
+ lock_fd = os.open(
672
+ path.name,
673
+ os.O_CREAT | os.O_RDWR | _no_follow_flag(),
674
+ 0o600,
675
+ dir_fd=parent_fd,
676
+ )
677
+ if not stat.S_ISREG(os.fstat(lock_fd).st_mode):
678
+ raise UnsafeStatePathError(errno.EINVAL, "state lock is not regular")
679
+ if hasattr(os, "fchmod"):
680
+ os.fchmod(lock_fd, 0o600)
681
+ deadline = time.monotonic() + max(0.0, timeout)
682
+ while True:
683
+ try:
684
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
685
+ break
686
+ except BlockingIOError:
687
+ if time.monotonic() >= deadline:
688
+ raise StateLockTimeoutError(
689
+ errno.ETIMEDOUT,
690
+ "v2 nudge state lock timed out",
691
+ )
692
+ time.sleep(STATE_LOCK_POLL_SECONDS)
693
+ yield
694
+ finally:
695
+ if lock_fd != -1:
696
+ try:
697
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
698
+ except OSError:
699
+ pass
700
+ try:
701
+ os.close(lock_fd)
702
+ except OSError:
703
+ pass
704
+ os.close(parent_fd)
705
+
706
+
707
+ def empty_state() -> dict:
708
+ return {
709
+ "version": STATE_VERSION,
710
+ "episodes": [],
711
+ "events": [],
712
+ "counters": {},
713
+ }
714
+
715
+
716
+ _DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
717
+
718
+
719
+ def _valid_timestamp(value: object) -> bool:
720
+ return (
721
+ not isinstance(value, bool)
722
+ and isinstance(value, (int, float))
723
+ and math.isfinite(float(value))
724
+ and float(value) >= 0.0
725
+ )
726
+
727
+
728
+ def _validate_episode(value: object) -> dict:
729
+ if not isinstance(value, dict):
730
+ raise InvalidStateError(errno.EINVAL, "invalid episode record")
731
+ required = {
732
+ "key",
733
+ "session",
734
+ "protocol",
735
+ "command",
736
+ "state",
737
+ "count",
738
+ "updated_at",
739
+ }
740
+ if set(value) != required:
741
+ raise InvalidStateError(errno.EINVAL, "invalid episode fields")
742
+ if not all(
743
+ isinstance(value[name], str) and _DIGEST_RE.fullmatch(value[name])
744
+ for name in ("key", "session", "command")
745
+ ):
746
+ raise InvalidStateError(errno.EINVAL, "invalid episode digest")
747
+ if not isinstance(value["protocol"], str) or value["protocol"] not in PROTOCOLS:
748
+ raise InvalidStateError(errno.EINVAL, "invalid episode protocol")
749
+ if not isinstance(value["state"], str) or value["state"] not in {"tracking", "emitted"}:
750
+ raise InvalidStateError(errno.EINVAL, "invalid episode state")
751
+ count = value["count"]
752
+ if isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= MAX_COUNTER:
753
+ raise InvalidStateError(errno.EINVAL, "invalid episode count")
754
+ if value["state"] == "tracking" and count != 1:
755
+ raise InvalidStateError(errno.EINVAL, "invalid tracking count")
756
+ if value["state"] == "emitted" and count < 2:
757
+ raise InvalidStateError(errno.EINVAL, "invalid emitted count")
758
+ if not _valid_timestamp(value["updated_at"]):
759
+ raise InvalidStateError(errno.EINVAL, "invalid episode timestamp")
760
+ return dict(value)
761
+
762
+
763
+ def _validate_event(value: object) -> dict:
764
+ if not isinstance(value, dict):
765
+ raise InvalidStateError(errno.EINVAL, "invalid event record")
766
+ required = {"id", "episode", "outcome", "updated_at"}
767
+ if set(value) != required:
768
+ raise InvalidStateError(errno.EINVAL, "invalid event fields")
769
+ if not (
770
+ isinstance(value["id"], str)
771
+ and _DIGEST_RE.fullmatch(value["id"])
772
+ and isinstance(value["episode"], str)
773
+ and _DIGEST_RE.fullmatch(value["episode"])
774
+ ):
775
+ raise InvalidStateError(errno.EINVAL, "invalid event digest")
776
+ if value["outcome"] not in {"failure", "success"}:
777
+ raise InvalidStateError(errno.EINVAL, "invalid event outcome")
778
+ if not _valid_timestamp(value["updated_at"]):
779
+ raise InvalidStateError(errno.EINVAL, "invalid event timestamp")
780
+ return dict(value)
781
+
782
+
783
+ def validate_state(value: object) -> dict:
784
+ if not isinstance(value, dict) or set(value) != {
785
+ "version",
786
+ "episodes",
787
+ "events",
788
+ "counters",
789
+ }:
790
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge state")
791
+ if value["version"] != STATE_VERSION:
792
+ raise InvalidStateError(errno.EINVAL, "unsupported v2 nudge state version")
793
+ if not isinstance(value["episodes"], list) or not isinstance(value["events"], list):
794
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge collections")
795
+ if not isinstance(value["counters"], dict):
796
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge counters")
797
+
798
+ episodes = [_validate_episode(item) for item in value["episodes"]]
799
+ events = [_validate_event(item) for item in value["events"]]
800
+ if len({item["key"] for item in episodes}) != len(episodes):
801
+ raise InvalidStateError(errno.EINVAL, "duplicate episode key")
802
+ if len({item["id"] for item in events}) != len(events):
803
+ raise InvalidStateError(errno.EINVAL, "duplicate event id")
804
+
805
+ counters: dict[str, int] = {}
806
+ for name, count in value["counters"].items():
807
+ if (
808
+ name not in COUNTER_NAMES
809
+ or isinstance(count, bool)
810
+ or not isinstance(count, int)
811
+ or not 0 <= count <= MAX_COUNTER
812
+ ):
813
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge counter")
814
+ if count:
815
+ counters[name] = count
816
+ return {
817
+ "version": STATE_VERSION,
818
+ "episodes": episodes,
819
+ "events": events,
820
+ "counters": counters,
821
+ }
822
+
823
+
824
+ def load_state(path: Path = STATE_PATH) -> dict:
825
+ try:
826
+ raw = _read_bytes_no_follow(path)
827
+ except FileNotFoundError:
828
+ return empty_state()
829
+ try:
830
+ value = json.loads(raw.decode("utf-8"))
831
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
832
+ raise InvalidStateError(errno.EINVAL, "invalid v2 nudge JSON") from exc
833
+ return validate_state(value)
834
+
835
+
836
+ def save_state(state: dict, path: Path = STATE_PATH) -> None:
837
+ _atomic_write_json(path, validate_state(state))
838
+
839
+
410
840
  def safe_session_label(session_id: str | None) -> str | None:
411
841
  """session_id 를 파일명 안전 digest 로 변환. 없으면 None — 호출자가 hook 을 noop 한다."""
412
842
  if not session_id or not isinstance(session_id, str):
@@ -459,6 +889,250 @@ def read_bounded_stdin_text(limit: int = MAX_HOOK_STDIN_BYTES) -> tuple[str | No
459
889
  return data.decode("utf-8", errors="replace"), False
460
890
 
461
891
 
892
+ _MISSING = object()
893
+ _AMBIGUOUS = object()
894
+
895
+
896
+ def _alias(value: dict, snake: str, camel: str) -> object:
897
+ snake_value = value.get(snake, _MISSING)
898
+ camel_value = value.get(camel, _MISSING)
899
+ if snake_value is not _MISSING and camel_value is not _MISSING:
900
+ return snake_value if snake_value == camel_value else _AMBIGUOUS
901
+ return snake_value if snake_value is not _MISSING else camel_value
902
+
903
+
904
+ def classify_terminal_event(payload: dict) -> tuple[TerminalEvent | None, str | None]:
905
+ """Return one valid terminal event or a bounded no-transition reason."""
906
+ tool_name = _alias(payload, "tool_name", "toolName")
907
+ if tool_name is _AMBIGUOUS:
908
+ return None, "malformed_event"
909
+ if tool_name != "Bash":
910
+ return None, None
911
+
912
+ hook_event_name = _alias(payload, "hook_event_name", "hookEventName")
913
+ if (
914
+ not isinstance(hook_event_name, str)
915
+ or hook_event_name not in {"PostToolUse", "PostToolUseFailure"}
916
+ ):
917
+ return None, "malformed_event"
918
+
919
+ tool_input = _alias(payload, "tool_input", "toolInput")
920
+ if not isinstance(tool_input, dict):
921
+ return None, "malformed_event"
922
+ command = tool_input.get("command")
923
+ if not isinstance(command, str) or not command.strip():
924
+ return None, "malformed_event"
925
+
926
+ session_id = _alias(payload, "session_id", "sessionId")
927
+ if session_id is _AMBIGUOUS:
928
+ return None, "malformed_event"
929
+ if not isinstance(session_id, str) or not session_id:
930
+ return None, "missing_session"
931
+ tool_use_id = _alias(payload, "tool_use_id", "toolUseId")
932
+ if tool_use_id is _AMBIGUOUS:
933
+ return None, "malformed_event"
934
+ if not isinstance(tool_use_id, str) or not tool_use_id:
935
+ return None, "missing_tool_id"
936
+
937
+ outcome: str
938
+ if hook_event_name == "PostToolUse":
939
+ tool_response = _alias(payload, "tool_response", "toolResponse")
940
+ if not isinstance(tool_response, dict):
941
+ return None, "malformed_event"
942
+ interrupted = tool_response.get("interrupted", False)
943
+ if not isinstance(interrupted, bool):
944
+ return None, "malformed_event"
945
+ if interrupted:
946
+ return None, "interrupted"
947
+ alternate_exit_fields = {"exitCode", "returncode"}.intersection(tool_response)
948
+ if alternate_exit_fields:
949
+ return None, "ambiguous_exit"
950
+ if "exit_code" not in tool_response:
951
+ return None, "missing_exit"
952
+ exit_code = tool_response["exit_code"]
953
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int):
954
+ return None, "ambiguous_exit"
955
+ outcome = "success" if exit_code == 0 else "failure"
956
+ else:
957
+ error = payload.get("error")
958
+ is_interrupt = _alias(payload, "is_interrupt", "isInterrupt")
959
+ if is_interrupt is _AMBIGUOUS:
960
+ return None, "malformed_event"
961
+ if is_interrupt is _MISSING:
962
+ is_interrupt = False
963
+ if not isinstance(error, str) or not error:
964
+ return None, "malformed_event"
965
+ if not isinstance(is_interrupt, bool):
966
+ return None, "malformed_event"
967
+ lowered_error = error.casefold()
968
+ if is_interrupt or any(
969
+ marker in lowered_error
970
+ for marker in ("interrupt", "cancelled", "canceled")
971
+ ):
972
+ return None, "interrupted"
973
+ outcome = "failure"
974
+
975
+ return TerminalEvent(
976
+ hook_event_name=hook_event_name,
977
+ outcome=outcome,
978
+ session_digest=sha256_text(session_id),
979
+ tool_id_digest=sha256_text(tool_use_id),
980
+ command_identity=command_identity(command),
981
+ ), None
982
+
983
+
984
+ def _increment_counter(state: dict, name: str, amount: int = 1) -> None:
985
+ if name not in COUNTER_NAMES or amount <= 0:
986
+ return
987
+ counters = state["counters"]
988
+ counters[name] = min(MAX_COUNTER, int(counters.get(name, 0)) + amount)
989
+
990
+
991
+ def _prune_expired(state: dict, now: float) -> None:
992
+ episodes = [
993
+ item
994
+ for item in state["episodes"]
995
+ if now - float(item["updated_at"]) <= STATE_TTL_SECONDS
996
+ ]
997
+ events = [
998
+ item
999
+ for item in state["events"]
1000
+ if now - float(item["updated_at"]) <= STATE_TTL_SECONDS
1001
+ ]
1002
+ _increment_counter(state, "episode_expired", len(state["episodes"]) - len(episodes))
1003
+ _increment_counter(state, "event_expired", len(state["events"]) - len(events))
1004
+ state["episodes"] = episodes
1005
+ state["events"] = events
1006
+
1007
+
1008
+ def _enforce_lru(state: dict) -> None:
1009
+ if len(state["episodes"]) > MAX_EPISODES:
1010
+ ordered = sorted(
1011
+ state["episodes"],
1012
+ key=lambda item: (float(item["updated_at"]), item["key"]),
1013
+ )
1014
+ evict = len(ordered) - MAX_EPISODES
1015
+ evicted_keys = {item["key"] for item in ordered[:evict]}
1016
+ state["episodes"] = [
1017
+ item for item in state["episodes"] if item["key"] not in evicted_keys
1018
+ ]
1019
+ _increment_counter(state, "episode_evicted", evict)
1020
+ if len(state["events"]) > MAX_EVENT_IDS:
1021
+ ordered = sorted(
1022
+ state["events"],
1023
+ key=lambda item: (float(item["updated_at"]), item["id"]),
1024
+ )
1025
+ evict = len(ordered) - MAX_EVENT_IDS
1026
+ evicted_ids = {item["id"] for item in ordered[:evict]}
1027
+ state["events"] = [
1028
+ item for item in state["events"] if item["id"] not in evicted_ids
1029
+ ]
1030
+ _increment_counter(state, "event_evicted", evict)
1031
+
1032
+
1033
+ def _nudge_response(hook_event_name: str) -> dict:
1034
+ return {
1035
+ "hookSpecificOutput": {
1036
+ "hookEventName": hook_event_name,
1037
+ "additionalContext": NUDGE_TEXT,
1038
+ }
1039
+ }
1040
+
1041
+
1042
+ def apply_payload(
1043
+ state: dict,
1044
+ payload: dict,
1045
+ *,
1046
+ now: float | None = None,
1047
+ ) -> tuple[dict, dict]:
1048
+ """Pure v2 FSM transition used by the locked runtime and protocol tests."""
1049
+ state = validate_state(state)
1050
+ timestamp = time.time() if now is None else float(now)
1051
+ if not _valid_timestamp(timestamp):
1052
+ raise ValueError("now must be a finite non-negative timestamp")
1053
+ _prune_expired(state, timestamp)
1054
+ _enforce_lru(state)
1055
+
1056
+ event, reason = classify_terminal_event(payload)
1057
+ if event is None:
1058
+ if reason is not None:
1059
+ _increment_counter(state, reason)
1060
+ return state, {}
1061
+
1062
+ existing_event = next(
1063
+ (item for item in state["events"] if item["id"] == event.tool_id_digest),
1064
+ None,
1065
+ )
1066
+ if existing_event is not None:
1067
+ if (
1068
+ existing_event["episode"] == event.episode_key
1069
+ and existing_event["outcome"] == event.outcome
1070
+ ):
1071
+ _increment_counter(state, "dedupe")
1072
+ else:
1073
+ _increment_counter(state, "conflict")
1074
+ return state, {}
1075
+
1076
+ state["events"].append({
1077
+ "id": event.tool_id_digest,
1078
+ "episode": event.episode_key,
1079
+ "outcome": event.outcome,
1080
+ "updated_at": timestamp,
1081
+ })
1082
+ episode = next(
1083
+ (item for item in state["episodes"] if item["key"] == event.episode_key),
1084
+ None,
1085
+ )
1086
+
1087
+ response: dict = {}
1088
+ if event.outcome == "success":
1089
+ if episode is not None:
1090
+ state["episodes"].remove(episode)
1091
+ _increment_counter(state, "success_reset")
1092
+ elif episode is None:
1093
+ state["episodes"].append({
1094
+ "key": event.episode_key,
1095
+ "session": event.session_digest,
1096
+ "protocol": event.command_identity.protocol,
1097
+ "command": event.command_identity.digest,
1098
+ "state": "tracking",
1099
+ "count": 1,
1100
+ "updated_at": timestamp,
1101
+ })
1102
+ _increment_counter(state, "tracking_started")
1103
+ elif episode["state"] == "tracking":
1104
+ episode["state"] = "emitted"
1105
+ episode["count"] = 2
1106
+ episode["updated_at"] = timestamp
1107
+ _increment_counter(state, "nudge_emitted")
1108
+ response = _nudge_response(event.hook_event_name)
1109
+ else:
1110
+ episode["count"] = min(MAX_COUNTER, int(episode["count"]) + 1)
1111
+ episode["updated_at"] = timestamp
1112
+ _increment_counter(state, "failure_after_emit")
1113
+
1114
+ _enforce_lru(state)
1115
+ return validate_state(state), response
1116
+
1117
+
1118
+ def update_state_transaction(
1119
+ payload: dict,
1120
+ *,
1121
+ now: float | None = None,
1122
+ state_path: Path = STATE_PATH,
1123
+ lock_path: Path = STATE_LOCK_PATH,
1124
+ ) -> dict:
1125
+ """Serialize one event and return output only after durable persistence."""
1126
+ event, _reason = classify_terminal_event(payload)
1127
+ if event is None and _reason is None:
1128
+ return {}
1129
+ with state_lock(lock_path):
1130
+ state = load_state(state_path)
1131
+ state, response = apply_payload(state, payload, now=now)
1132
+ save_state(state, state_path)
1133
+ return response
1134
+
1135
+
462
1136
  def update_entries(entries: list[dict], fp: str, success: bool) -> list[dict]:
463
1137
  """성공한 fingerprint 는 카운트 리셋. 실패는 append.
464
1138
 
@@ -504,68 +1178,16 @@ def main() -> int:
504
1178
  if not isinstance(payload, dict):
505
1179
  print("{}")
506
1180
  return 0
507
-
508
- tool_name = payload.get("tool_name") or payload.get("toolName")
509
- if tool_name != "Bash":
510
- print("{}")
511
- return 0
512
-
513
- tool_input = payload.get("tool_input") or payload.get("toolInput") or {}
514
- tool_response = payload.get("tool_response") or payload.get("toolResponse") or {}
515
- if not isinstance(tool_input, dict) or not isinstance(tool_response, dict):
516
- print("{}")
517
- return 0
518
-
519
- command = tool_input.get("command")
520
- if not isinstance(command, str) or not command.strip():
521
- print("{}")
522
- return 0
523
-
524
- exit_code = extract_exit_code(tool_response)
525
- if exit_code is None:
526
- # exit_code 미확정 — 실패 여부를 모르므로 회귀 위험 방지 차원에서 noop.
527
- print("{}")
528
- return 0
529
-
530
- session = safe_session_label(payload.get("session_id") or payload.get("sessionId"))
531
- if session is None:
532
- # session_id 가 없으면 cross-session 오염 위험으로 그냥 noop. 상태 파일도 만들지 않는다.
533
- print("{}")
534
- return 0
535
-
536
- fp = fingerprint(normalize_command(command))
537
- state_path = STATE_DIR / STATE_FILE_TEMPLATE.format(session=session)
538
-
539
1181
  try:
540
- entries = load_entries(state_path)
1182
+ response = update_state_transaction(payload)
541
1183
  except OSError as exc:
542
- # state 읽기 실패해도 실행을 막지 않는다. 진단 신호만 stderr 남긴 뒤 새 streak 으로 시작한다.
543
- sys.stderr.write(f"context-guard-failed-nudge: state read skipped: {diagnostic_text(exc)}\n")
544
- entries = []
545
- success = exit_code == 0
546
- entries = update_entries(entries, fp, success)
547
- try:
548
- save_entries(state_path, entries)
549
- except OSError as exc:
550
- # state 저장 실패해도 실행을 막지 않는다. 진단 신호만 stderr 에 남긴다.
551
- sys.stderr.write(f"context-guard-failed-nudge: state write skipped: {diagnostic_text(exc)}\n")
552
-
553
- if success:
554
- # 성공이면 nudge 는 절대 발화하지 않는다.
555
- print("{}")
556
- return 0
557
-
558
- consecutive = count_consecutive_failures(entries, fp)
559
- if consecutive < MIN_CONSECUTIVE:
560
- print("{}")
561
- return 0
562
-
563
- response = {
564
- "hookSpecificOutput": {
565
- "hookEventName": "PostToolUse",
566
- "additionalContext": NUDGE_TEXT + (STRATEGY_SWITCH_TEXT if consecutive >= STRATEGY_SWITCH_MIN_CONSECUTIVE else ""),
567
- }
568
- }
1184
+ # Never emit from an uncommitted transition: a later retry must not
1185
+ # produce repeated text after read/lock/durability failure.
1186
+ sys.stderr.write(
1187
+ "context-guard-failed-nudge: state update skipped: "
1188
+ f"{diagnostic_text(exc)}\n"
1189
+ )
1190
+ response = {}
569
1191
  print(json.dumps(response, ensure_ascii=False))
570
1192
  return 0
571
1193