claude-smart 0.2.29 → 0.2.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -23
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +3 -3
- package/plugin/hooks/hooks.json +4 -4
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +65 -0
- package/plugin/scripts/backend-service.sh +29 -7
- package/plugin/scripts/cli.sh +3 -2
- package/plugin/scripts/codex-hook.js +72 -9
- package/plugin/scripts/dashboard-service.sh +16 -4
- package/plugin/scripts/hook_entry.sh +50 -9
- package/plugin/scripts/smart-install.sh +57 -39
- package/plugin/src/claude_smart/cli.py +119 -1
- package/plugin/src/claude_smart/events/session_end.py +171 -6
- package/plugin/src/claude_smart/events/session_start.py +26 -2
- package/plugin/src/claude_smart/events/stop.py +17 -4
- package/plugin/src/claude_smart/hook.py +62 -4
- package/plugin/src/claude_smart/hook_log.py +301 -0
- package/plugin/uv.lock +1 -1
|
@@ -22,63 +22,81 @@ MARKER_DIR="$HOME/.claude-smart"
|
|
|
22
22
|
FAILURE_MARKER="$MARKER_DIR/install-failed"
|
|
23
23
|
SUCCESS_MARKER="$MARKER_DIR/install-complete"
|
|
24
24
|
INSTALL_LOCK="$MARKER_DIR/install.lock"
|
|
25
|
+
INSTALL_REAP_LOCK="$MARKER_DIR/install.lock.reap"
|
|
25
26
|
mkdir -p "$MARKER_DIR"
|
|
26
27
|
|
|
28
|
+
remove_stale_install_lock() {
|
|
29
|
+
local expected current
|
|
30
|
+
|
|
31
|
+
expected="$1"
|
|
32
|
+
if ! mkdir "$INSTALL_REAP_LOCK" 2>/dev/null; then
|
|
33
|
+
sleep 1
|
|
34
|
+
return 0
|
|
35
|
+
fi
|
|
36
|
+
current="$(cat "$INSTALL_LOCK" 2>/dev/null || true)"
|
|
37
|
+
if [ "$current" = "$expected" ]; then
|
|
38
|
+
rm -f "$INSTALL_LOCK"
|
|
39
|
+
fi
|
|
40
|
+
rmdir "$INSTALL_REAP_LOCK" 2>/dev/null || true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
acquire_install_lock() {
|
|
44
|
+
local lock_pid
|
|
45
|
+
|
|
46
|
+
if command -v flock >/dev/null 2>&1; then
|
|
47
|
+
exec 9>"$INSTALL_LOCK"
|
|
48
|
+
if ! flock 9; then
|
|
49
|
+
echo "[claude-smart] install lock failed; continuing without serialization" >&2
|
|
50
|
+
echo '{"continue":true,"suppressOutput":true}'
|
|
51
|
+
exit 0
|
|
52
|
+
fi
|
|
53
|
+
return 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
while ! ( set -C; printf '%s\n' "$$" > "$INSTALL_LOCK" ) 2>/dev/null; do
|
|
57
|
+
lock_pid="$(cat "$INSTALL_LOCK" 2>/dev/null || true)"
|
|
58
|
+
case "$lock_pid" in
|
|
59
|
+
''|*[!0-9]*)
|
|
60
|
+
remove_stale_install_lock "$lock_pid"
|
|
61
|
+
;;
|
|
62
|
+
*)
|
|
63
|
+
if kill -0 "$lock_pid" 2>/dev/null; then
|
|
64
|
+
sleep 1
|
|
65
|
+
else
|
|
66
|
+
remove_stale_install_lock "$lock_pid"
|
|
67
|
+
fi
|
|
68
|
+
;;
|
|
69
|
+
esac
|
|
70
|
+
done
|
|
71
|
+
trap '[ "$(cat "$INSTALL_LOCK" 2>/dev/null || true)" = "$$" ] && rm -f "$INSTALL_LOCK" || true' EXIT
|
|
72
|
+
}
|
|
73
|
+
|
|
27
74
|
# Serialize concurrent installer runs (SessionStart hook + slash-command
|
|
28
75
|
# self-heal can both invoke this script). Wait for the active installer
|
|
29
76
|
# rather than returning early, otherwise callers can re-check uv before
|
|
30
77
|
# the first install has finished and report a false missing-dependency error.
|
|
31
|
-
|
|
32
|
-
exec 9>"$INSTALL_LOCK"
|
|
33
|
-
if ! flock 9; then
|
|
34
|
-
echo "[claude-smart] install lock failed; continuing without serialization" >&2
|
|
35
|
-
echo '{"continue":true,"suppressOutput":true}'
|
|
36
|
-
exit 0
|
|
37
|
-
fi
|
|
38
|
-
fi
|
|
78
|
+
acquire_install_lock
|
|
39
79
|
|
|
40
80
|
rm -f "$FAILURE_MARKER"
|
|
41
81
|
|
|
42
82
|
write_failure() {
|
|
43
|
-
local reason
|
|
83
|
+
local reason fp_hash
|
|
44
84
|
reason="$1"
|
|
45
|
-
|
|
85
|
+
fp_hash="$(claude_smart_install_fingerprint_hash "$PLUGIN_ROOT" "$HERE" 2>/dev/null || true)"
|
|
86
|
+
{
|
|
87
|
+
printf '%s\n' "$reason"
|
|
88
|
+
if [ -n "$fp_hash" ]; then
|
|
89
|
+
printf 'fingerprint=%s\n' "$fp_hash"
|
|
90
|
+
fi
|
|
91
|
+
} > "$FAILURE_MARKER"
|
|
46
92
|
rm -f "$SUCCESS_MARKER"
|
|
47
93
|
echo "[claude-smart] install failed: $reason" >&2
|
|
48
94
|
echo '{"continue":true,"suppressOutput":true}'
|
|
49
95
|
exit 0
|
|
50
96
|
}
|
|
51
97
|
|
|
52
|
-
fingerprint_file() {
|
|
53
|
-
local path
|
|
54
|
-
path="$1"
|
|
55
|
-
if [ -f "$path" ]; then
|
|
56
|
-
cksum "$path" 2>/dev/null | awk '{print $1 ":" $2}'
|
|
57
|
-
else
|
|
58
|
-
printf 'missing\n'
|
|
59
|
-
fi
|
|
60
|
-
}
|
|
61
|
-
|
|
62
98
|
install_fingerprint() {
|
|
63
|
-
|
|
64
|
-
printf 'smart_install=%s\n' "$(fingerprint_file "$HERE/smart-install.sh")"
|
|
65
|
-
printf 'pyproject=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/pyproject.toml")"
|
|
66
|
-
printf 'uv_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/uv.lock")"
|
|
67
|
-
# Resolved python interpreter — catches a system upgrade (3.12.4 → 3.12.5)
|
|
68
|
-
# that would otherwise let install_complete return true against a venv
|
|
69
|
-
# built against a now-deleted interpreter.
|
|
70
|
-
if command -v uv >/dev/null 2>&1; then
|
|
71
|
-
printf 'python=%s\n' "$(uv python find 3.12 2>/dev/null || echo missing)"
|
|
72
|
-
else
|
|
73
|
-
printf 'python=no-uv\n'
|
|
74
|
-
fi
|
|
75
|
-
if [ -d "$PLUGIN_ROOT/dashboard" ]; then
|
|
76
|
-
printf 'dashboard_pkg=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package.json")"
|
|
77
|
-
printf 'dashboard_lock=%s\n' "$(fingerprint_file "$PLUGIN_ROOT/dashboard/package-lock.json")"
|
|
78
|
-
else
|
|
79
|
-
printf 'dashboard_pkg=none\n'
|
|
80
|
-
printf 'dashboard_lock=none\n'
|
|
81
|
-
fi
|
|
99
|
+
claude_smart_install_fingerprint "$PLUGIN_ROOT" "$HERE"
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
install_complete() {
|
|
@@ -205,7 +205,8 @@ def _bootstrap_claude_code_install() -> tuple[bool, str]:
|
|
|
205
205
|
if result.returncode != 0:
|
|
206
206
|
return False, f"smart-install.sh failed in {plugin_root}"
|
|
207
207
|
if _INSTALL_FAILURE_MARKER.is_file():
|
|
208
|
-
|
|
208
|
+
first_line = _INSTALL_FAILURE_MARKER.read_text().splitlines()
|
|
209
|
+
reason = (first_line[0].strip() if first_line else "") or "unknown error"
|
|
209
210
|
return False, reason
|
|
210
211
|
return True, str(plugin_root)
|
|
211
212
|
|
|
@@ -1561,6 +1562,104 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
|
|
|
1561
1562
|
return start_rc or 0
|
|
1562
1563
|
|
|
1563
1564
|
|
|
1565
|
+
def _resolve_reflexio_url() -> str:
|
|
1566
|
+
"""Return the URL endpoint for the local claude-smart reflexio backend.
|
|
1567
|
+
|
|
1568
|
+
Mirrors ``claude_smart.reflexio_adapter`` so the CLI's one-shot
|
|
1569
|
+
ReflexioClient calls hit the same server as the long-lived adapter
|
|
1570
|
+
used by hook handlers.
|
|
1571
|
+
|
|
1572
|
+
Returns:
|
|
1573
|
+
str: The ``REFLEXIO_URL`` env var if set, otherwise the default
|
|
1574
|
+
local backend endpoint.
|
|
1575
|
+
"""
|
|
1576
|
+
return os.environ.get("REFLEXIO_URL", "http://localhost:8071/")
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _format_deleted_counts(deleted_counts: object) -> str:
|
|
1580
|
+
"""Render ``deleted_counts`` from a ``clear_user_data`` response as a summary.
|
|
1581
|
+
|
|
1582
|
+
The backend returns one count per affected table. We surface the
|
|
1583
|
+
total in the headline string (``"removed N row(s)"``) and append a
|
|
1584
|
+
per-table breakdown when at least one bucket is populated, so the
|
|
1585
|
+
operator can tell *what* got cleared at a glance.
|
|
1586
|
+
|
|
1587
|
+
Args:
|
|
1588
|
+
deleted_counts (object): The ``deleted_counts`` field from the
|
|
1589
|
+
backend response. Expected to be a mapping of
|
|
1590
|
+
``str -> int``; anything else is rendered as ``"unknown"``.
|
|
1591
|
+
|
|
1592
|
+
Returns:
|
|
1593
|
+
str: A short human-readable summary suitable for echoing to
|
|
1594
|
+
stdout (e.g. ``"3 row(s) (interactions=3)"``).
|
|
1595
|
+
"""
|
|
1596
|
+
if not isinstance(deleted_counts, dict):
|
|
1597
|
+
return "unknown row(s)"
|
|
1598
|
+
pairs: list[tuple[str, int]] = []
|
|
1599
|
+
for key, value in deleted_counts.items():
|
|
1600
|
+
if isinstance(key, str) and isinstance(value, int):
|
|
1601
|
+
pairs.append((key, value))
|
|
1602
|
+
total = sum(value for _, value in pairs)
|
|
1603
|
+
if not pairs:
|
|
1604
|
+
return f"{total} row(s)"
|
|
1605
|
+
breakdown = ", ".join(f"{name}={count}" for name, count in sorted(pairs))
|
|
1606
|
+
return f"{total} row(s) ({breakdown})"
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def cmd_clear_user(args: argparse.Namespace) -> int:
|
|
1610
|
+
"""Delete one user_id's playbooks, profiles, and interactions via the backend.
|
|
1611
|
+
|
|
1612
|
+
Per-user-scoped counterpart of ``clear-all``. Where ``clear-all`` does
|
|
1613
|
+
a filesystem-level wipe of the entire local storage root, ``clear-user``
|
|
1614
|
+
routes through the running reflexio backend's ``clear_user_data``
|
|
1615
|
+
endpoint so it can scope the deletion to a single ``user_id`` —
|
|
1616
|
+
leaving other users' data and globally-shared ``agent_playbooks``
|
|
1617
|
+
untouched. This is the primitive SWE-bench-style benchmark harnesses
|
|
1618
|
+
need when running many parallel evaluations under distinct synthetic
|
|
1619
|
+
user ids against one shared backend.
|
|
1620
|
+
|
|
1621
|
+
Args:
|
|
1622
|
+
args (argparse.Namespace): Parsed CLI args. Requires
|
|
1623
|
+
``args.user_id`` (positional) and ``args.yes`` (the
|
|
1624
|
+
destructive-action confirmation flag, matching ``clear-all``).
|
|
1625
|
+
|
|
1626
|
+
Returns:
|
|
1627
|
+
int: 0 on success, 1 if confirmation is missing or the backend
|
|
1628
|
+
call fails.
|
|
1629
|
+
"""
|
|
1630
|
+
user_id = args.user_id
|
|
1631
|
+
if not args.yes:
|
|
1632
|
+
sys.stdout.write(
|
|
1633
|
+
f"This will permanently delete all reflexio rows for user '{user_id}' "
|
|
1634
|
+
"(playbooks, profiles, interactions). Other users' data and "
|
|
1635
|
+
"agent_playbooks are not affected.\n"
|
|
1636
|
+
"Re-run with --yes to confirm.\n"
|
|
1637
|
+
)
|
|
1638
|
+
return 1
|
|
1639
|
+
|
|
1640
|
+
try:
|
|
1641
|
+
from reflexio import ReflexioClient # type: ignore[import-not-found]
|
|
1642
|
+
except ImportError as exc:
|
|
1643
|
+
sys.stderr.write(f"error: reflexio is not installed: {exc}\n")
|
|
1644
|
+
return 1
|
|
1645
|
+
|
|
1646
|
+
try:
|
|
1647
|
+
client = ReflexioClient(url_endpoint=_resolve_reflexio_url())
|
|
1648
|
+
# The companion reflexio PR adds ``clear_user_data``; keep the
|
|
1649
|
+
# CLI buildable until both sides ship together.
|
|
1650
|
+
response = client.clear_user_data(user_id) # pyright: ignore[reportAttributeAccessIssue]
|
|
1651
|
+
except Exception as exc: # noqa: BLE001 — surface backend failure verbatim.
|
|
1652
|
+
sys.stderr.write(f"error: clear-user failed: {exc}\n")
|
|
1653
|
+
return 1
|
|
1654
|
+
|
|
1655
|
+
deleted_counts = (
|
|
1656
|
+
response.get("deleted_counts") if isinstance(response, dict) else None
|
|
1657
|
+
)
|
|
1658
|
+
summary = _format_deleted_counts(deleted_counts)
|
|
1659
|
+
sys.stdout.write(f"Cleared user '{user_id}': removed {summary}.\n")
|
|
1660
|
+
return 0
|
|
1661
|
+
|
|
1662
|
+
|
|
1564
1663
|
def _build_parser() -> argparse.ArgumentParser:
|
|
1565
1664
|
parser = argparse.ArgumentParser(prog="claude-smart")
|
|
1566
1665
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -1625,6 +1724,25 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1625
1724
|
)
|
|
1626
1725
|
ca.set_defaults(func=cmd_clear_all)
|
|
1627
1726
|
|
|
1727
|
+
cu = sub.add_parser(
|
|
1728
|
+
"clear-user",
|
|
1729
|
+
help=(
|
|
1730
|
+
"Delete one user_id's playbooks, profiles, and interactions via "
|
|
1731
|
+
"the backend (leaves other users and agent_playbooks intact)"
|
|
1732
|
+
),
|
|
1733
|
+
)
|
|
1734
|
+
cu.add_argument(
|
|
1735
|
+
"user_id",
|
|
1736
|
+
help="User id whose reflexio rows should be cleared",
|
|
1737
|
+
)
|
|
1738
|
+
cu.add_argument(
|
|
1739
|
+
"--yes",
|
|
1740
|
+
"-y",
|
|
1741
|
+
action="store_true",
|
|
1742
|
+
help="Confirm the destructive clear without prompting",
|
|
1743
|
+
)
|
|
1744
|
+
cu.set_defaults(func=cmd_clear_user)
|
|
1745
|
+
|
|
1628
1746
|
rs = sub.add_parser(
|
|
1629
1747
|
"restart",
|
|
1630
1748
|
help="Restart the reflexio backend and dashboard to pick up changes",
|
|
@@ -1,20 +1,185 @@
|
|
|
1
|
-
"""SessionEnd hook — flush any remaining interactions; extraction runs async.
|
|
1
|
+
"""SessionEnd hook — flush any remaining interactions; extraction runs async.
|
|
2
|
+
|
|
3
|
+
When ``Stop`` never fires during a session (autonomous agentic loops
|
|
4
|
+
that don't reach ``stop_reason=end_turn``), the buffer at SessionEnd
|
|
5
|
+
time can contain orphan ``Assistant_tool`` records with no closing
|
|
6
|
+
``Assistant`` anchor. The ``state.unpublished_slice`` contract folds
|
|
7
|
+
tool records into the *next* Assistant turn's ``tools_used``; without
|
|
8
|
+
that anchor, every buffered tool call is silently dropped at publish
|
|
9
|
+
time and reflexio receives only the original User prompt.
|
|
10
|
+
|
|
11
|
+
This module synthesises a closing Assistant record from whatever
|
|
12
|
+
assistant text the Claude Code transcript still has, so the publish
|
|
13
|
+
carries the full tool record stream instead of just the User prompt.
|
|
14
|
+
A short placeholder string is used when the transcript is unreadable.
|
|
15
|
+
"""
|
|
2
16
|
|
|
3
17
|
from __future__ import annotations
|
|
4
18
|
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
5
22
|
from typing import Any
|
|
6
23
|
|
|
7
|
-
from claude_smart import ids, publish
|
|
24
|
+
from claude_smart import ids, publish, state
|
|
25
|
+
from claude_smart.events.stop import (
|
|
26
|
+
_read_transcript_entries,
|
|
27
|
+
_scan_transcript_for_assistant_text,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_LOGGER = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Fallback Assistant content used when the transcript is missing,
|
|
33
|
+
# unreadable, or has no recoverable assistant text. The string is
|
|
34
|
+
# distinctive so a future audit can grep for it and quantify how often
|
|
35
|
+
# a session ended without any model response.
|
|
36
|
+
_PLACEHOLDER_ASSISTANT_TEXT = "<session ended without final assistant response>"
|
|
8
37
|
|
|
9
38
|
|
|
10
|
-
def handle(payload: dict[str, Any]) -> None:
|
|
39
|
+
def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
40
|
+
"""Flush any remaining buffered turns to reflexio.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
payload (dict[str, Any]): Claude Code hook payload.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
tuple[PublishStatus, int] | None: The ``(status, count)`` tuple
|
|
47
|
+
from ``publish.publish_unpublished`` so the dispatcher's
|
|
48
|
+
forensic hook log captures the publish outcome. ``None``
|
|
49
|
+
when the payload had no ``session_id``.
|
|
50
|
+
"""
|
|
11
51
|
session_id = payload.get("session_id")
|
|
12
52
|
if not session_id:
|
|
13
|
-
return
|
|
53
|
+
return None
|
|
14
54
|
project_id = ids.resolve_project_id(payload.get("cwd"))
|
|
15
|
-
|
|
55
|
+
|
|
56
|
+
_maybe_synthesize_assistant_anchor(
|
|
16
57
|
session_id=session_id,
|
|
17
58
|
project_id=project_id,
|
|
18
|
-
|
|
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
|
-
|
|
19
|
-
|
|
18
|
+
#
|
|
19
|
+
# Env-var overrides let benchmark harnesses (SWE-bench Verified, in particular)
|
|
20
|
+
# run with ``CLAUDE_SMART_STRIDE_SIZE=1`` so a single short autonomous session
|
|
21
|
+
# triggers extraction on its sole publish instead of being silently filtered
|
|
22
|
+
# out by the stride pre-filter (``new < stride_size``). Production users keep
|
|
23
|
+
# the defaults; the values are only read on SessionStart, so a launcher that
|
|
24
|
+
# sets the env var has predictable effect for the entire Claude Code session.
|
|
25
|
+
#
|
|
26
|
+
# Parsing is defensive: a non-integer env value falls back to the default
|
|
27
|
+
# rather than raising at import time. SessionStart is the hook that wires up
|
|
28
|
+
# every subsequent hook, so an import-time crash here would silently disable
|
|
29
|
+
# the whole plugin for the session.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _int_env(name: str, default: int) -> int:
|
|
33
|
+
raw = os.environ.get(name)
|
|
34
|
+
if raw is None:
|
|
35
|
+
return default
|
|
36
|
+
try:
|
|
37
|
+
return int(raw)
|
|
38
|
+
except ValueError:
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_CLAUDE_SMART_WINDOW_SIZE = _int_env("CLAUDE_SMART_WINDOW_SIZE", 5)
|
|
43
|
+
_CLAUDE_SMART_STRIDE_SIZE = _int_env("CLAUDE_SMART_STRIDE_SIZE", 3)
|
|
20
44
|
# Optimizer is on by default. Set this env var to "0" to skip pushing the
|
|
21
45
|
# claude-smart optimizer defaults on SessionStart (kill switch).
|
|
22
46
|
_DISABLE_OPTIMIZER_ENV = "CLAUDE_SMART_ENABLE_OPTIMIZER"
|
|
@@ -308,10 +308,23 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
|
|
|
308
308
|
return resolved
|
|
309
309
|
|
|
310
310
|
|
|
311
|
-
def handle(payload: dict[str, Any]) -> None:
|
|
311
|
+
def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
312
|
+
"""Drain the buffered assistant turn to reflexio.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
payload (dict[str, Any]): Claude Code hook payload.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
tuple[PublishStatus, int] | None: The ``(status, count)`` tuple
|
|
319
|
+
from ``publish.publish_unpublished`` so the dispatcher can
|
|
320
|
+
include it in the forensic hook log. ``None`` is returned
|
|
321
|
+
only when the handler short-circuits before publishing (no
|
|
322
|
+
``session_id`` in the payload, or Codex internal-prompt
|
|
323
|
+
detection skipped the fire).
|
|
324
|
+
"""
|
|
312
325
|
session_id = payload.get("session_id")
|
|
313
326
|
if not session_id:
|
|
314
|
-
return
|
|
327
|
+
return None
|
|
315
328
|
|
|
316
329
|
# Always append an Assistant record, even when the turn emitted only
|
|
317
330
|
# tool calls and no text. ``state.unpublished_slice`` folds any
|
|
@@ -332,7 +345,7 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
332
345
|
)
|
|
333
346
|
if runtime.is_codex():
|
|
334
347
|
if internal_call.is_codex_internal_prompt(prompt):
|
|
335
|
-
return
|
|
348
|
+
return None
|
|
336
349
|
|
|
337
350
|
last_assistant_message = payload.get("last_assistant_message")
|
|
338
351
|
assistant_text = (
|
|
@@ -374,7 +387,7 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
374
387
|
if cited_items:
|
|
375
388
|
record["cited_items"] = cited_items
|
|
376
389
|
state.append(session_id, record)
|
|
377
|
-
publish.publish_unpublished(
|
|
390
|
+
return publish.publish_unpublished(
|
|
378
391
|
session_id=session_id,
|
|
379
392
|
project_id=project_id,
|
|
380
393
|
force_extraction=False,
|
|
@@ -5,6 +5,13 @@ The plugin's ``hook_entry.sh`` calls either
|
|
|
5
5
|
``python -m claude_smart.hook <host> <event>`` once per hook invocation.
|
|
6
6
|
This module reads the hook JSON from stdin, routes to the matching handler,
|
|
7
7
|
and makes sure no unhandled exception ever propagates.
|
|
8
|
+
|
|
9
|
+
Every fire produces one structured JSON line in
|
|
10
|
+
``~/.claude-smart/hook.log`` via ``hook_log.log_event`` — covers the
|
|
11
|
+
event name, session id, internal-invocation skip, handler outcome
|
|
12
|
+
(``ok`` / ``raised:<Exc>`` / ``unknown_event``), and for Stop/SessionEnd
|
|
13
|
+
the publish status + count. The log is the forensic trail for chasing
|
|
14
|
+
"hook didn't publish" mysteries from a single file.
|
|
8
15
|
"""
|
|
9
16
|
|
|
10
17
|
from __future__ import annotations
|
|
@@ -12,15 +19,22 @@ from __future__ import annotations
|
|
|
12
19
|
import json
|
|
13
20
|
import logging
|
|
14
21
|
import sys
|
|
15
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|