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.
@@ -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)
package/plugin/uv.lock CHANGED
@@ -419,7 +419,7 @@ wheels = [
419
419
 
420
420
  [[package]]
421
421
  name = "claude-smart"
422
- version = "0.2.29"
422
+ version = "0.2.31"
423
423
  source = { editable = "." }
424
424
  dependencies = [
425
425
  { name = "chromadb" },