claude-smart 0.2.23 → 0.2.25

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 (113) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +76 -28
  3. package/bin/claude-smart.js +355 -11
  4. package/package.json +11 -1
  5. package/plugin/.claude-plugin/plugin.json +17 -0
  6. package/plugin/.codex-plugin/plugin.json +35 -0
  7. package/plugin/LICENSE +202 -0
  8. package/plugin/README.md +37 -0
  9. package/plugin/bin/cs-cite +77 -0
  10. package/plugin/commands/clear-all.md +8 -0
  11. package/plugin/commands/dashboard.md +8 -0
  12. package/plugin/commands/learn.md +12 -0
  13. package/plugin/commands/restart.md +8 -0
  14. package/plugin/commands/show.md +8 -0
  15. package/plugin/dashboard/AGENTS.md +6 -0
  16. package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
  17. package/plugin/dashboard/app/api/config/route.ts +16 -0
  18. package/plugin/dashboard/app/api/health/route.ts +10 -0
  19. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
  20. package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
  21. package/plugin/dashboard/app/api/sessions/route.ts +14 -0
  22. package/plugin/dashboard/app/configure/env/page.tsx +318 -0
  23. package/plugin/dashboard/app/configure/layout.tsx +47 -0
  24. package/plugin/dashboard/app/configure/page.tsx +5 -0
  25. package/plugin/dashboard/app/configure/server/page.tsx +258 -0
  26. package/plugin/dashboard/app/dashboard/page.tsx +227 -0
  27. package/plugin/dashboard/app/globals.css +129 -0
  28. package/plugin/dashboard/app/icon.png +0 -0
  29. package/plugin/dashboard/app/layout.tsx +40 -0
  30. package/plugin/dashboard/app/page.tsx +5 -0
  31. package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
  32. package/plugin/dashboard/app/preferences/page.tsx +126 -0
  33. package/plugin/dashboard/app/providers.tsx +12 -0
  34. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
  35. package/plugin/dashboard/app/sessions/page.tsx +186 -0
  36. package/plugin/dashboard/app/skills/page.tsx +362 -0
  37. package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
  38. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
  39. package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
  40. package/plugin/dashboard/components/common/empty-state.tsx +34 -0
  41. package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
  42. package/plugin/dashboard/components/common/page-header.tsx +34 -0
  43. package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
  44. package/plugin/dashboard/components/common/stat-card.tsx +38 -0
  45. package/plugin/dashboard/components/layout/nav-items.ts +22 -0
  46. package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
  47. package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
  48. package/plugin/dashboard/components/stall-banner.tsx +53 -0
  49. package/plugin/dashboard/components/ui/badge.tsx +52 -0
  50. package/plugin/dashboard/components/ui/button.tsx +60 -0
  51. package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
  52. package/plugin/dashboard/components/ui/input.tsx +20 -0
  53. package/plugin/dashboard/components/ui/label.tsx +20 -0
  54. package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
  55. package/plugin/dashboard/components/ui/select.tsx +201 -0
  56. package/plugin/dashboard/components/ui/separator.tsx +25 -0
  57. package/plugin/dashboard/components/ui/sheet.tsx +135 -0
  58. package/plugin/dashboard/components/ui/switch.tsx +32 -0
  59. package/plugin/dashboard/components.json +25 -0
  60. package/plugin/dashboard/eslint.config.mjs +16 -0
  61. package/plugin/dashboard/hooks/use-settings.tsx +88 -0
  62. package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
  63. package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
  64. package/plugin/dashboard/lib/config-file.ts +131 -0
  65. package/plugin/dashboard/lib/format.ts +58 -0
  66. package/plugin/dashboard/lib/reflexio-client.ts +238 -0
  67. package/plugin/dashboard/lib/reflexio-url.ts +17 -0
  68. package/plugin/dashboard/lib/session-reader.ts +245 -0
  69. package/plugin/dashboard/lib/status.ts +24 -0
  70. package/plugin/dashboard/lib/types.ts +145 -0
  71. package/plugin/dashboard/lib/utils.ts +6 -0
  72. package/plugin/dashboard/next.config.ts +7 -0
  73. package/plugin/dashboard/package-lock.json +10275 -0
  74. package/plugin/dashboard/package.json +37 -0
  75. package/plugin/dashboard/postcss.config.mjs +7 -0
  76. package/plugin/dashboard/public/claude-smart-icon.png +0 -0
  77. package/plugin/dashboard/tsconfig.json +34 -0
  78. package/plugin/hooks/codex-hooks.json +67 -0
  79. package/plugin/hooks/hooks.json +111 -0
  80. package/plugin/pyproject.toml +49 -0
  81. package/plugin/scripts/_codex_env.sh +27 -0
  82. package/plugin/scripts/_lib.sh +325 -0
  83. package/plugin/scripts/backend-service.sh +208 -0
  84. package/plugin/scripts/cli.sh +40 -0
  85. package/plugin/scripts/dashboard-build.sh +139 -0
  86. package/plugin/scripts/dashboard-open.sh +107 -0
  87. package/plugin/scripts/dashboard-service.sh +195 -0
  88. package/plugin/scripts/ensure-plugin-root.sh +84 -0
  89. package/plugin/scripts/hook_entry.sh +70 -0
  90. package/plugin/scripts/smart-install.sh +411 -0
  91. package/plugin/src/claude_smart/__init__.py +3 -0
  92. package/plugin/src/claude_smart/cli.py +1342 -0
  93. package/plugin/src/claude_smart/context_format.py +277 -0
  94. package/plugin/src/claude_smart/context_inject.py +92 -0
  95. package/plugin/src/claude_smart/cs_cite.py +236 -0
  96. package/plugin/src/claude_smart/events/__init__.py +1 -0
  97. package/plugin/src/claude_smart/events/post_tool.py +148 -0
  98. package/plugin/src/claude_smart/events/pre_tool.py +52 -0
  99. package/plugin/src/claude_smart/events/session_end.py +20 -0
  100. package/plugin/src/claude_smart/events/session_start.py +119 -0
  101. package/plugin/src/claude_smart/events/stop.py +393 -0
  102. package/plugin/src/claude_smart/events/user_prompt.py +73 -0
  103. package/plugin/src/claude_smart/hook.py +114 -0
  104. package/plugin/src/claude_smart/ids.py +56 -0
  105. package/plugin/src/claude_smart/internal_call.py +89 -0
  106. package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
  107. package/plugin/src/claude_smart/publish.py +71 -0
  108. package/plugin/src/claude_smart/query_compose.py +51 -0
  109. package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
  110. package/plugin/src/claude_smart/runtime.py +52 -0
  111. package/plugin/src/claude_smart/stall_banner.py +61 -0
  112. package/plugin/src/claude_smart/state.py +276 -0
  113. package/plugin/uv.lock +3720 -0
