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.
@@ -22,63 +22,81 @@ MARKER_DIR="$HOME/.claude-smart"
22
22
  FAILURE_MARKER="$MARKER_DIR/install-failed"
23
23
  SUCCESS_MARKER="$MARKER_DIR/install-complete"
24
24
  INSTALL_LOCK="$MARKER_DIR/install.lock"
25
+ INSTALL_REAP_LOCK="$MARKER_DIR/install.lock.reap"
25
26
  mkdir -p "$MARKER_DIR"
26
27
 
28
+ remove_stale_install_lock() {
29
+ local expected current
30
+
31
+ expected="$1"
32
+ if ! mkdir "$INSTALL_REAP_LOCK" 2>/dev/null; then
33
+ sleep 1
34
+ return 0
35
+ fi
36
+ current="$(cat "$INSTALL_LOCK" 2>/dev/null || true)"
37
+ if [ "$current" = "$expected" ]; then
38
+ rm -f "$INSTALL_LOCK"
39
+ fi
40
+ rmdir "$INSTALL_REAP_LOCK" 2>/dev/null || true
41
+ }
42
+
43
+ acquire_install_lock() {
44
+ local lock_pid
45
+
46
+ if command -v flock >/dev/null 2>&1; then
47
+ exec 9>"$INSTALL_LOCK"
48
+ if ! flock 9; then
49
+ echo "[claude-smart] install lock failed; continuing without serialization" >&2
50
+ echo '{"continue":true,"suppressOutput":true}'
51
+ exit 0
52
+ fi
53
+ return 0
54
+ fi
55
+
56
+ while ! ( set -C; printf '%s\n' "$$" > "$INSTALL_LOCK" ) 2>/dev/null; do
57
+ lock_pid="$(cat "$INSTALL_LOCK" 2>/dev/null || true)"
58
+ case "$lock_pid" in
59
+ ''|*[!0-9]*)
60
+ remove_stale_install_lock "$lock_pid"
61
+ ;;
62
+ *)
63
+ if kill -0 "$lock_pid" 2>/dev/null; then
64
+ sleep 1
65
+ else
66
+ remove_stale_install_lock "$lock_pid"
67
+ fi
68
+ ;;
69
+ esac
70
+ done
71
+ trap '[ "$(cat "$INSTALL_LOCK" 2>/dev/null || true)" = "$$" ] && rm -f "$INSTALL_LOCK" || true' EXIT
72
+ }
73
+
27
74
  # Serialize concurrent installer runs (SessionStart hook + slash-command
28
75
  # self-heal can both invoke this script). Wait for the active installer
29
76
  # rather than returning early, otherwise callers can re-check uv before
30
77
  # the first install has finished and report a false missing-dependency error.
31
- if command -v flock >/dev/null 2>&1; then
32
- exec 9>"$INSTALL_LOCK"
33
- if ! flock 9; then
34
- echo "[claude-smart] install lock failed; continuing without serialization" >&2
35
- echo '{"continue":true,"suppressOutput":true}'
36
- exit 0
37
- fi
38
- fi
78
+ acquire_install_lock
39
79
 
40
80
  rm -f "$FAILURE_MARKER"
41
81
 
42
82
  write_failure() {
43
- local reason
83
+ local reason fp_hash
44
84
  reason="$1"
45
- printf '%s\n' "$reason" > "$FAILURE_MARKER"
85
+ fp_hash="$(claude_smart_install_fingerprint_hash "$PLUGIN_ROOT" "$HERE" 2>/dev/null || true)"
86
+ {
87
+ printf '%s\n' "$reason"
88
+ if [ -n "$fp_hash" ]; then
89
+ printf 'fingerprint=%s\n' "$fp_hash"
90
+ fi
91
+ } > "$FAILURE_MARKER"
46
92
  rm -f "$SUCCESS_MARKER"
47
93
  echo "[claude-smart] install failed: $reason" >&2
48
94
  echo '{"continue":true,"suppressOutput":true}'
49
95
  exit 0
50
96
  }
51
97
 
