claude-smart 0.2.30 → 0.2.32

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 (64) hide show
  1. package/README.md +2 -2
  2. package/bin/claude-smart.js +172 -18
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/api/config/route.ts +11 -2
  7. package/plugin/dashboard/app/api/health/route.ts +45 -6
  8. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
  9. package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
  10. package/plugin/dashboard/app/configure/env/page.tsx +36 -31
  11. package/plugin/dashboard/app/configure/layout.tsx +1 -1
  12. package/plugin/dashboard/app/configure/server/page.tsx +8 -14
  13. package/plugin/dashboard/app/dashboard/page.tsx +311 -115
  14. package/plugin/dashboard/app/globals.css +80 -66
  15. package/plugin/dashboard/app/layout.tsx +13 -10
  16. package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
  17. package/plugin/dashboard/app/preferences/page.tsx +154 -54
  18. package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
  19. package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
  20. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
  21. package/plugin/dashboard/app/sessions/page.tsx +14 -10
  22. package/plugin/dashboard/app/skills/page.tsx +175 -56
  23. package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
  24. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
  25. package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
  26. package/plugin/dashboard/components/common/empty-state.tsx +4 -2
  27. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  28. package/plugin/dashboard/components/common/page-header.tsx +5 -3
  29. package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
  30. package/plugin/dashboard/components/common/stat-card.tsx +9 -5
  31. package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
  32. package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
  33. package/plugin/dashboard/components/ui/input.tsx +1 -0
  34. package/plugin/dashboard/hooks/use-settings.tsx +30 -61
  35. package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
  36. package/plugin/dashboard/lib/config-file.ts +2 -0
  37. package/plugin/dashboard/lib/reflexio-client.ts +23 -48
  38. package/plugin/dashboard/lib/session-reader.ts +222 -6
  39. package/plugin/dashboard/lib/types.ts +20 -1
  40. package/plugin/dashboard/package-lock.json +70 -95
  41. package/plugin/dashboard/package.json +5 -2
  42. package/plugin/hooks/hooks.json +1 -1
  43. package/plugin/pyproject.toml +1 -1
  44. package/plugin/scripts/_lib.sh +126 -0
  45. package/plugin/scripts/backend-service.sh +32 -7
  46. package/plugin/scripts/cli.sh +4 -2
  47. package/plugin/scripts/codex-hook.js +100 -3
  48. package/plugin/scripts/dashboard-service.sh +98 -19
  49. package/plugin/scripts/hook_entry.sh +32 -11
  50. package/plugin/scripts/smart-install.sh +27 -44
  51. package/plugin/src/claude_smart/cli.py +204 -20
  52. package/plugin/src/claude_smart/context_format.py +244 -6
  53. package/plugin/src/claude_smart/context_inject.py +8 -1
  54. package/plugin/src/claude_smart/cs_cite.py +186 -34
  55. package/plugin/src/claude_smart/env_config.py +102 -0
  56. package/plugin/src/claude_smart/events/session_end.py +171 -6
  57. package/plugin/src/claude_smart/events/session_start.py +26 -2
  58. package/plugin/src/claude_smart/events/stop.py +48 -9
  59. package/plugin/src/claude_smart/hook.py +62 -4
  60. package/plugin/src/claude_smart/hook_log.py +301 -0
  61. package/plugin/src/claude_smart/internal_call.py +30 -0
  62. package/plugin/src/claude_smart/publish.py +5 -0
  63. package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
  64. package/plugin/uv.lock +1 -1
