@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.
- package/CHANGELOG.md +8 -0
- package/README.ko.md +46 -1
- package/README.md +58 -2
- package/package.json +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +18 -0
- package/plugins/context-guard/README.md +18 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +5765 -224
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +5 -1
- package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
- package/plugins/context-guard/bin/context-guard-guard-read +490 -55
- package/plugins/context-guard/bin/context-guard-pack +110 -11
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
- package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
- package/plugins/context-guard/bin/context-guard-setup +1073 -147
- package/plugins/context-guard/bin/context-guard-statusline +131 -54
- package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +89 -13
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +6 -2
- package/plugins/context-guard/lib/credential_policy.py +177 -0
- package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Claude Code
|
|
2
|
+
"""Claude Code Bash terminal-hook feedback with privacy-safe accounting.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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 힌트:
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
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
|
-
|
|
1182
|
+
response = update_state_transaction(payload)
|
|
541
1183
|
except OSError as exc:
|
|
542
|
-
#
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|