52
- fingerprint_file() {
53
- local path
54
- path="$1"
55
- if [ -f "$path" ]; then
56
- cksum "$path" 2>/dev/null | awk '{print $1 ":" $2}'
57
- else
58
- printf 'missing\n'
59
- fi
60
- }
61
-
62
98
  install_fingerprint() {
63
- printf 'plugin_root=%s\n' "$PLUGIN_ROOT"
64
- printf 'smart_install=%s\n' "$(fingerprint_file "$HERE/smart-install.sh")"
65
- printf 'pyproject=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/pyproject.toml")"
66
- printf 'uv_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/uv.lock")"
67
- # Resolved python interpreter — catches a system upgrade (3.12.4 → 3.12.5)
68
- # that would otherwise let install_complete return true against a venv
69
- # built against a now-deleted interpreter.
70
- if command -v uv >/dev/null 2>&1; then
71
- printf 'python=%s\n' "$(uv python find 3.12 2>/dev/null || echo missing)"
72
- else
73
- printf 'python=no-uv\n'
74
- fi
75
- if [ -d "$PLUGIN_ROOT/dashboard" ]; then
76
- printf 'dashboard_pkg=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package.json")"
77
- printf 'dashboard_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package-lock.json")"
78
- else
79
- printf 'dashboard_pkg=none\n'
80
- printf 'dashboard_lock=none\n'
81
- fi
99
+ claude_smart_install_fingerprint "$PLUGIN_ROOT" "$HERE"
82
100
  }
83
101
 
