claude-smart 0.2.28 → 0.2.29

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.
@@ -69,6 +69,8 @@ _DASHBOARD_DIR = _PLUGIN_ROOT / "dashboard"
69
69
  _BACKEND_SCRIPT = _SCRIPTS_DIR / "backend-service.sh"
70
70
  _DASHBOARD_SCRIPT = _SCRIPTS_DIR / "dashboard-service.sh"
71
71
  _REFLEXIO_DIR = Path.home() / ".reflexio"
72
+ _STATE_DIR = Path.home() / ".claude-smart"
73
+ _INSTALL_FAILURE_MARKER = _STATE_DIR / "install-failed"
72
74
  _DEFAULT_STORAGE_ROOT = _REFLEXIO_DIR / "data"
73
75
  _REFLEXIO_CONFIG_PATH = _REFLEXIO_DIR / "configs" / "config_self-host-org.json"
74
76
  _LOCAL_STORAGE_ENV = "LOCAL_STORAGE_PATH"
@@ -76,8 +78,11 @@ _CODEX_REQUIRED_FILES = (
76
78
  Path(".agents/plugins/marketplace.json"),
77
79
  Path("plugin/.codex-plugin/plugin.json"),
78
80
  Path("plugin/hooks/codex-hooks.json"),
79
- Path("plugin/scripts/codex-claude-compat.py"),
81
+ Path("plugin/scripts/codex-claude-compat"),
82
+ Path("plugin/scripts/codex-claude-compat.cmd"),
83
+ Path("plugin/scripts/codex-claude-compat.js"),
80
84
  Path("plugin/scripts/codex-hook.js"),
85
+ Path("plugin/scripts/backend-log-runner.sh"),
81
86
  Path("plugin/scripts/_codex_env.sh"),
82
87
  )
