claude-smart 0.2.29 → 0.2.31
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/README.md +9 -23
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +3 -3
- package/plugin/hooks/hooks.json +4 -4
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +65 -0
- package/plugin/scripts/backend-service.sh +29 -7
- package/plugin/scripts/cli.sh +3 -2
- package/plugin/scripts/codex-hook.js +72 -9
- package/plugin/scripts/dashboard-service.sh +16 -4
- package/plugin/scripts/hook_entry.sh +50 -9
- package/plugin/scripts/smart-install.sh +57 -39
- package/plugin/src/claude_smart/cli.py +119 -1
- package/plugin/src/claude_smart/events/session_end.py +171 -6
- package/plugin/src/claude_smart/events/session_start.py +26 -2
- package/plugin/src/claude_smart/events/stop.py +17 -4
- package/plugin/src/claude_smart/hook.py +62 -4
- package/plugin/src/claude_smart/hook_log.py +301 -0
- package/plugin/uv.lock +1 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Structured, append-only log of every hook fire and its outcome.
|
|
2
|
+
|
|
3
|
+
Why a dedicated log rather than relying on the Python ``logging`` module:
|
|
4
|
+
hooks run as short-lived subprocesses with no preconfigured handler, so
|
|
5
|
+
``_LOGGER.warning(...)`` calls silently vanish to stderr that Claude Code
|
|
6
|
+
captures but typically does not surface. The dispatcher already wraps
|
|
7
|
+
handlers in a broad ``except`` (so a hook crash never breaks the user's
|
|
8
|
+
session) — without a forensic trail the silent-fail mode is invisible.
|
|
9
|
+
|
|
10
|
+
This module writes one JSON line per hook fire to
|
|
11
|
+
``~/.claude-smart/hook.log`` from ``hook.main`` after the handler returns.
|
|
12
|
+
The Stop and SessionEnd handlers additionally piggyback their publish
|
|
13
|
+
outcome into the record so the chain hook→adapter→backend is fully
|
|
14
|
+
observable from a single file.
|
|
15
|
+
|
|
16
|
+
Failure policy: every public function in this module swallows all
|
|
17
|
+
``OSError``/``Exception`` paths. Logging is best-effort; a stuck disk
|
|
18
|
+
or read-only ``$HOME`` must never abort an in-progress hook.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import IO, Any
|
|
31
|
+
|
|
32
|
+
# ``fcntl`` is POSIX-only. On Windows we skip the cross-process lock and
|
|
33
|
+
# accept the unlikely rotation race (claude-smart is primarily a POSIX
|
|
34
|
+
# tool for Claude Code's plugin runtime).
|
|
35
|
+
try:
|
|
36
|
+
import fcntl as _fcntl
|
|
37
|
+
except ImportError: # pragma: no cover — exercised on Windows only
|
|
38
|
+
_fcntl = None # type: ignore[assignment]
|
|
39
|
+
|
|
40
|
+
_LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Anchored on $HOME so unit tests can monkeypatch ``_LOG_PATH`` to a
|
|
43
|
+
# tmp_path. The directory is created lazily on first write — keeping
|
|
44
|
+
# import side-effect-free matches the rest of the package.
|
|
45
|
+
_LOG_PATH = Path.home() / ".claude-smart" / "hook.log"
|
|
46
|
+
|
|
47
|
+
_TRUNCATE_LIMIT = 4096
|
|
48
|
+
|
|
49
|
+
# Hard cap on ``hook.log`` size. At ~400 bytes/line and ~50 lines per
|
|
50
|
+
# typical session, 5 MB ≈ 12k records ≈ a few hundred sessions of recent
|
|
51
|
+
# forensic history — enough to debug the last week, bounded so a power
|
|
52
|
+
# user's log can't grow unbounded.
|
|
53
|
+
_MAX_LOG_BYTES = 5 * 1024 * 1024
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def log_event(
|
|
57
|
+
*,
|
|
58
|
+
event: str,
|
|
59
|
+
host: str,
|
|
60
|
+
session_id: Any = None,
|
|
61
|
+
project_id: Any = None,
|
|
62
|
+
cwd: Any = None,
|
|
63
|
+
internal_skipped: bool = False,
|
|
64
|
+
handler_status: str = "ok",
|
|
65
|
+
publish_status: str | None = None,
|
|
66
|
+
publish_count: int | None = None,
|
|
67
|
+
extra: dict[str, Any] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Append one JSON record describing a hook fire.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
event (str): Hook event name (``"stop"``, ``"user-prompt"``, …).
|
|
73
|
+
host (str): Host identifier (``"claude-code"`` / ``"codex"``).
|
|
74
|
+
session_id (Any): Claude Code session id from the payload, or
|
|
75
|
+
``None`` when the payload didn't carry one.
|
|
76
|
+
project_id (Any): Project-id resolved from ``cwd``. Optional.
|
|
77
|
+
cwd (Any): Working directory reported by the hook payload.
|
|
78
|
+
Optional — preserved verbatim so a future audit can correlate
|
|
79
|
+
fires against workspace directories.
|
|
80
|
+
internal_skipped (bool): True when ``is_internal_invocation``
|
|
81
|
+
short-circuited the dispatcher (reflexio-internal sub-claude
|
|
82
|
+
calls, or invocations from inside the reflexio submodule).
|
|
83
|
+
handler_status (str): ``"ok"`` for a clean return, ``"unknown_event"``
|
|
84
|
+
when no handler is registered, or ``"raised:<ExcClass>: <msg>"``
|
|
85
|
+
when the handler raised — formatted by ``hook.main``.
|
|
86
|
+
publish_status (str | None): Status returned by
|
|
87
|
+
``publish.publish_unpublished`` (``"ok"`` / ``"failed"`` /
|
|
88
|
+
``"nothing"``). Only emitted by Stop + SessionEnd.
|
|
89
|
+
publish_count (int | None): Interaction count from the same
|
|
90
|
+
tuple. ``None`` when the handler doesn't publish.
|
|
91
|
+
extra (dict[str, Any] | None): Free-form fields preserved as-is.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
None
|
|
95
|
+
"""
|
|
96
|
+
record: dict[str, Any] = {
|
|
97
|
+
"ts": int(time.time()),
|
|
98
|
+
"event": event,
|
|
99
|
+
"host": host,
|
|
100
|
+
"session_id": _truncate(session_id),
|
|
101
|
+
"project_id": _truncate(project_id),
|
|
102
|
+
"cwd": _truncate(cwd),
|
|
103
|
+
"internal_skipped": bool(internal_skipped),
|
|
104
|
+
"handler_status": _truncate(handler_status),
|
|
105
|
+
}
|
|
106
|
+
if publish_status is not None:
|
|
107
|
+
record["publish_status"] = publish_status
|
|
108
|
+
try:
|
|
109
|
+
record["publish_count"] = int(
|
|
110
|
+
publish_count if publish_count is not None else 0
|
|
111
|
+
)
|
|
112
|
+
except (TypeError, ValueError):
|
|
113
|
+
# Defensive: a hostile caller passing an unparseable publish_count
|
|
114
|
+
# must not abort the hook fire. Treat as zero, the record still
|
|
115
|
+
# writes with publish_status preserved.
|
|
116
|
+
record["publish_count"] = 0
|
|
117
|
+
if extra:
|
|
118
|
+
for key, value in extra.items():
|
|
119
|
+
if key in record:
|
|
120
|
+
continue
|
|
121
|
+
record[key] = _truncate(value)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
line = json.dumps(record, ensure_ascii=False, default=str) + "\n"
|
|
125
|
+
except (TypeError, ValueError) as exc:
|
|
126
|
+
_LOGGER.debug("hook_log: json encode failed: %s", exc)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
path = log_path()
|
|
130
|
+
try:
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
_LOGGER.debug("hook_log: mkdir for %s failed: %s", path.parent, exc)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
# Hold an exclusive cross-process flock through the entire append +
|
|
137
|
+
# rotate critical section. The original rationale ("POSIX atomic
|
|
138
|
+
# append below PIPE_BUF") protects the *append* alone but not the
|
|
139
|
+
# ``read_bytes → write tmp → rename`` rotation sequence: a concurrent
|
|
140
|
+
# appender that lands between rotation's read and rename has its
|
|
141
|
+
# record silently dropped by the rename. The lock closes that gap.
|
|
142
|
+
#
|
|
143
|
+
# On Windows ``_fcntl`` is None and writes proceed unlocked — same
|
|
144
|
+
# behaviour as before. The original PIPE_BUF-based append atomicity
|
|
145
|
+
# is preserved by ``_TRUNCATE_LIMIT`` keeping each record below 4 KiB.
|
|
146
|
+
with _rotation_lock(path):
|
|
147
|
+
try:
|
|
148
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
149
|
+
fh.write(line)
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
_LOGGER.debug("hook_log: write to %s failed: %s", path, exc)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Rotate AFTER the append so the just-written record always survives,
|
|
155
|
+
# even on the call that crosses the cap. A single oversized record
|
|
156
|
+
# still gets through and is dropped by the *next* rotation when the
|
|
157
|
+
# file is read back.
|
|
158
|
+
_maybe_rotate(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def log_path() -> Path:
|
|
162
|
+
"""Return the active log path, honouring ``CLAUDE_SMART_HOOK_LOG``.
|
|
163
|
+
|
|
164
|
+
Resolution order:
|
|
165
|
+
|
|
166
|
+
1. ``CLAUDE_SMART_HOOK_LOG`` env var — escape hatch for tests and
|
|
167
|
+
advanced users who want the log elsewhere.
|
|
168
|
+
2. ``_LOG_PATH`` module attribute — what monkeypatched tests override.
|
|
169
|
+
3. ``~/.claude-smart/hook.log`` — the default.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Path: The absolute path to write to.
|
|
173
|
+
"""
|
|
174
|
+
override = os.environ.get("CLAUDE_SMART_HOOK_LOG")
|
|
175
|
+
if override:
|
|
176
|
+
return Path(override)
|
|
177
|
+
return _LOG_PATH
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _truncate(value: Any) -> Any:
|
|
181
|
+
"""Cap string values to ``_TRUNCATE_LIMIT`` chars so single-line writes
|
|
182
|
+
stay below the POSIX atomic-append limit.
|
|
183
|
+
|
|
184
|
+
Non-string values pass through unchanged; the json encoder handles
|
|
185
|
+
them. ``None`` becomes ``null`` in the JSON, which is what we want.
|
|
186
|
+
"""
|
|
187
|
+
if isinstance(value, str) and len(value) > _TRUNCATE_LIMIT:
|
|
188
|
+
return value[:_TRUNCATE_LIMIT] + "…"
|
|
189
|
+
return value
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@contextlib.contextmanager
|
|
193
|
+
def _rotation_lock(path: Path) -> Iterator[None]:
|
|
194
|
+
"""Hold an exclusive cross-process flock on a sibling ``.lock`` file.
|
|
195
|
+
|
|
196
|
+
Serialises every ``log_event`` call against itself so the rotation
|
|
197
|
+
``read_bytes → write tmp → rename`` sequence cannot interleave with a
|
|
198
|
+
concurrent append. On Windows or when the lock cannot be acquired
|
|
199
|
+
(no ``fcntl``, read-only parent, etc.) the lock degrades to a no-op
|
|
200
|
+
and the caller proceeds unlocked — matches the pre-existing
|
|
201
|
+
best-effort policy of this module.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
path (Path): The log file path; the lock file is a sibling at
|
|
205
|
+
``<path>.lock``.
|
|
206
|
+
|
|
207
|
+
Yields:
|
|
208
|
+
None.
|
|
209
|
+
"""
|
|
210
|
+
if _fcntl is None:
|
|
211
|
+
yield
|
|
212
|
+
return
|
|
213
|
+
lock_path = path.with_name(path.name + ".lock")
|
|
214
|
+
fh: IO[bytes] | None = None
|
|
215
|
+
try:
|
|
216
|
+
fh = lock_path.open("ab")
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
_LOGGER.debug("hook_log: lock open %s failed: %s", lock_path, exc)
|
|
219
|
+
yield
|
|
220
|
+
return
|
|
221
|
+
try:
|
|
222
|
+
_fcntl.flock(fh.fileno(), _fcntl.LOCK_EX)
|
|
223
|
+
except OSError as exc:
|
|
224
|
+
_LOGGER.debug("hook_log: flock acquire failed: %s", exc)
|
|
225
|
+
fh.close()
|
|
226
|
+
yield
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
yield
|
|
230
|
+
finally:
|
|
231
|
+
with contextlib.suppress(OSError):
|
|
232
|
+
_fcntl.flock(fh.fileno(), _fcntl.LOCK_UN)
|
|
233
|
+
with contextlib.suppress(OSError):
|
|
234
|
+
fh.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _maybe_rotate(path: Path) -> None:
|
|
238
|
+
"""Cap ``path`` at ``_MAX_LOG_BYTES`` by dropping the oldest half in place.
|
|
239
|
+
|
|
240
|
+
The fast path is a single ``stat`` syscall — when the file is under
|
|
241
|
+
the cap (the overwhelmingly common case on every fire) we do nothing.
|
|
242
|
+
When over the cap we read the file, find the first newline at-or-after
|
|
243
|
+
the midpoint byte, and atomically replace the original with the bytes
|
|
244
|
+
*after* that newline. Splitting on a newline boundary preserves the
|
|
245
|
+
JSON Lines invariant: every retained line is a complete record.
|
|
246
|
+
|
|
247
|
+
We picked this over ``logging.handlers.RotatingFileHandler`` because:
|
|
248
|
+
|
|
249
|
+
1. ``hook_log`` deliberately avoids the ``logging`` framework — see
|
|
250
|
+
module docstring. Adding a handler reintroduces the configuration
|
|
251
|
+
fragility we were dodging.
|
|
252
|
+
2. ``RotatingFileHandler`` produces ``hook.log.1``, ``hook.log.2`` …
|
|
253
|
+
sidecar files; our forensic story is "one file, tail it". Users
|
|
254
|
+
have already wired up tooling around that path.
|
|
255
|
+
3. In-place truncate-to-second-half is O(n) on the rare rotation
|
|
256
|
+
event and zero-cost on every other write. Backup file management
|
|
257
|
+
would add steady-state inode pressure for no benefit.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
path (Path): The log file to inspect. Need not exist — the
|
|
261
|
+
``FileNotFoundError`` branch of the ``stat`` call returns
|
|
262
|
+
cleanly via the ``OSError`` guard.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
None
|
|
266
|
+
"""
|
|
267
|
+
try:
|
|
268
|
+
size = path.stat().st_size
|
|
269
|
+
except OSError as exc:
|
|
270
|
+
_LOGGER.debug("hook_log: stat for rotation failed: %s", exc)
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
if size < _MAX_LOG_BYTES:
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
data = path.read_bytes()
|
|
278
|
+
except OSError as exc:
|
|
279
|
+
_LOGGER.debug("hook_log: read for rotation failed: %s", exc)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
midpoint = len(data) // 2
|
|
283
|
+
cut = data.find(b"\n", midpoint)
|
|
284
|
+
# ``find`` returns -1 when the second half has no newline at all,
|
|
285
|
+
# meaning the file is one giant record with no framing left to
|
|
286
|
+
# preserve. Drop the lot rather than split a record mid-byte.
|
|
287
|
+
tail = b"" if cut == -1 else data[cut + 1 :]
|
|
288
|
+
|
|
289
|
+
# Atomic replace: write to a sibling temp file then rename. This
|
|
290
|
+
# avoids the truncation window where a concurrent reader would see
|
|
291
|
+
# an empty file, and matches the contract that ``hook.log`` always
|
|
292
|
+
# contains complete JSONL framing.
|
|
293
|
+
tmp_path = path.with_name(path.name + ".rot")
|
|
294
|
+
try:
|
|
295
|
+
tmp_path.write_bytes(tail)
|
|
296
|
+
tmp_path.replace(path)
|
|
297
|
+
except OSError as exc:
|
|
298
|
+
_LOGGER.debug("hook_log: rotation rename failed: %s", exc)
|
|
299
|
+
# Best-effort cleanup of the temp file; ignore failures.
|
|
300
|
+
with contextlib.suppress(OSError):
|
|
301
|
+
tmp_path.unlink(missing_ok=True)
|