@@ -0,0 +1,102 @@
1
+ """Shared ``~/.reflexio/.env`` helpers for claude-smart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ REFLEXIO_ENV_PATH = Path.home() / ".reflexio" / ".env"
9
+ MANAGED_REFLEXIO_URL = "https://www.reflexio.ai/"
10
+ REFLEXIO_URL_ENV = "REFLEXIO_URL"
11
+ REFLEXIO_API_KEY_ENV = "REFLEXIO_API_KEY"
12
+
13
+
14
+ def parse_env_line(line: str) -> tuple[str, str] | None:
15
+ """Parse a simple dotenv ``KEY=value`` line.
16
+
17
+ This intentionally ignores comments, blank lines, and shell features. The
18
+ Reflexio env writer emits plain quoted assignments, which is all
19
+ claude-smart needs here.
20
+ """
21
+ stripped = line.strip()
22
+ if not stripped or stripped.startswith("#"):
23
+ return None
24
+ if stripped.startswith("export "):
25
+ stripped = stripped[len("export ") :].lstrip()
26
+ key, sep, raw_value = stripped.partition("=")
27
+ if not sep:
28
+ return None
29
+ key = key.strip()
30
+ if not key or not key.replace("_", "").isalnum() or key[0].isdigit():
31
+ return None
32
+ value = raw_value.strip()
33
+ if len(value) >= 2 and (
34
+ (value[0] == value[-1] == '"') or (value[0] == value[-1] == "'")
35
+ ):
36
+ value = value[1:-1]
37
+ return key, value
38
+
39
+
40
+ def load_reflexio_env(path: Path | None = None) -> None:
41
+ """Load ``~/.reflexio/.env`` into ``os.environ`` without overriding values."""
42
+ path = path or REFLEXIO_ENV_PATH
43
+ try:
44
+ text = path.read_text()
45
+ except OSError:
46
+ return
47
+ for line in text.splitlines():
48
+ parsed = parse_env_line(line)
49
+ if parsed is None:
50
+ continue
51
+ key, value = parsed
52
+ os.environ.setdefault(key, value)
53
+
54
+
55
+ def set_env_vars(path: Path, values: dict[str, str]) -> list[str]:
56
+ """Upsert dotenv keys while preserving comments and unrelated entries."""
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ try:
59
+ existing = path.read_text()
60
+ except OSError:
61
+ existing = ""
62
+
63
+ lines = existing.splitlines()
64
+ seen: set[str] = set()
65
+ out: list[str] = []
66
+ for line in lines:
67
+ parsed = parse_env_line(line)
68
+ if parsed is None:
69
+ out.append(line)
70
+ continue
71
+ key, _old = parsed
72
+ if key in values:
73
+ out.append(f'{key}="{_escape_env_value(values[key])}"')
74
+ seen.add(key)
75
+ else:
76
+ out.append(line)
77
+
78
+ added: list[str] = []
79
+ for key, value in values.items():
80
+ if key in seen:
81
+ continue
82
+ out.append(f'{key}="{_escape_env_value(value)}"')
83
+ added.append(key)
84
+
85
+ content = "\n".join(out)
86
+ path.write_text(content + ("\n" if content else ""), encoding="utf-8")
87
+ path.chmod(0o600)
88
+ return added
89
+
90
+
91
+ def mask_secret(value: str) -> str:
92
+ """Return a display-safe API key preview."""
93
+ if not value:
94
+ return ""
95
+ if len(value) <= 8:
96
+ return "*" * len(value)
97
+ prefix = value[:5] if "-" in value[:8] else value[:4]
98
+ return f"{prefix}****{value[-4:]}"
99
+
100
+
101
+ def _escape_env_value(value: str) -> str:
102
+ return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -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"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import logging
7
+ import os
7
8
  import time
8
9
  from pathlib import Path
9
10
  from typing import Any
@@ -289,7 +290,7 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
289
290
  for cid in cited_ids:
290
291
  if cid in seen:
291
292
  continue
292
- entry = registry.get(cid)
293
+ entry = _registry_entry_for_citation(registry, cid)
293
294
  if not entry:
294
295
  continue
295
296
  seen.add(cid)
@@ -308,10 +309,44 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
308
309
  return resolved
309
310
 
310
311
 
311
- def handle(payload: dict[str, Any]) -> None:
312
+ def _registry_entry_for_citation(
313
+ registry: dict[str, dict[str, Any]], citation: str
314
+ ) -> dict[str, Any] | None:
315
+ """Resolve a legacy rank id or a dashboard-route citation token."""
316
+ if not citation.startswith("route:"):
317
+ return registry.get(citation)
318
+ try:
319
+ _, kind, source_kind, real_id = citation.split(":", 3)
320
+ except ValueError:
321
+ return None
322
+ for entry in registry.values():
323
+ if entry.get("kind") != kind:
324
+ continue
325
+ if str(entry.get("real_id") or "") != real_id:
326
+ continue
327
+ if kind == "playbook" and entry.get("source_kind") != source_kind:
328
+ continue
329
+ return entry
330
+ return None
331
+
332
+
333
+ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
334
+ """Drain the buffered assistant turn to reflexio.
335
+
336
+ Args:
337
+ payload (dict[str, Any]): Claude Code hook payload.
338
+
339
+ Returns:
340
+ tuple[PublishStatus, int] | None: The ``(status, count)`` tuple
341
+ from ``publish.publish_unpublished`` so the dispatcher can
342
+ include it in the forensic hook log. ``None`` is returned
343
+ only when the handler short-circuits before publishing (no
344
+ ``session_id`` in the payload, or Codex internal-prompt
345
+ detection skipped the fire).
346
+ """
312
347
  session_id = payload.get("session_id")
313
348
  if not session_id:
314
- return
349
+ return None
315
350
 
316
351
  # Always append an Assistant record, even when the turn emitted only
317
352
  # tool calls and no text. ``state.unpublished_slice`` folds any
@@ -330,9 +365,8 @@ def handle(payload: dict[str, Any]) -> None:
330
365
  prompt = payload.get("prompt") or (
331
366
  _scan_transcript_for_user_text(entries) if runtime.is_codex() else ""
332
367
  )
333
- if runtime.is_codex():
334
- if internal_call.is_codex_internal_prompt(prompt):
335
- return
368
+ if runtime.is_codex() and internal_call.is_codex_internal_prompt(prompt):
369
+ return None
336
370
 
337
371
  last_assistant_message = payload.get("last_assistant_message")
338
372
  assistant_text = (
@@ -344,11 +378,16 @@ def handle(payload: dict[str, Any]) -> None:
344
378
  )
345
379
  if (
346
380
  runtime.is_codex()
347
- and internal_call.is_codex_title_response(assistant_text)
381
+ and (
382
+ internal_call.is_codex_title_response(assistant_text)
383
+ or internal_call.is_codex_suggestions_response(assistant_text)
384
+ )
348
385
  and not prompt
349
386
  and not _has_unpublished_user_turn(session_id)
350
387
  ):
351
- return
388
+ return None
389
+ if os.environ.get("CLAUDE_SMART_CITATIONS", "on") == "off":
390
+ assistant_text = cs_cite.strip_marker_lines(assistant_text)
352
391
  text_cited_ids = cs_cite.parse_text_citations(assistant_text)
353
392
  cited_items = _resolve_cited_items(session_id, text_cited_ids)
354
393
  plan_decisions = _scan_transcript_for_plan_decisions(entries)
@@ -374,7 +413,7 @@ def handle(payload: dict[str, Any]) -> None:
374
413
  if cited_items:
375
414
  record["cited_items"] = cited_items
376
415
  state.append(session_id, record)
377
- publish.publish_unpublished(
416
+ return publish.publish_unpublished(
378
417
  session_id=session_id,
379
418
  project_id=project_id,
380
419
  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