83
88
  _COPYTREE_IGNORE = shutil.ignore_patterns(
@@ -125,6 +130,86 @@ def _seed_reflexio_env() -> list[str]:
125
130
  return missing
126
131
 
127
132
 
133
+ def _find_claude_code_plugin_root() -> Path | None:
134
+ """Locate the installed Claude Code plugin root after native install."""
135
+ cache_root = (
136
+ Path.home()
137
+ / ".claude"
138
+ / "plugins"
139
+ / "cache"
140
+ / _CODEX_MARKETPLACE_NAME
141
+ / "claude-smart"
142
+ )
143
+ candidates: list[Path] = []
144
+ if cache_root.is_dir():
145
+ for child in cache_root.iterdir():
146
+ if (
147
+ child.is_dir()
148
+ and (child / "pyproject.toml").is_file()
149
+ and (child / "scripts" / "smart-install.sh").is_file()
150
+ ):
151
+ candidates.append(child)
152
+ candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
153
+ candidates.extend(
154
+ [
155
+ Path.home()
156
+ / ".claude"
157
+ / "plugins"
158
+ / "marketplaces"
159
+ / _CODEX_MARKETPLACE_NAME
160
+ / "plugin",
161
+ _PLUGIN_ROOT,
162
+ ]
163
+ )
164
+ for candidate in candidates:
165
+ if (
166
+ (candidate / "pyproject.toml").is_file()
167
+ and (candidate / "scripts" / "smart-install.sh").is_file()
168
+ ):
169
+ return candidate
170
+ return None
171
+
172
+
173
+ def _force_plugin_root(plugin_root: Path) -> None:
174
+ """Point ~/.reflexio/plugin-root at the installed plugin root."""
175
+ _REFLEXIO_DIR.mkdir(parents=True, exist_ok=True)
176
+ link = _REFLEXIO_DIR / "plugin-root"
177
+ if link.is_symlink() or link.is_file():
178
+ link.unlink()
179
+ elif link.exists():
180
+ raise OSError(f"refusing to replace non-symlink plugin-root at {link}")
181
+ try:
182
+ link.symlink_to(plugin_root, target_is_directory=True)
183
+ except OSError:
184
+ if link.exists():
185
+ raise
186
+ (_REFLEXIO_DIR / "plugin-root.txt").write_text(f"{plugin_root}\n")
187
+
188
+
189
+ def _bootstrap_claude_code_install() -> tuple[bool, str]:
190
+ """Run smart-install immediately for the installed Claude Code plugin."""
191
+ plugin_root = _find_claude_code_plugin_root()
192
+ if plugin_root is None:
193
+ return False, "could not locate installed Claude Code plugin root after install"
194
+ try:
195
+ _force_plugin_root(plugin_root)
196
+ except OSError as exc:
197
+ return False, str(exc)
198
+ bash = shutil.which("bash")
199
+ if not bash:
200
+ return False, "bash is required to bootstrap claude-smart dependencies"
201
+ result = subprocess.run(
202
+ [bash, str(plugin_root / "scripts" / "smart-install.sh")],
203
+ cwd=plugin_root,
204
+ )
205
+ if result.returncode != 0:
206
+ return False, f"smart-install.sh failed in {plugin_root}"
207
+ if _INSTALL_FAILURE_MARKER.is_file():
208
+ reason = _INSTALL_FAILURE_MARKER.read_text().strip() or "unknown error"
209
+ return False, reason
210
+ return True, str(plugin_root)
211
+
212
+
128
213
  def _missing_codex_marketplace_files(root: Path) -> list[Path]:
129
214
  return [entry for entry in _CODEX_REQUIRED_FILES if not (root / entry).is_file()]
130
215
 
@@ -789,7 +874,21 @@ def cmd_install(args: argparse.Namespace) -> int:
789
874
  if added:
790
875
  sys.stdout.write(f"Seeded {_REFLEXIO_ENV_PATH} with {', '.join(added)}.\n")
791
876
 
792
- sys.stdout.write("\nclaude-smart installed. Restart Claude Code in your project.\n")
877
+ bootstrapped, message = _bootstrap_claude_code_install()
878
+ if not bootstrapped:
879
+ sys.stderr.write(
880
+ f"error: claude-smart installed, but dependency bootstrap failed: {message}\n"
881
+ )
882
+ sys.stderr.write(
883
+ "Fix the issue above, then run /claude-smart:restart or restart Claude Code to retry.\n"
884
+ )
885
+ return 1
886
+ sys.stdout.write(f"Prepared claude-smart runtime at {message}.\n")
887
+
888
+ sys.stdout.write(
889
+ "\nclaude-smart installed and dependencies are prepared. "
890
+ "Restart Claude Code in your project.\n"
891
+ )
793
892
  return 0
794
893
 
795
894
 
@@ -6,8 +6,7 @@ search, (b) render the hits with ``context_format.render_inline_with_registry``,
6
6
  (d) emit a Claude Code ``hookSpecificOutput.additionalContext`` envelope
7
7
  on stdout. This module owns that shared pipeline so the two hook
8
8
  handlers keep exactly one source of truth for the injection contract —
9
- the envelope shape, the registry schema, and the ordering of
10
- ``ensure_installed`` / ``append_injected``.
9
+ the envelope shape, the registry schema, and the injected context append.
11
10
 
12
11
  The caller remains responsible for handler-specific framing (PreToolUse
13
12
  needs ``hook.emit_continue()`` on the empty path; UserPromptSubmit wraps
@@ -21,7 +20,7 @@ import json
21
20
  import sys
22
21
  import time
23
22
 
24
- from claude_smart import context_format, cs_cite, state
23
+ from claude_smart import context_format, state
25
24
  from claude_smart.reflexio_adapter import Adapter
26
25
 
27
26
 
@@ -69,7 +68,6 @@ def emit_context(
69
68
  if not markdown:
70
69
  return False
71
70
 
72
- cs_cite.ensure_installed()
73
71
  state.append_injected(
74
72
  session_id,
75
73
  (dict(entry, ts=int(time.time())) for entry in registry),
@@ -11,8 +11,7 @@ impactful replies with a marker like::
11
11
 
12
12
  The Stop hook later scans the assistant text for those markers and resolves
13
13
  the ids against a per-session registry persisted at
14
- ``~/.claude-smart/sessions/<session_id>.injected.jsonl``. Legacy ``cs-cite``
15
- Bash tool calls are still accepted as a fallback for older instructions.
14
+ ``~/.claude-smart/sessions/<session_id>.injected.jsonl``.
16
15
 
17
16
  Why rank + fingerprint: rank alone resets at every injection, so a
18
17
  later injection's ``s1`` would silently overwrite an earlier entry in
@@ -27,48 +26,19 @@ This module holds:
27
26
  - ``rank_id``: ``p{n}-{fp}`` / ``s{n}-{fp}`` tag for a given
28
27
  (kind, rank, real_id) tuple. Fingerprint is omitted when no real id
29
28
  is available. ``p`` is preference, ``s`` is skill.
30
- - ``CITATION_CMD_RE``: regex matching a valid legacy ``cs-cite`` command line.
31
- - ``ensure_installed``: idempotent copy of ``plugin/bin/cs-cite`` to
32
- ``~/.claude-smart/bin/cs-cite`` with the executable bit set.
33
29
  - ``CITATION_INSTRUCTION``: the trailer text appended to injected context
34
30
  so the assistant knows when and how to emit the citation marker.
35
31
  """
36
32
 
37
33
  from __future__ import annotations
38
34
 
39
- import logging
40
35
  import re
41
- import shutil
42
- import stat as stat_
43
- from pathlib import Path
44
36
  from typing import Any
45
37
 
46
- _LOGGER = logging.getLogger(__name__)
47
-
48
- _THIS_DIR = Path(__file__).resolve().parent
49
- _PLUGIN_ROOT = _THIS_DIR.parents[1] # plugin/src/claude_smart/ -> plugin/
50
- _SOURCE_SCRIPT = _PLUGIN_ROOT / "bin" / "cs-cite"
51
- _INSTALL_DIR = Path.home() / ".claude-smart" / "bin"
52
- INSTALL_PATH = _INSTALL_DIR / "cs-cite"
53
-
54
38
  _FINGERPRINT_LEN = 4
55
39
 
56
- # Match a bare `cs-cite <ids>` invocation. Ids are rank tokens of the
57
- # form `p<N>` (preference) or `s<N>` (skill) with an optional
58
- # `-<fp>` fingerprint (1-4 alphanumeric chars), optionally
59
- # `cs:`-prefixed (since bullets render as `[cs:p1-ab12]` and the model
60
- # often copies the tag verbatim). The `(?i:...)` inline flags make the
61
- # prefix, kind letter, and fingerprint case-insensitive so `CS:P1-AB12`
62
- # is accepted — matching the `re.IGNORECASE` used by the standalone
63
- # `cs-cite` script. Tokens may be comma- and/or whitespace-separated.
64
- # Chained commands (&&, |, ;) and extra trailing tokens remain rejected
65
- # by the anchored `\s*$` terminator so accidental mentions don't
66
- # register as citations.
67
40
  _ID_TOKEN = r"(?i:cs:)?(?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?"
68
41
  _ID_SEP = r"[,\s]+"
69
- CITATION_CMD_RE = re.compile(
70
- rf"^\s*(?:[^\s]*/)?cs-cite\s+({_ID_TOKEN}(?:{_ID_SEP}{_ID_TOKEN})*)\s*$"
71
- )
72
42
  _CLEAN_ID_RE = re.compile(r"^(?i:cs:)?((?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?)$")
73
43
  _SPLIT_RE = re.compile(_ID_SEP)
74
44
  _TEXT_CITATION_LINE_RE = re.compile(
@@ -82,7 +52,7 @@ CITATION_INSTRUCTION = (
82
52
  "only if — an injected `[cs:…]` item materially changed your reply "
83
53
  "(different wording, action, or conclusion than you would have produced "
84
54
  "without it), append exactly one final citation line after your answer. "
85
- "Do not call `cs-cite` or any other tool for citations. Ids come verbatim "
55
+ "Do not call a shell command or any other tool for citations. Ids come verbatim "
86
56
  "from the `[cs:…]` tags — keep the leading `p` (preference) or `s` "
87
57
  "(skill) and the `-<fp>` suffix. Use this exact format for one id: "
88
58
  "`✨ 1 claude-smart learning applied [cs:s1-ab12]`. Use this exact format "
@@ -155,29 +125,6 @@ def rank_id(kind: str, rank: int, real_id: Any = None) -> str:
155
125
  return f"{prefix}{rank}-{fp}" if fp else f"{prefix}{rank}"
156
126
 
157
127
 
158
- def parse_citation_command(command: str) -> list[str]:
159
- """Extract citation ids from a ``cs-cite`` Bash command string.
160
-
161
- Returns an empty list when the command does not match the expected
162
- shape (chained commands, extra arguments, or anything other than a
163
- bare ``cs-cite <ids>`` invocation are rejected to avoid false
164
- positives from accidental mentions).
165
-
166
- Args:
167
- command: The raw ``input.command`` value from a Bash tool_use
168
- block.
169
-
170
- Returns:
171
- list[str]: Lowercase rank ids (e.g. ``"p1"``, ``"s3"``), in the
172
- order Claude cited them. Empty when the command does not
173
- match.
174
- """
175
- match = CITATION_CMD_RE.match(command or "")
176
- if not match:
177
- return []
178
- return _parse_id_tokens(match.group(1))
179
-
180
-
181
128
  def parse_text_citations(text: str) -> list[str]:
182
129
  """Extract Codex text-only citation ids from a final learning marker line.
183
130
 
@@ -199,38 +146,3 @@ def _parse_id_tokens(raw_ids: str) -> list[str]:
199
146
  if clean := _CLEAN_ID_RE.match(tok):
200
147
  ids.append(clean.group(1).lower())
201
148
  return ids
202
-
203
-
204
- def ensure_installed() -> Path:
205
- """Idempotently install ``cs-cite`` into ``~/.claude-smart/bin/``.
206
-
207
- Called from every PreToolUse / UserPromptSubmit inject, so we
208
- short-circuit when the target file already exists with
209
- the executable bit set — the steady-state path is one ``stat`` syscall
210
- instead of mkdir + copy + stat + chmod. Keying on filesystem state
211
- (rather than a module-level boolean) keeps test isolation working when
212
- tests monkeypatch ``INSTALL_PATH`` to a fresh tmpdir.
213
-
214
- Never raises — filesystem errors are logged at DEBUG and the caller
215
- proceeds with injection regardless (the citation feature degrades to
216
- silent if the script is unreachable).
217
-
218
- Returns:
219
- Path: Target path, whether or not install succeeded.
220
- """
221
- try:
222
- if (
223
- INSTALL_PATH.is_file()
224
- and INSTALL_PATH.stat().st_mode & stat_.S_IXUSR
225
- and _SOURCE_SCRIPT.is_file()
226
- and INSTALL_PATH.read_bytes() == _SOURCE_SCRIPT.read_bytes()
227
- ):
228
- return INSTALL_PATH
229
- _INSTALL_DIR.mkdir(parents=True, exist_ok=True)
230
- if _SOURCE_SCRIPT.is_file():
231
- shutil.copy2(_SOURCE_SCRIPT, INSTALL_PATH)
232
- mode = INSTALL_PATH.stat().st_mode
233
- INSTALL_PATH.chmod(mode | stat_.S_IXUSR | stat_.S_IXGRP | stat_.S_IXOTH)
234
- except OSError as exc:
235
- _LOGGER.debug("cs-cite install failed: %s", exc)
236
- return INSTALL_PATH
@@ -34,7 +34,7 @@ _EXIT_PLAN_MODE_TOOL = "ExitPlanMode"
34
34
  def _read_transcript_entries(path: Path) -> list[dict[str, Any]]:
35
35
  """Parse the transcript JSONL once into a list of entries.
36
36
 
37
- Stop's three scanners (assistant text, cs-cite ids, plan decisions) all
37
+ Stop's scanners (assistant text and plan decisions) both
38
38
  need the same parsed view; reading once and passing the list around keeps
39
39
  the hook's wall-clock cost to a single ``read_text`` per fire even on
40
40
  multi-megabyte transcripts.
@@ -267,43 +267,10 @@ def _parse_plan_decision(text: str) -> str | None:
267
267
  return None
268
268
 
269
269
 
270
- def _scan_transcript_for_cs_cite_ids(entries: list[dict[str, Any]]) -> list[str]:
271
- """Scan the current assistant turn for ``cs-cite`` Bash tool_use calls.
272
-
273
- Collects citation ids from every matching Bash ``tool_use`` block in
274
- the current turn. Multiple calls are merged; order follows Claude's
275
- emission order (earliest first).
276
-
277
- Args:
278
- entries (list[dict[str, Any]]): Pre-parsed transcript entries.
279
-
280
- Returns:
281
- list[str]: Rank ids (e.g. ``"s1-ab12"``, ``"p2-cd34"``) in
282
- emission order. Empty when no ``cs-cite`` call is found.
283
- """
284
- out: list[str] = []
285
- for entry in _current_turn_assistant_entries(entries):
286
- message = entry.get("message") or {}
287
- out.extend(_extract_cs_cite_ids(message.get("content")))
288
- return out
289
-
290
-
291
- def _extract_cs_cite_ids(content: Any) -> list[str]:
292
- """Return citation ids from all Bash ``cs-cite`` tool_use blocks in ``content``."""
293
- if not isinstance(content, list):
294
- return []
295
- out: list[str] = []
296
- for block in content:
297
- if not isinstance(block, dict) or block.get("type") != "tool_use":
298
- continue
299
- if block.get("name") != "Bash":
300
- continue
301
- tool_input = block.get("input") or {}
302
- command = tool_input.get("command")
303
- if not isinstance(command, str):
304
- continue
305
- out.extend(cs_cite.parse_citation_command(command))
306
- return out
270
+ def _has_unpublished_user_turn(session_id: str) -> bool:
271
+ """True when the session buffer already has a user turn awaiting publish."""
272
+ _, interactions = state.unpublished_slice(state.read_all(session_id))
273
+ return any(item.get("role") == "User" for item in interactions)
307
274
 
308
275
 
309
276
  def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str, Any]]:
@@ -360,8 +327,10 @@ def handle(payload: dict[str, Any]) -> None:
360
327
  if path.is_file():
361
328
  entries = _load_transcript_with_retry(path)
362
329
 
330
+ prompt = payload.get("prompt") or (
331
+ _scan_transcript_for_user_text(entries) if runtime.is_codex() else ""
332
+ )
363
333
  if runtime.is_codex():
364
- prompt = payload.get("prompt") or _scan_transcript_for_user_text(entries)
365
334
  if internal_call.is_codex_internal_prompt(prompt):
366
335
  return
367
336
 
@@ -373,10 +342,15 @@ def handle(payload: dict[str, Any]) -> None:
373
342
  and last_assistant_message
374
343
  else _scan_transcript_for_assistant_text(entries)
375
344
  )
376
- transcript_cited_ids = _scan_transcript_for_cs_cite_ids(entries)
345
+ if (
346
+ runtime.is_codex()
347
+ and internal_call.is_codex_title_response(assistant_text)
348
+ and not prompt
349
+ and not _has_unpublished_user_turn(session_id)
350
+ ):
351
+ return
377
352
  text_cited_ids = cs_cite.parse_text_citations(assistant_text)
378
- cited_ids = [*text_cited_ids, *transcript_cited_ids]
379
- cited_items = _resolve_cited_items(session_id, cited_ids)
353
+ cited_items = _resolve_cited_items(session_id, text_cited_ids)
380
354
  plan_decisions = _scan_transcript_for_plan_decisions(entries)
381
355
 
382
356
  now = int(time.time())
@@ -36,6 +36,7 @@ Detection signals, OR'd:
36
36
 
37
37
  from __future__ import annotations
38
38
 
39
+ import json
39
40
  import os
40
41
  from pathlib import Path
41
42
  from typing import Any
@@ -117,3 +118,25 @@ def is_codex_internal_prompt(prompt: Any) -> bool:
117
118
  and _CODEX_SUGGESTIONS_PROMPT_MARKER in text
118
119
  and _CODEX_SUGGESTIONS_APPS_MARKER in text
119
120
  )
121
+
122
+
123
+ def is_codex_title_response(content: Any) -> bool:
124
+ """True for Codex's title-generator response body.
125
+
126
+ Codex can run a separate title-generation task whose Stop payload contains
127
+ only the assistant response, e.g. ``{"title":"Fix tests"}``, with no
128
+ corresponding user turn. That metadata is useful to Codex's UI, but it is
129
+ not a user interaction and should not be published to reflexio.
130
+ """
131
+ if not isinstance(content, str):
132
+ return False
133
+ try:
134
+ parsed = json.loads(content)
135
+ except json.JSONDecodeError:
136
+ return False
137
+ return (
138
+ isinstance(parsed, dict)
139
+ and set(parsed) == {"title"}
140
+ and isinstance(parsed.get("title"), str)
141
+ and bool(parsed["title"].strip())
142
+ )
@@ -77,7 +77,7 @@ def session_path(session_id: str) -> Path:
77
77
 
78
78
 
79
79
  def injected_path(session_id: str) -> Path:
80
- """Return the JSONL path for the per-session cs-cite registry."""
80
+ """Return the JSONL path for the per-session citation registry."""
81
81
  return state_dir() / f"{session_id}.injected.jsonl"
82
82
 
83
83
 
@@ -85,8 +85,8 @@ def append_injected(session_id: str, entries: Iterable[dict[str, Any]]) -> None:
85
85
  """Append citation-registry entries to the per-session injected-items file.
86
86
 
87
87
  Each entry maps a short ``id`` (4-hex-char) back to the skill or
88
- preference it came from so the Stop hook can resolve ids cited by
89
- Claude via ``cs-cite`` into human-readable titles for the dashboard.
88
+ preference it came from so the Stop hook can resolve citation ids into
89
+ human-readable titles for the dashboard.
90
90
  Silently no-ops when ``entries`` is empty.
91
91
  """
92
92
  records = list(entries)