84
102
  install_complete() {
@@ -205,7 +205,8 @@ def _bootstrap_claude_code_install() -> tuple[bool, str]:
205
205
  if result.returncode != 0:
206
206
  return False, f"smart-install.sh failed in {plugin_root}"
207
207
  if _INSTALL_FAILURE_MARKER.is_file():
208
- reason = _INSTALL_FAILURE_MARKER.read_text().strip() or "unknown error"
208
+ first_line = _INSTALL_FAILURE_MARKER.read_text().splitlines()
209
+ reason = (first_line[0].strip() if first_line else "") or "unknown error"
209
210
  return False, reason
210
211
  return True, str(plugin_root)
211
212
 
@@ -1561,6 +1562,104 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
1561
1562
  return start_rc or 0
1562
1563
 
1563
1564
 
1565
+ def _resolve_reflexio_url() -> str:
1566
+ """Return the URL endpoint for the local claude-smart reflexio backend.
1567
+
1568
+ Mirrors ``claude_smart.reflexio_adapter`` so the CLI's one-shot
1569
+ ReflexioClient calls hit the same server as the long-lived adapter
1570
+ used by hook handlers.
1571
+
1572
+ Returns:
1573
+ str: The ``REFLEXIO_URL`` env var if set, otherwise the default
1574
+ local backend endpoint.
1575
+ """
1576
+ return os.environ.get("REFLEXIO_URL", "http://localhost:8071/")
1577
+
1578
+
1579
+ def _format_deleted_counts(deleted_counts: object) -> str:
1580
+ """Render ``deleted_counts`` from a ``clear_user_data`` response as a summary.
1581
+
1582
+ The backend returns one count per affected table. We surface the
1583
+ total in the headline string (``"removed N row(s)"``) and append a
1584
+ per-table breakdown when at least one bucket is populated, so the
1585
+ operator can tell *what* got cleared at a glance.
1586
+
1587
+ Args:
1588
+ deleted_counts (object): The ``deleted_counts`` field from the
1589
+ backend response. Expected to be a mapping of
1590
+ ``str -> int``; anything else is rendered as ``"unknown"``.
1591
+
1592
+ Returns:
1593
+ str: A short human-readable summary suitable for echoing to
1594
+ stdout (e.g. ``"3 row(s) (interactions=3)"``).
1595
+ """
1596
+ if not isinstance(deleted_counts, dict):
1597
+ return "unknown row(s)"
1598
+ pairs: list[tuple[str, int]] = []
1599
+ for key, value in deleted_counts.items():
1600
+ if isinstance(key, str) and isinstance(value, int):
1601
+ pairs.append((key, value))
1602
+ total = sum(value for _, value in pairs)
1603
+ if not pairs:
1604
+ return f"{total} row(s)"
1605
+ breakdown = ", ".join(f"{name}={count}" for name, count in sorted(pairs))
1606
+ return f"{total} row(s) ({breakdown})"
1607
+
1608
+
1609
+ def cmd_clear_user(args: argparse.Namespace) -> int:
1610
+ """Delete one user_id's playbooks, profiles, and interactions via the backend.
1611
+
1612
+ Per-user-scoped counterpart of ``clear-all``. Where ``clear-all`` does
1613
+ a filesystem-level wipe of the entire local storage root, ``clear-user``
1614
+ routes through the running reflexio backend's ``clear_user_data``
1615
+ endpoint so it can scope the deletion to a single ``user_id`` —
1616
+ leaving other users' data and globally-shared ``agent_playbooks``
1617
+ untouched. This is the primitive SWE-bench-style benchmark harnesses
1618
+ need when running many parallel evaluations under distinct synthetic
1619
+ user ids against one shared backend.
1620
+
1621
+ Args:
1622
+ args (argparse.Namespace): Parsed CLI args. Requires
1623
+ ``args.user_id`` (positional) and ``args.yes`` (the
1624
+ destructive-action confirmation flag, matching ``clear-all``).
1625
+
1626
+ Returns:
1627
+ int: 0 on success, 1 if confirmation is missing or the backend
1628
+ call fails.
1629
+ """
1630
+ user_id = args.user_id
1631
+ if not args.yes:
1632
+ sys.stdout.write(
1633
+ f"This will permanently delete all reflexio rows for user '{user_id}' "
1634
+ "(playbooks, profiles, interactions). Other users' data and "
1635
+ "agent_playbooks are not affected.\n"
1636
+ "Re-run with --yes to confirm.\n"
1637
+ )
1638
+ return 1
1639
+
1640
+ try:
1641
+ from reflexio import ReflexioClient # type: ignore[import-not-found]
1642
+ except ImportError as exc:
1643
+ sys.stderr.write(f"error: reflexio is not installed: {exc}\n")
1644
+ return 1
1645
+
1646
+ try:
1647
+ client = ReflexioClient(url_endpoint=_resolve_reflexio_url())
1648
+ # The companion reflexio PR adds ``clear_user_data``; keep the
1649
+ # CLI buildable until both sides ship together.
1650
+ response = client.clear_user_data(user_id) # pyright: ignore[reportAttributeAccessIssue]
1651
+ except Exception as exc: # noqa: BLE001 — surface backend failure verbatim.
1652
+ sys.stderr.write(f"error: clear-user failed: {exc}\n")
1653
+ return 1
1654
+
1655
+ deleted_counts = (
1656
+ response.get("deleted_counts") if isinstance(response, dict) else None
1657
+ )
1658
+ summary = _format_deleted_counts(deleted_counts)
1659
+ sys.stdout.write(f"Cleared user '{user_id}': removed {summary}.\n")
1660
+ return 0
1661
+
1662
+
1564
1663
  def _build_parser() -> argparse.ArgumentParser:
1565
1664
  parser = argparse.ArgumentParser(prog="claude-smart")
1566
1665
  sub = parser.add_subparsers(dest="command", required=True)
@@ -1625,6 +1724,25 @@ def _build_parser() -> argparse.ArgumentParser:
1625
1724
  )
1626
1725
  ca.set_defaults(func=cmd_clear_all)
1627
1726
 
