claude-smart 0.2.28 → 0.2.30

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.
@@ -20,18 +20,161 @@ REPO_ROOT="$(cd "$HERE/../.." && pwd)"
20
20
 
21
21
  MARKER_DIR="$HOME/.claude-smart"
22
22
  FAILURE_MARKER="$MARKER_DIR/install-failed"
23
+ SUCCESS_MARKER="$MARKER_DIR/install-complete"
24
+ INSTALL_LOCK="$MARKER_DIR/install.lock"
25
+ INSTALL_REAP_LOCK="$MARKER_DIR/install.lock.reap"
23
26
  mkdir -p "$MARKER_DIR"
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
+
74
+ # Serialize concurrent installer runs (SessionStart hook + slash-command
75
+ # self-heal can both invoke this script). Wait for the active installer
76
+ # rather than returning early, otherwise callers can re-check uv before
77
+ # the first install has finished and report a false missing-dependency error.
78
+ acquire_install_lock
79
+
24
80
  rm -f "$FAILURE_MARKER"
25
81
 
26
82
  write_failure() {
27
83
  local reason
28
84
  reason="$1"
29
85
  printf '%s\n' "$reason" > "$FAILURE_MARKER"
86
+ rm -f "$SUCCESS_MARKER"
30
87
  echo "[claude-smart] install failed: $reason" >&2
31
88
  echo '{"continue":true,"suppressOutput":true}'
32
89
  exit 0
33
90
  }
34
91
 