@@ -0,0 +1,393 @@
1
+ """Stop hook — finalize the current assistant turn, publish to reflexio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from claude_smart import cs_cite, ids, publish, runtime, state
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ # Stop fires immediately after Claude Code logs the final assistant message,
17
+ # and at tight gaps (<~10 ms observed) the transcript write hasn't propagated
18
+ # before our read. Retry briefly when the scan returns empty — note the cost
19
+ # is paid on *every* tool-only turn too (we can't tell those apart from a
20
+ # flush race), so keep the total budget small: 100 ms worst case.
21
+ _TRANSCRIPT_RETRY_DELAYS_S = (0.03, 0.07)
22
+
23
+ # Plan-mode approve/reject flows never fire a hook — Claude Code writes the
24
+ # decision as a ``user`` / ``tool_result`` transcript entry whose text begins
25
+ # with one of these markers. Surface them as synthetic User turns so reflexio
26
+ # sees the correction signal (especially rejection-with-comment feedback).
27
+ _PLAN_APPROVAL_MARKER = "User has approved your plan"
28
+ _PLAN_REJECTION_MARKER = "The user doesn't want to proceed"
29
+ _PLAN_REJECTION_COMMENT_MARKER = "the user said:"
30
+
31
+ _EXIT_PLAN_MODE_TOOL = "ExitPlanMode"
32
+
33
+
34
+ def _read_transcript_entries(path: Path) -> list[dict[str, Any]]:
35
+ """Parse the transcript JSONL once into a list of entries.
36
+
37
+ Stop's three scanners (assistant text, cs-cite ids, plan decisions) all
38
+ need the same parsed view; reading once and passing the list around keeps
39
+ the hook's wall-clock cost to a single ``read_text`` per fire even on
40
+ multi-megabyte transcripts.
41
+
42
+ Args:
43
+ path (Path): Absolute path to the transcript JSONL.
44
+
45
+ Returns:
46
+ list[dict[str, Any]]: Parsed entries in chronological order. Empty
47
+ on read failure; malformed lines are silently skipped.
48
+ """
49
+ try:
50
+ lines = path.read_text(encoding="utf-8").splitlines()
51
+ except OSError as exc:
52
+ _LOGGER.debug("transcript read failed: %s", exc)
53
+ return []
54
+ entries: list[dict[str, Any]] = []
55
+ for raw in lines:
56
+ candidate = raw.strip()
57
+ if not candidate:
58
+ continue
59
+ try:
60
+ entries.append(json.loads(candidate))
61
+ except json.JSONDecodeError:
62
+ continue
63
+ return entries
64
+
65
+
66
+ def _load_transcript_with_retry(path: Path) -> list[dict[str, Any]]:
67
+ """Read the transcript, retrying briefly when the assistant text is empty.
68
+
69
+ Stop fires immediately after Claude Code logs the final assistant
70
+ message; at tight gaps the transcript write hasn't propagated. Reread
71
+ a couple of times if the current-turn assistant text comes back empty.
72
+ Total worst-case wait is ~100ms.
73
+ """
74
+ entries = _read_transcript_entries(path)
75
+ if _scan_transcript_for_assistant_text(entries):
76
+ return entries
77
+ for delay in _TRANSCRIPT_RETRY_DELAYS_S:
78
+ time.sleep(delay)
79
+ entries = _read_transcript_entries(path)
80
+ if _scan_transcript_for_assistant_text(entries):
81
+ return entries
82
+ return entries
83
+
84
+
85
+ def _current_turn_assistant_entries(
86
+ entries: list[dict[str, Any]],
87
+ ) -> list[dict[str, Any]]:
88
+ """Return assistant entries for the in-progress turn, oldest first.
89
+
90
+ Walks ``entries`` from the end backward, collecting every
91
+ ``type: assistant`` entry until a real user message is reached (a user
92
+ entry whose content is not purely ``tool_result`` blocks — those continue
93
+ the assistant turn). Restores chronological order before returning.
94
+ """
95
+ out: list[dict[str, Any]] = []
96
+ for entry in reversed(entries):
97
+ entry_type = entry.get("type")
98
+ if entry_type == "assistant":
99
+ out.append(entry)
100
+ elif _is_user_turn_boundary(entry):
101
+ break
102
+ out.reverse()
103
+ return out
104
+
105
+
106
+ def _scan_transcript_for_assistant_text(entries: list[dict[str, Any]]) -> str:
107
+ """Join every text block from the current-turn assistant entries."""
108
+ parts: list[str] = []
109
+ for entry in _current_turn_assistant_entries(entries):
110
+ message = entry.get("message") or {}
111
+ parts.extend(_extract_text_blocks(message.get("content")))
112
+ return "\n\n".join(parts)
113
+
114
+
115
+ def _is_user_turn_boundary(entry: dict[str, Any]) -> bool:
116
+ """True if ``entry`` is the user message that opened the current turn.
117
+
118
+ Tool results are delivered as ``type: user`` entries whose content is a
119
+ list of ``tool_result`` blocks — those continue the assistant turn and
120
+ must not be treated as a boundary. A real user message has string
121
+ content or contains at least one non-``tool_result`` block.
122
+ """
123
+ if entry.get("type") != "user":
124
+ return False
125
+ message = entry.get("message") or {}
126
+ content = message.get("content")
127
+ if isinstance(content, str):
128
+ return True
129
+ if isinstance(content, list):
130
+ return any(
131
+ isinstance(block, dict) and block.get("type") != "tool_result"
132
+ for block in content
133
+ )
134
+ return False
135
+
136
+
137
+ def _extract_text_blocks(content: Any) -> list[str]:
138
+ """Return assistant-visible text from a transcript content list.
139
+
140
+ Picks up plain ``type: "text"`` blocks and the ``plan`` payload of
141
+ ``ExitPlanMode`` tool_use blocks. Plan mode emits the plan as a
142
+ tool_use argument rather than a text block, so without the second
143
+ branch the plan is silently dropped from the published turn.
144
+ """
145
+ if isinstance(content, str):
146
+ return [content]
147
+ if not isinstance(content, list):
148
+ return []
149
+ out: list[str] = []
150
+ for block in content:
151
+ if not isinstance(block, dict):
152
+ continue
153
+ btype = block.get("type")
154
+ if btype == "text":
155
+ text = block.get("text")
156
+ if isinstance(text, str) and text:
157
+ out.append(text)
158
+ elif btype == "tool_use" and block.get("name") == _EXIT_PLAN_MODE_TOOL:
159
+ plan = (block.get("input") or {}).get("plan")
160
+ if isinstance(plan, str) and plan:
161
+ out.append(f"Plan:\n{plan}")
162
+ return out
163
+
164
+
165
+ def _scan_transcript_for_plan_decisions(entries: list[dict[str, Any]]) -> list[str]:
166
+ """Return plan-mode approval/rejection content strings for the current turn.
167
+
168
+ Plan-mode decisions arrive as ``tool_result`` blocks on ``type: user``
169
+ transcript entries (the ExitPlanMode tool's "output"). PostToolUse runs
170
+ *before* the user decides, so the decision text never reaches the hook
171
+ payload — the transcript is the only place it exists. Walks forward from
172
+ the user message that opened the current turn so prior-turn decisions
173
+ (already published) are not re-emitted.
174
+
175
+ The walk tracks the most recent assistant ``tool_use`` name so that only
176
+ ``tool_result`` blocks immediately following an ``ExitPlanMode`` call are
177
+ treated as plan decisions — guards against false positives from other
178
+ tools whose output happens to contain the marker text (e.g. a ``Bash``
179
+ that echoes "User has approved your plan").
180
+
181
+ Args:
182
+ entries (list[dict[str, Any]]): Pre-parsed transcript entries.
183
+
184
+ Returns:
185
+ list[str]: Human-readable content strings, e.g. ``"Approved the plan."``
186
+ or ``"Rejected the plan. Instead: <comment>"``, in transcript
187
+ order. Empty when no decisions are found.
188
+ """
189
+ turn_start = 0
190
+ for idx in range(len(entries) - 1, -1, -1):
191
+ if _is_user_turn_boundary(entries[idx]):
192
+ turn_start = idx
193
+ break
194
+
195
+ decisions: list[str] = []
196
+ pending_tool_name: str | None = None
197
+ for entry in entries[turn_start:]:
198
+ message = entry.get("message") or {}
199
+ content = message.get("content")
200
+ if not isinstance(content, list):
201
+ continue
202
+ if entry.get("type") == "assistant":
203
+ for block in content:
204
+ if isinstance(block, dict) and block.get("type") == "tool_use":
205
+ pending_tool_name = block.get("name")
206
+ elif entry.get("type") == "user":
207
+ for block in content:
208
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
209
+ continue
210
+ if pending_tool_name != _EXIT_PLAN_MODE_TOOL:
211
+ continue
212
+ text = _tool_result_text(block.get("content"))
213
+ decision = _parse_plan_decision(text)
214
+ if decision:
215
+ decisions.append(decision)
216
+ # Each tool_use → tool_result pair is consumed once.
217
+ pending_tool_name = None
218
+ return decisions
219
+
220
+
221
+ def _tool_result_text(content: Any) -> str:
222
+ """Flatten a ``tool_result.content`` field into a searchable string.
223
+
224
+ Claude Code emits tool_result content as either a bare string or a list
225
+ of ``{type: "text", text: "…"}`` blocks depending on the tool; we accept
226
+ both so the plan-decision markers match regardless of shape.
227
+ """
228
+ if isinstance(content, str):
229
+ return content
230
+ if not isinstance(content, list):
231
+ return ""
232
+ parts: list[str] = []
233
+ for item in content:
234
+ if isinstance(item, str):
235
+ parts.append(item)
236
+ elif isinstance(item, dict):
237
+ inner = item.get("text") or item.get("content")
238
+ if isinstance(inner, str):
239
+ parts.append(inner)
240
+ return "\n".join(parts)
241
+
242
+
243
+ def _parse_plan_decision(text: str) -> str | None:
244
+ """Map a plan-mode tool_result text to a User-record content string."""
245
+ if not text:
246
+ return None
247
+ if _PLAN_APPROVAL_MARKER in text:
248
+ return "Approved the plan."
249
+ if _PLAN_REJECTION_MARKER in text:
250
+ _, sep, tail = text.partition(_PLAN_REJECTION_COMMENT_MARKER)
251
+ comment = tail.strip() if sep else ""
252
+ return (
253
+ f"Rejected the plan. Instead: {comment}"
254
+ if comment
255
+ else "Rejected the plan."
256
+ )
257
+ return None
258
+
259
+
260
+ def _scan_transcript_for_cs_cite_ids(entries: list[dict[str, Any]]) -> list[str]:
261
+ """Scan the current assistant turn for ``cs-cite`` Bash tool_use calls.
262
+
263
+ Collects citation ids from every matching Bash ``tool_use`` block in
264
+ the current turn. Multiple calls are merged; order follows Claude's
265
+ emission order (earliest first).
266
+
267
+ Args:
268
+ entries (list[dict[str, Any]]): Pre-parsed transcript entries.
269
+
270
+ Returns:
271
+ list[str]: Rank ids (e.g. ``"s1-ab12"``, ``"p2-cd34"``) in
272
+ emission order. Empty when no ``cs-cite`` call is found.
273
+ """
274
+ out: list[str] = []
275
+ for entry in _current_turn_assistant_entries(entries):
276
+ message = entry.get("message") or {}
277
+ out.extend(_extract_cs_cite_ids(message.get("content")))
278
+ return out
279
+
280
+
281
+ def _extract_cs_cite_ids(content: Any) -> list[str]:
282
+ """Return citation ids from all Bash ``cs-cite`` tool_use blocks in ``content``."""
283
+ if not isinstance(content, list):
284
+ return []
285
+ out: list[str] = []
286
+ for block in content:
287
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
288
+ continue
289
+ if block.get("name") != "Bash":
290
+ continue
291
+ tool_input = block.get("input") or {}
292
+ command = tool_input.get("command")
293
+ if not isinstance(command, str):
294
+ continue
295
+ out.extend(cs_cite.parse_citation_command(command))
296
+ return out
297
+
298
+
299
+ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str, Any]]:
300
+ """Map citation ids to ``{id, kind, title}`` entries via the session registry.
301
+
302
+ Unknown ids (Claude hallucinations, or items injected in a newer
303
+ session than this hook can see) are dropped. Duplicate ids within
304
+ one turn collapse to a single entry — the user-facing badge row
305
+ doesn't need the multiplicity.
306
+ """
307
+ if not cited_ids:
308
+ return []
309
+ registry = state.read_injected(session_id)
310
+ seen: set[str] = set()
311
+ resolved: list[dict[str, Any]] = []
312
+ for cid in cited_ids:
313
+ if cid in seen:
314
+ continue
315
+ entry = registry.get(cid)
316
+ if not entry:
317
+ continue
318
+ seen.add(cid)
319
+ item: dict[str, Any] = {
320
+ "id": entry.get("id", cid),
321
+ "kind": entry.get("kind", ""),
322
+ "title": entry.get("title", ""),
323
+ }
324
+ real_id = entry.get("real_id")
325
+ if real_id:
326
+ item["real_id"] = real_id
327
+ source_kind = entry.get("source_kind")
328
+ if isinstance(source_kind, str) and source_kind:
329
+ item["source_kind"] = source_kind
330
+ resolved.append(item)
331
+ return resolved
332
+
333
+
334
+ def handle(payload: dict[str, Any]) -> None:
335
+ session_id = payload.get("session_id")
336
+ if not session_id:
337
+ return
338
+
339
+ # Always append an Assistant record, even when the turn emitted only
340
+ # tool calls and no text. ``state.unpublished_slice`` folds any
341
+ # buffered ``Assistant_tool`` records into this turn's ``tools_used``;
342
+ # without this placeholder, those tools would be misattributed to the
343
+ # next assistant turn.
344
+ transcript_path = payload.get("transcript_path")
345
+ project_id = ids.resolve_project_id(payload.get("cwd"))
346
+
347
+ entries: list[dict[str, Any]] = []
348
+ if transcript_path:
349
+ path = Path(transcript_path)
350
+ if path.is_file():
351
+ entries = _load_transcript_with_retry(path)
352
+
353
+ last_assistant_message = payload.get("last_assistant_message")
354
+ assistant_text = (
355
+ last_assistant_message
356
+ if runtime.is_codex()
357
+ and isinstance(last_assistant_message, str)
358
+ and last_assistant_message
359
+ else _scan_transcript_for_assistant_text(entries)
360
+ )
361
+ transcript_cited_ids = _scan_transcript_for_cs_cite_ids(entries)
362
+ text_cited_ids = cs_cite.parse_text_citations(assistant_text)
363
+ cited_ids = [*text_cited_ids, *transcript_cited_ids]
364
+ cited_items = _resolve_cited_items(session_id, cited_ids)
365
+ plan_decisions = _scan_transcript_for_plan_decisions(entries)
366
+
367
+ now = int(time.time())
368
+ for decision_text in plan_decisions:
369
+ state.append(
370
+ session_id,
371
+ {
372
+ "ts": now,
373
+ "role": "User",
374
+ "content": decision_text,
375
+ "user_id": project_id,
376
+ },
377
+ )
378
+
379
+ record: dict[str, Any] = {
380
+ "ts": now,
381
+ "role": "Assistant",
382
+ "content": assistant_text,
383
+ "user_id": project_id,
384
+ }
385
+ if cited_items:
386
+ record["cited_items"] = cited_items
387
+ state.append(session_id, record)
388
+ publish.publish_unpublished(
389
+ session_id=session_id,
390
+ project_id=project_id,
391
+ force_extraction=False,
392
+ skip_aggregation=False,
393
+ )
@@ -0,0 +1,73 @@
1
+ """UserPromptSubmit hook — buffer the user turn and inject matching context.
2
+
3
+ Two responsibilities, in order:
4
+
5
+ 1. Buffer the prompt into the session JSONL (this is the sole source of
6
+ "User" role turns downstream — Claude Code replays the rest of the
7
+ transcript via tool events, not UserPromptSubmit).
8
+ 2. Use the prompt text as a search query against reflexio's preferences +
9
+ skills and emit the top hits as ``hookSpecificOutput.additionalContext``
10
+ so Claude sees relevant rules before planning the response.
11
+
12
+ The PreToolUse hook does similar retrieval keyed to tool-call text; this
13
+ hook covers the gap where a prompt-only turn (e.g. a question answered
14
+ from context without edits) never fires PreToolUse and so would otherwise
15
+ see no injected context at all. The shared pipeline lives in
16
+ ``context_inject.emit_context``.
17
+
18
+ Retrieval is best-effort: any failure from search (reflexio unreachable,
19
+ HTTP timeout, unexpected shape) is caught so the buffered-prompt
20
+ behaviour is always preserved. PreToolUse does not wrap — a tool-call
21
+ injection failure is invisible to the user, whereas a failed user-turn
22
+ would silently lose the prompt.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import time
29
+ from typing import Any
30
+
31
+ from claude_smart import context_inject, ids, state
32
+
33
+ _LOGGER = logging.getLogger(__name__)
34
+ _TOP_K = 3
35
+
36
+
37
+ def handle(payload: dict[str, Any]) -> None:
38
+ """UserPromptSubmit dispatcher — buffers the prompt, then injects context.
39
+
40
+ Args:
41
+ payload (dict[str, Any]): The Claude Code hook payload. Expected keys
42
+ ``session_id``, ``prompt``, and optionally ``cwd``.
43
+
44
+ Returns:
45
+ None: Side effects only — appends to the session buffer and may
46
+ write a ``hookSpecificOutput`` JSON document to stdout.
47
+ """
48
+ session_id = payload.get("session_id")
49
+ prompt = payload.get("prompt") or ""
50
+ if not session_id or not prompt:
51
+ return
52
+
53
+ project_id = ids.resolve_project_id(payload.get("cwd"))
54
+ state.append(
55
+ session_id,
56
+ {
57
+ "ts": int(time.time()),
58
+ "role": "User",
59
+ "content": prompt,
60
+ "user_id": project_id,
61
+ },
62
+ )
63
+
64
+ try:
65
+ context_inject.emit_context(
66
+ session_id=session_id,
67
+ project_id=project_id,
68
+ query=prompt,
69
+ hook_event_name="UserPromptSubmit",
70
+ top_k=_TOP_K,
71
+ )
72
+ except Exception as exc: # noqa: BLE001 — never break the user's turn
73
+ _LOGGER.debug("user_prompt context inject failed: %s", exc)
@@ -0,0 +1,114 @@
1
+ """Dispatch table for claude-smart hook events.
2
+
3
+ The plugin's ``hook_entry.sh`` calls either
4
+ ``python -m claude_smart.hook <event>`` (legacy Claude Code shape) or
5
+ ``python -m claude_smart.hook <host> <event>`` once per hook invocation.
6
+ This module reads the hook JSON from stdin, routes to the matching handler,
7
+ and makes sure no unhandled exception ever propagates.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import sys
15
+ from typing import Any, Callable
16
+
17
+ from claude_smart import runtime
18
+ from claude_smart.internal_call import is_internal_invocation
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+
23
+ def _load_handlers() -> dict[str, Callable[[dict[str, Any]], None]]:
24
+ from claude_smart.events import (
25
+ post_tool,
26
+ pre_tool,
27
+ session_end,
28
+ session_start,
29
+ stop,
30
+ user_prompt,
31
+ )
32
+
33
+ return {
34
+ "session-start": session_start.handle,
35
+ "user-prompt": user_prompt.handle,
36
+ "pre-tool": pre_tool.handle,
37
+ "post-tool": post_tool.handle,
38
+ "stop": stop.handle,
39
+ "session-end": session_end.handle,
40
+ }
41
+
42
+
43
+ def _read_stdin_json() -> dict[str, Any]:
44
+ """Parse stdin as JSON. Returns {} on empty or malformed input."""
45
+ try:
46
+ raw = sys.stdin.read()
47
+ except Exception as exc: # noqa: BLE001
48
+ _LOGGER.debug("stdin read failed: %s", exc)
49
+ return {}
50
+ if not raw.strip():
51
+ return {}
52
+ try:
53
+ parsed = json.loads(raw)
54
+ except json.JSONDecodeError as exc:
55
+ _LOGGER.debug("stdin JSON decode failed: %s", exc)
56
+ return {}
57
+ return parsed if isinstance(parsed, dict) else {}
58
+
59
+
60
+ def emit_continue() -> None:
61
+ """Fallback stdout — tells Claude Code to keep going without injection."""
62
+ payload = {"continue": True}
63
+ if not runtime.is_codex():
64
+ payload["suppressOutput"] = True
65
+ sys.stdout.write(json.dumps(payload))
66
+ sys.stdout.write("\n")
67
+
68
+
69
+ def _parse_args(argv: list[str]) -> tuple[str, str]:
70
+ """Return ``(host, event)`` for old and new hook argv shapes."""
71
+ if not argv:
72
+ return runtime.HOST_CLAUDE_CODE, ""
73
+ if len(argv) >= 2 and argv[0] in runtime.VALID_HOSTS:
74
+ return argv[0], argv[1]
75
+ return runtime.HOST_CLAUDE_CODE, argv[0]
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> int:
79
+ """Entry point used by ``python -m claude_smart.hook`` and the console script."""
80
+ argv = argv if argv is not None else sys.argv[1:]
81
+ host, event = _parse_args(argv)
82
+ runtime.set_host(host)
83
+ if not event:
84
+ _LOGGER.warning("hook dispatcher called with no event name")
85
+ emit_continue()
86
+ return 0
87
+
88
+ payload = _read_stdin_json()
89
+
90
+ # Self-feedback guard: when this hook fires inside reflexio's own
91
+ # `claude -p` subprocess (the claude-code LLM provider), skip all
92
+ # handlers so we don't publish the extractor's system prompt back
93
+ # into reflexio. See claude_smart.internal_call for detection logic.
94
+ if is_internal_invocation(payload):
95
+ emit_continue()
96
+ return 0
97
+
98
+ handlers = _load_handlers()
99
+ handler = handlers.get(event)
100
+ if handler is None:
101
+ _LOGGER.warning("unknown hook event: %s", event)
102
+ emit_continue()
103
+ return 0
104
+
105
+ try:
106
+ handler(payload)
107
+ except Exception as exc: # noqa: BLE001 — hooks must never crash the session.
108
+ _LOGGER.exception("hook handler %s raised: %s", event, exc)
109
+ emit_continue()
110
+ return 0
111
+
112
+
113
+ if __name__ == "__main__":
114
+ raise SystemExit(main())
@@ -0,0 +1,56 @@
1
+ """Resolve stable identifiers for Claude Code sessions.
2
+
3
+ Two identifiers matter to reflexio:
4
+
5
+ - ``session_id``: Claude Code's per-session id, passed in hook stdin. We
6
+ forward it to reflexio's interaction ``session_id`` field so individual
7
+ turns remain attributable to their conversation, but it is no longer
8
+ the scope key for extracted preferences.
9
+ - ``project_id``: a stable, cross-session name for the project. We use
10
+ this as reflexio's ``user_id`` for preferences, so user preferences
11
+ extracted in one session are visible to every later session in the
12
+ same repo. ``agent_version`` is hardcoded to ``"claude-code"`` in the
13
+ adapter so shared skills roll up globally across all projects rather than
14
+ per project.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import subprocess # noqa: S404 — git invocation with a fixed flag set.
22
+ from pathlib import Path
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+
27
+ def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
28
+ """Return a stable project identifier for the given working directory.
29
+
30
+ Prefers the basename of the git toplevel (so worktrees, submodules, and
31
+ `cd src/` all still map to the same project). Falls back to the cwd
32
+ basename when the directory is not inside a git repo.
33
+
34
+ Args:
35
+ cwd: Working directory to resolve. Defaults to ``os.getcwd()``.
36
+
37
+ Returns:
38
+ str: A non-empty identifier. Never raises.
39
+ """
40
+ base = Path(cwd) if cwd is not None else Path.cwd()
41
+ try:
42
+ result = subprocess.run( # noqa: S603, S607 — fixed argv, cwd is a Path.
43
+ ["git", "rev-parse", "--show-toplevel"],
44
+ cwd=base,
45
+ capture_output=True,
46
+ text=True,
47
+ check=False,
48
+ timeout=5,
49
+ )
50
+ if result.returncode == 0:
51
+ toplevel = result.stdout.strip()
52
+ if toplevel:
53
+ return Path(toplevel).name
54
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
55
+ _LOGGER.debug("git toplevel resolution failed: %s", exc)
56
+ return base.name or "unknown-project"