1727
+ cu = sub.add_parser(
1728
+ "clear-user",
1729
+ help=(
1730
+ "Delete one user_id's playbooks, profiles, and interactions via "
1731
+ "the backend (leaves other users and agent_playbooks intact)"
1732
+ ),
1733
+ )
1734
+ cu.add_argument(
1735
+ "user_id",
1736
+ help="User id whose reflexio rows should be cleared",
1737
+ )
1738
+ cu.add_argument(
1739
+ "--yes",
1740
+ "-y",
1741
+ action="store_true",
1742
+ help="Confirm the destructive clear without prompting",
1743
+ )
1744
+ cu.set_defaults(func=cmd_clear_user)
1745
+
1628
1746
  rs = sub.add_parser(
1629
1747
  "restart",
1630
1748
  help="Restart the reflexio backend and dashboard to pick up changes",
@@ -1,20 +1,185 @@
1
- """SessionEnd hook — flush any remaining interactions; extraction runs async."""
1
+ """SessionEnd hook — flush any remaining interactions; extraction runs async.
2
+
3
+ When ``Stop`` never fires during a session (autonomous agentic loops
4
+ that don't reach ``stop_reason=end_turn``), the buffer at SessionEnd
5
+ time can contain orphan ``Assistant_tool`` records with no closing
6
+ ``Assistant`` anchor. The ``state.unpublished_slice`` contract folds
7
+ tool records into the *next* Assistant turn's ``tools_used``; without
8
+ that anchor, every buffered tool call is silently dropped at publish
9
+ time and reflexio receives only the original User prompt.
10
+
11
+ This module synthesises a closing Assistant record from whatever
12
+ assistant text the Claude Code transcript still has, so the publish
13
+ carries the full tool record stream instead of just the User prompt.
14
+ A short placeholder string is used when the transcript is unreadable.
15
+ """
2
16
 
3
17
  from __future__ import annotations
4
18
 
19
+ import logging
20
+ import time
21
+ from pathlib import Path
5
22
  from typing import Any
6
23
 
7
- from claude_smart import ids, publish
24
+ from claude_smart import ids, publish, state
25
+ from claude_smart.events.stop import (
26
+ _read_transcript_entries,
27
+ _scan_transcript_for_assistant_text,
28
+ )
29
+
30
+ _LOGGER = logging.getLogger(__name__)
31
+
32
+ # Fallback Assistant content used when the transcript is missing,
33
+ # unreadable, or has no recoverable assistant text. The string is
34
+ # distinctive so a future audit can grep for it and quantify how often
35
+ # a session ended without any model response.
36
+ _PLACEHOLDER_ASSISTANT_TEXT = "<session ended without final assistant response>"
8
37
 
9
38
 
10
- def handle(payload: dict[str, Any]) -> None:
39
+ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
40
+ """Flush any remaining buffered turns to reflexio.
41
+
42
+ Args:
43
+ payload (dict[str, Any]): Claude Code hook payload.
44
+
45
+ Returns:
46
+ tuple[PublishStatus, int] | None: The ``(status, count)`` tuple
47
+ from ``publish.publish_unpublished`` so the dispatcher's
48
+ forensic hook log captures the publish outcome. ``None``
49
+ when the payload had no ``session_id``.
50
+ """
11
51
  session_id = payload.get("session_id")
12
52
  if not session_id:
13
- return
53
+ return None
14
54
  project_id = ids.resolve_project_id(payload.get("cwd"))
15
- publish.publish_unpublished(
55
+
56
+ _maybe_synthesize_assistant_anchor(
16
57
  session_id=session_id,
17
58
  project_id=project_id,
18
- force_extraction=False,
59
+ transcript_path=payload.get("transcript_path"),
60
+ )
61
+
62
+ # ``force_extraction=True`` makes the reflexio server run the
63
+ # extractor synchronously for this publish, so by the time the HTTP
64
+ # call returns the playbook (if any) is already committed. SessionEnd
65
+ # is the final flush — the user/agent is already going away — so
66
+ # adding a few extra seconds here trades nothing for "snapshot_playbooks()
67
+ # reads after this point see the just-extracted rows." Stop hooks
68
+ # (the per-turn flushes) stay async so they don't slow interactive
69
+ # generation.
70
+ return publish.publish_unpublished(
71
+ session_id=session_id,
72
+ project_id=project_id,
73
+ force_extraction=True,
19
74
  skip_aggregation=False,
20
75
  )
76
+
77
+
78
+ def _maybe_synthesize_assistant_anchor(
79
+ *,
80
+ session_id: str,
81
+ project_id: str,
82
+ transcript_path: Any,
83
+ ) -> None:
84
+ """Append a closing Assistant record when the unpublished tail
85
+ holds orphan ``Assistant_tool`` rows.
86
+
87
+ Walks the post-watermark slice of the buffer and looks for a tail
88
+ pattern of ``Assistant_tool`` records that aren't followed by an
89
+ ``Assistant`` record. If that's the case, scans the Claude Code
90
+ transcript for whatever final assistant text is recoverable and
91
+ appends one synthetic ``Assistant`` record so
92
+ ``state.unpublished_slice`` can fold the tools into its
93
+ ``tools_used`` field at publish time.
94
+
95
+ Args:
96
+ session_id (str): Claude Code session id for the state buffer.
97
+ project_id (str): Resolved project_id used as the synthetic
98
+ record's ``user_id`` (matches Stop's convention).
99
+ transcript_path (Any): The ``transcript_path`` value from the
100
+ hook payload — accepted as ``Any`` because Claude Code may
101
+ send a string, ``None``, or omit the key entirely.
102
+
103
+ Returns:
104
+ None
105
+ """
106
+ records = state.read_all(session_id)
107
+ if not _tail_has_orphan_tools(records):
108
+ return
109
+
110
+ assistant_text = _recover_assistant_text(transcript_path)
111
+ state.append(
112
+ session_id,
113
+ {
114
+ "ts": int(time.time()),
115
+ "role": "Assistant",
116
+ "content": assistant_text,
117
+ "user_id": project_id,
118
+ "synthesised_by": "session_end_anchor",
119
+ },
120
+ )
121
+ _LOGGER.info(
122
+ "session_end: synthesised Assistant anchor for %s (len=%d)",
123
+ session_id,
124
+ len(assistant_text),
125
+ )
126
+
127
+
128
+ def _tail_has_orphan_tools(records: list[dict[str, Any]]) -> bool:
129
+ """Return True when the post-watermark tail ends with one or more
130
+ ``Assistant_tool`` records with no closing ``Assistant`` record.
131
+
132
+ Walks records from the latest ``published_up_to`` marker forward;
133
+ tracks the most recent record role. The tail is considered "orphan"
134
+ when at least one ``Assistant_tool`` was seen after the watermark
135
+ and no ``Assistant`` record was seen *after* the last
136
+ ``Assistant_tool``.
137
+
138
+ Args:
139
+ records (list[dict[str, Any]]): The full buffered record list
140
+ as returned by ``state.read_all``.
141
+
142
+ Returns:
143
+ bool: True if a synthetic anchor would unblock publishing tool
144
+ records that would otherwise be dropped.
145
+ """
146
+ # Find the most recent published_up_to watermark.
147
+ published = 0
148
+ for rec in records:
149
+ if "published_up_to" in rec:
150
+ published = rec["published_up_to"]
151
+
152
+ tail = records[published:]
153
+ saw_orphan_tool = False
154
+ for rec in tail:
155
+ role = rec.get("role")
156
+ if role == "Assistant_tool":
157
+ saw_orphan_tool = True
158
+ elif role == "Assistant":
159
+ saw_orphan_tool = False
160
+ return saw_orphan_tool
161
+
162
+
163
+ def _recover_assistant_text(transcript_path: Any) -> str:
164
+ """Return whatever assistant text the transcript still has, or a placeholder.
165
+
166
+ Args:
167
+ transcript_path (Any): Raw value from the hook payload.
168
+
169
+ Returns:
170
+ str: Concatenated current-turn assistant text from the
171
+ transcript, or ``_PLACEHOLDER_ASSISTANT_TEXT`` when the
172
+ transcript is unset, missing, or yields nothing.
173
+ """
174
+ if not isinstance(transcript_path, str) or not transcript_path:
175
+ return _PLACEHOLDER_ASSISTANT_TEXT
176
+ path = Path(transcript_path)
177
+ if not path.is_file():
178
+ return _PLACEHOLDER_ASSISTANT_TEXT
179
+ try:
180
+ entries = _read_transcript_entries(path)
181
+ except OSError as exc:
182
+ _LOGGER.debug("session_end transcript read failed: %s", exc)
183
+ return _PLACEHOLDER_ASSISTANT_TEXT
184
+ text = _scan_transcript_for_assistant_text(entries)
185
+ return text or _PLACEHOLDER_ASSISTANT_TEXT
@@ -15,8 +15,32 @@ from claude_smart.stall_banner import render_banner
15
15
  # Claude-smart's preferred extraction cadence — more frequent, smaller windows
16
16
  # than reflexio's out-of-box 10/5. Applied idempotently to the reflexio server
17
17
  # on every SessionStart via Adapter.apply_extraction_defaults.
18
- _CLAUDE_SMART_WINDOW_SIZE = 5
19
- _CLAUDE_SMART_STRIDE_SIZE = 3
18
+ #
19
+ # Env-var overrides let benchmark harnesses (SWE-bench Verified, in particular)
20
+ # run with ``CLAUDE_SMART_STRIDE_SIZE=1`` so a single short autonomous session
21
+ # triggers extraction on its sole publish instead of being silently filtered
22
+ # out by the stride pre-filter (``new < stride_size``). Production users keep
23
+ # the defaults; the values are only read on SessionStart, so a launcher that
24
+ # sets the env var has predictable effect for the entire Claude Code session.
25
+ #
26
+ # Parsing is defensive: a non-integer env value falls back to the default
27
+ # rather than raising at import time. SessionStart is the hook that wires up
28
+ # every subsequent hook, so an import-time crash here would silently disable
29
+ # the whole plugin for the session.
30
+
31
+
32
+ def _int_env(name: str, default: int) -> int:
33
+ raw = os.environ.get(name)
34
+ if raw is None:
35
+ return default
36
+ try:
37
+ return int(raw)
38
+ except ValueError:
39
+ return default
40
+
41
+
42
+ _CLAUDE_SMART_WINDOW_SIZE = _int_env("CLAUDE_SMART_WINDOW_SIZE", 5)
43
+ _CLAUDE_SMART_STRIDE_SIZE = _int_env("CLAUDE_SMART_STRIDE_SIZE", 3)
20
44
  # Optimizer is on by default. Set this env var to "0" to skip pushing the
21
45
  # claude-smart optimizer defaults on SessionStart (kill switch).
22
46
  _DISABLE_OPTIMIZER_ENV = "CLAUDE_SMART_ENABLE_OPTIMIZER"
@@ -308,10 +308,23 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
308
308
  return resolved
309
309
 
310
310
 
311
- def handle(payload: dict[str, Any]) -> None:
311
+ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
312
+ """Drain the buffered assistant turn to reflexio.
313
+
314
+ Args:
315
+ payload (dict[str, Any]): Claude Code hook payload.
316
+
317
+ Returns:
318
+ tuple[PublishStatus, int] | None: The ``(status, count)`` tuple
319
+ from ``publish.publish_unpublished`` so the dispatcher can
320
+ include it in the forensic hook log. ``None`` is returned
321
+ only when the handler short-circuits before publishing (no
322
+ ``session_id`` in the payload, or Codex internal-prompt
323
+ detection skipped the fire).
324
+ """
312
325
  session_id = payload.get("session_id")
313
326
  if not session_id:
314
- return
327
+ return None
315
328
 
316
329
  # Always append an Assistant record, even when the turn emitted only
317
330
  # tool calls and no text. ``state.unpublished_slice`` folds any
@@ -332,7 +345,7 @@ def handle(payload: dict[str, Any]) -> None:
332
345
  )
333
346
  if runtime.is_codex():
334
347
  if internal_call.is_codex_internal_prompt(prompt):
335
- return
348
+ return None
336
349
 
337
350
  last_assistant_message = payload.get("last_assistant_message")
338
351
  assistant_text = (
@@ -374,7 +387,7 @@ def handle(payload: dict[str, Any]) -> None:
374
387
  if cited_items:
375
388
  record["cited_items"] = cited_items
376
389
  state.append(session_id, record)
377
- publish.publish_unpublished(
390
+ return publish.publish_unpublished(
378
391
  session_id=session_id,
379
392
  project_id=project_id,
380
393
  force_extraction=False,
@@ -5,6 +5,13 @@ The plugin's ``hook_entry.sh`` calls either
5
5
  ``python -m claude_smart.hook <host> <event>`` once per hook invocation.
6
6
  This module reads the hook JSON from stdin, routes to the matching handler,
7
7
  and makes sure no unhandled exception ever propagates.
8
+
9
+ Every fire produces one structured JSON line in
10
+ ``~/.claude-smart/hook.log`` via ``hook_log.log_event`` — covers the
11
+ event name, session id, internal-invocation skip, handler outcome
12
+ (``ok`` / ``raised:<Exc>`` / ``unknown_event``), and for Stop/SessionEnd
13
+ the publish status + count. The log is the forensic trail for chasing
14
+ "hook didn't publish" mysteries from a single file.
8
15
  """
9
16
 
10
17
  from __future__ import annotations
@@ -12,15 +19,22 @@ from __future__ import annotations
12
19
  import json
13
20
  import logging
14
21
  import sys
15
- from typing import Any, Callable
22
+ from collections.abc import Callable
23
+ from contextlib import redirect_stdout
24
+ from io import StringIO
25
+ from typing import Any
16
26
 
17
- from claude_smart import runtime
27
+ from claude_smart import hook_log, ids, runtime
18
28
  from claude_smart.internal_call import is_internal_invocation
19
29
 
20
30
  _LOGGER = logging.getLogger(__name__)
21
31
 
22
32
 
23
- def _load_handlers() -> dict[str, Callable[[dict[str, Any]], None]]:
33
+ # Stop and SessionEnd return ``(PublishStatus, int)`` so the dispatcher can
34
+ # log the publish outcome; the other handlers return ``None`` (or nothing).
35
+ # Use ``Any`` here rather than two specialised typedefs — the dispatcher
36
+ # narrows at the call site via ``isinstance(result, tuple)``.
37
+ def _load_handlers() -> dict[str, Callable[[dict[str, Any]], Any]]:
24
38
  from claude_smart.events import (
25
39
  post_tool,
26
40
  pre_tool,
@@ -92,6 +106,15 @@ def main(argv: list[str] | None = None) -> int:
92
106
  # handlers so we don't publish the extractor's system prompt back
93
107
  # into reflexio. See claude_smart.internal_call for detection logic.
94
108
  if is_internal_invocation(payload):
109
+ hook_log.log_event(
110
+ event=event,
111
+ host=host,
112
+ session_id=payload.get("session_id"),
113
+ project_id=ids.resolve_project_id(payload.get("cwd")),
114
+ cwd=payload.get("cwd"),
115
+ internal_skipped=True,
116
+ handler_status="ok",
117
+ )
95
118
  emit_continue()
96
119
  return 0
97
120
 
@@ -99,14 +122,49 @@ def main(argv: list[str] | None = None) -> int:
99
122
  handler = handlers.get(event)
100
123
  if handler is None:
101
124
  _LOGGER.warning("unknown hook event: %s", event)
125
+ hook_log.log_event(
126
+ event=event,
127
+ host=host,
128
+ session_id=payload.get("session_id"),
129
+ project_id=ids.resolve_project_id(payload.get("cwd")),
130
+ cwd=payload.get("cwd"),
131
+ handler_status="unknown_event",
132
+ )
102
133
  emit_continue()
103
134
  return 0
104
135
 
136
+ handler_status = "ok"
137
+ publish_status: str | None = None
138
+ publish_count: int | None = None
139
+ handler_stdout = StringIO()
105
140
  try:
106
- handler(payload)
141
+ with redirect_stdout(handler_stdout):
142
+ result = handler(payload)
143
+ if isinstance(result, tuple) and len(result) == 2:
144
+ publish_status, publish_count = result
107
145
  except Exception as exc: # noqa: BLE001 — hooks must never crash the session.
108
146
  _LOGGER.exception("hook handler %s raised: %s", event, exc)
147
+ handler_status = f"raised:{type(exc).__name__}: {exc}"
109
148
  emit_continue()
149
+ else:
150
+ # Handlers that inject context already emit the full hook JSON. Silent
151
+ # handlers still need the continue response so the parent session does
152
+ # not wait on an empty stdout payload.
153
+ captured = handler_stdout.getvalue()
154
+ if captured.strip():
155
+ sys.stdout.write(captured)
156
+ else:
157
+ emit_continue()
158
+ hook_log.log_event(
159
+ event=event,
160
+ host=host,
161
+ session_id=payload.get("session_id"),
162
+ project_id=ids.resolve_project_id(payload.get("cwd")),
163
+ cwd=payload.get("cwd"),
164
+ handler_status=handler_status,
165
+ publish_status=publish_status,
166
+ publish_count=publish_count,
167
+ )
110
168
  return 0
111
169
 
112
170