92
+ fingerprint_file() {
93
+ local path
94
+ path="$1"
95
+ if [ -f "$path" ]; then
96
+ cksum "$path" 2>/dev/null | awk '{print $1 ":" $2}'
97
+ else
98
+ printf 'missing\n'
99
+ fi
100
+ }
101
+
102
+ install_fingerprint() {
103
+ printf 'plugin_root=%s\n' "$PLUGIN_ROOT"
104
+ printf 'smart_install=%s\n' "$(fingerprint_file "$HERE/smart-install.sh")"
105
+ printf 'pyproject=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/pyproject.toml")"
106
+ printf 'uv_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/uv.lock")"
107
+ # Resolved python interpreter — catches a system upgrade (3.12.4 → 3.12.5)
108
+ # that would otherwise let install_complete return true against a venv
109
+ # built against a now-deleted interpreter.
110
+ if command -v uv >/dev/null 2>&1; then
111
+ printf 'python=%s\n' "$(uv python find 3.12 2>/dev/null || echo missing)"
112
+ else
113
+ printf 'python=no-uv\n'
114
+ fi
115
+ if [ -d "$PLUGIN_ROOT/dashboard" ]; then
116
+ printf 'dashboard_pkg=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package.json")"
117
+ printf 'dashboard_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package-lock.json")"
118
+ else
119
+ printf 'dashboard_pkg=none\n'
120
+ printf 'dashboard_lock=none\n'
121
+ fi
122
+ }
123
+
124
+ install_complete() {
125
+ [ -f "$SUCCESS_MARKER" ] || return 1
126
+ [ "$(cat "$SUCCESS_MARKER" 2>/dev/null || true)" = "$(install_fingerprint)" ] || return 1
127
+ command -v uv >/dev/null 2>&1 || return 1
128
+ [ -d "$PLUGIN_ROOT/.venv" ] || return 1
129
+ [ -f "$HOME/.reflexio/.env" ] || return 1
130
+ grep -q '^CLAUDE_SMART_USE_LOCAL_CLI=' "$HOME/.reflexio/.env" || return 1
131
+ grep -q '^CLAUDE_SMART_USE_LOCAL_EMBEDDING=' "$HOME/.reflexio/.env" || return 1
132
+ if [ -d "$PLUGIN_ROOT/dashboard" ]; then
133
+ [ -d "$PLUGIN_ROOT/dashboard/.next" ] || [ -f "$MARKER_DIR/dashboard-build.pid" ] || [ -f "$(claude_smart_dashboard_unavailable_marker)" ] || return 1
134
+ fi
135
+ return 0
136
+ }
137
+
138
+ write_success_marker() {
139
+ install_fingerprint > "$SUCCESS_MARKER"
140
+ }
141
+
142
+ preflight_supported_runtime_platform() {
143
+ local os_name machine darwin_major
144
+ os_name="$(uname -s 2>/dev/null || echo unknown)"
145
+ machine="$(uname -m 2>/dev/null || echo unknown)"
146
+ case "$os_name" in
147
+ Darwin*)
148
+ if [ "$machine" != "arm64" ]; then
149
+ write_failure "claude-smart currently supports Apple Silicon macOS 14+ only; Intel Mac is not supported because native ML wheels are unavailable."
150
+ fi
151
+ darwin_major="$(uname -r 2>/dev/null | awk -F. '{print $1}')"
152
+ case "$darwin_major" in
153
+ ''|*[!0-9]*)
154
+ write_failure "claude-smart could not determine the macOS version; Apple Silicon macOS 14+ is required."
155
+ ;;
156
+ esac
157
+ if [ "$darwin_major" -lt 23 ]; then
158
+ write_failure "claude-smart currently supports macOS 14+ on Apple Silicon; macOS 13 and older are not supported because native ML wheels are unavailable."
159
+ fi
160
+ ;;
161
+ MINGW*|MSYS*|CYGWIN*)
162
+ case "$machine" in
163
+ x86_64|amd64) : ;;
164
+ *)
165
+ write_failure "claude-smart currently supports Windows x64 only; Windows ARM is not supported because native ML wheels are unavailable."
166
+ ;;
167
+ esac
168
+ ;;
169
+ Linux*)
170
+ : # Existing Linux installs remain supported when package wheels are available.
171
+ ;;
172
+ *)
173
+ write_failure "claude-smart currently supports Apple Silicon macOS 14+, Windows x64, and Linux for vanilla installs."
174
+ ;;
175
+ esac
176
+ }
177
+
35
178
  install_private_node() {
36
179
  local NODE_MIN_MAJOR NODE_MIN_MINOR NODE_LTS_MAJOR
37
180
  local node_os archive_ext reason node_arch node_platform base_url node_root
@@ -236,6 +379,13 @@ if [ "${CLAUDE_SMART_INSTALL_PRIVATE_NODE_ONLY:-}" = "1" ]; then
236
379
  exit $?
237
380
  fi
238
381
 
382
+ preflight_supported_runtime_platform
383
+
384
+ if install_complete; then
385
+ echo '{"continue":true,"suppressOutput":true}'
386
+ exit 0
387
+ fi
388
+
239
389
  # Dev-mode only: when running from a git checkout, pull the reflexio
240
390
  # submodule so tests/benchmarks can use its sources. In install mode the
241
391
  # plugin lives under ~/.claude/plugins/cache and reflexio-ai resolves
@@ -327,57 +477,32 @@ if ! command -v claude >/dev/null 2>&1; then
327
477
  echo "[claude-smart] WARNING: 'claude' CLI not on PATH — reflexio extractors will have no LLM until it's installed" >&2
328
478
  fi
329
479
 
330
- # Allowlist cs-cite globally so Claude's citation Bash calls don't pop a
331
- # permission prompt mid-turn. Idempotent: no-ops when the entry is already
332
- # present. Uses Python to preserve the rest of settings.json intact.
333
- # Resolves python via claude_smart_resolve_python so we don't fire the
334
- # Windows App Execution Alias stub (which exits non-zero with "Python
335
- # was not found" when no real interpreter is installed).
480
+ LEGACY_CS_CITE="$HOME/.claude-smart/bin/cs-cite"
481
+ if [ -e "$LEGACY_CS_CITE" ]; then
482
+ rm -f "$LEGACY_CS_CITE"
483
+ echo "[claude-smart] removed legacy cs-cite helper at $LEGACY_CS_CITE" >&2
484
+ fi
485
+
336
486
  CLAUDE_SETTINGS="$HOME/.claude/settings.json"
337
- mkdir -p "$(dirname "$CLAUDE_SETTINGS")"
338
- PY_BIN=$(claude_smart_resolve_python || true)
339
- if [ -z "$PY_BIN" ]; then
340
- echo "[claude-smart] WARNING: no working python interpreter found; skipping cs-cite allowlist" >&2
341
- elif "$PY_BIN" - "$CLAUDE_SETTINGS" <<'PY' >&2
342
- import json
343
- import sys
344
- from pathlib import Path
345
-
346
- path = Path(sys.argv[1])
347
- entry = "Bash(cs-cite:*)"
348
- data: dict = {}
349
- if path.is_file():
350
- try:
351
- data = json.loads(path.read_text() or "{}")
352
- except json.JSONDecodeError:
353
- print(
354
- f"[claude-smart] WARNING: {path} is not valid JSON; skipping cs-cite allowlist",
355
- file=sys.stderr,
356
- )
357
- sys.exit(2)
358
- def _warn_and_exit(reason: str) -> None:
359
- print(
360
- f"[claude-smart] WARNING: {path} {reason}; skipping cs-cite allowlist",
361
- file=sys.stderr,
362
- )
363
- sys.exit(2)
364
-
365
- if not isinstance(data, dict):
366
- _warn_and_exit("top-level is not a JSON object")
367
- permissions = data.setdefault("permissions", {})
368
- if not isinstance(permissions, dict):
369
- _warn_and_exit("'permissions' is not a JSON object")
370
- allow = permissions.setdefault("allow", [])
371
- if not isinstance(allow, list):
372
- _warn_and_exit("'permissions.allow' is not a JSON array")
373
- if entry in allow:
374
- sys.exit(1) # already present — convey via exit code so shell can skip the log
375
- allow.append(entry)
376
- path.write_text(json.dumps(data, indent=2) + "\n")
377
- sys.exit(0)
378
- PY
379
- then
380
- echo "[claude-smart] added Bash(cs-cite:*) to $CLAUDE_SETTINGS permissions.allow" >&2
487
+ if [ -f "$CLAUDE_SETTINGS" ] && command -v node >/dev/null 2>&1; then
488
+ node - "$CLAUDE_SETTINGS" <<'JS' >&2 || true
489
+ const fs = require("fs");
490
+ const path = process.argv[2];
491
+ const entry = "Bash(cs-cite:*)";
492
+ let data;
493
+ try {
494
+ data = JSON.parse(fs.readFileSync(path, "utf8") || "{}");
495
+ } catch {
496
+ process.exit(0);
497
+ }
498
+ const allow = data?.permissions?.allow;
499
+ if (!Array.isArray(allow)) process.exit(0);
500
+ const next = allow.filter((item) => item !== entry);
501
+ if (next.length === allow.length) process.exit(0);
502
+ data.permissions.allow = next;
503
+ fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
504
+ console.error(`[claude-smart] removed legacy ${entry} permission from ${path}`);
505
+ JS
381
506
  fi
382
507
 
383
508
  # Spawn the dashboard build detached so install returns immediately and
@@ -406,5 +531,6 @@ if ! bash "$HERE/ensure-plugin-root.sh" "$PLUGIN_ROOT"; then
406
531
  echo "[claude-smart] WARNING: failed to set ~/.reflexio/plugin-root symlink — slash commands may not resolve" >&2
407
532
  fi
408
533
 
534
+ write_success_marker
409
535
  echo "[claude-smart] install complete. Backend and dashboard auto-start on session start." >&2
410
536
  echo '{"continue":true,"suppressOutput":true}'
@@ -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
+ )