Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 78 additions & 50 deletions bin/revive
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ LOG_DIR="${HOME}/.context-revive"
LOG_FILE="${LOG_DIR}/hook.log"

COUNTER_FILE=".claude/revive-counter"
COMPACT_SIGNAL=".claude/revive-compact.signal"
REFRESH_EVERY="${REVIVE_REFRESH_EVERY:-5}"
REFRESH_TIME_GAP="${REVIVE_REFRESH_TIME_GAP:-600}" # seconds

Expand All @@ -34,8 +35,10 @@ Commands:
non-inferable facts the generation pass missed.
show Print the brief that would be injected (force-emit)
refresh Hook entry point — cadence-gated, silent on failure
install-hook Wire UserPromptSubmit into .claude/settings.json
(use --global for ~/.claude/settings.json)
mark-compact PostCompact hook entry — drops a signal so the next
refresh bypasses cadence and emits immediately
install-hook Wire UserPromptSubmit + PostCompact into
.claude/settings.json (use --global for ~/.claude/)
doctor Run sanity checks (git, .revive/static.md, hook,
log file). Exits non-zero if anything is broken.
version Print version
Expand Down Expand Up @@ -401,7 +404,15 @@ cadence_gate() {
gap=$((now - last))

local emit=0
if (( n == 1 )); then emit=1
# Post-compact override: a PostCompact hook drops a signal file when the
# user (or AutoCompact) just compacted the session. The agent has lost
# most of its context — that's exactly when re-injecting the brief gives
# the highest ROI, so bypass cadence and emit immediately.
if [[ -f "$COMPACT_SIGNAL" ]]; then
rm -f "$COMPACT_SIGNAL" 2>/dev/null || true
log "post-compact: forcing emit (counter=$n)"
emit=1
elif (( n == 1 )); then emit=1
elif (( n % REFRESH_EVERY == 0 )); then emit=1
elif (( gap > REFRESH_TIME_GAP )); then emit=1
fi
Expand All @@ -426,6 +437,16 @@ cmd_show() {
printf '\n--- %d chars (budget: %d) ---\n' "$len" "$BRIEF_CHAR_BUDGET" >&2
}

# PostCompact hook entry point: drop a signal file so the next refresh
# bypasses cadence and emits immediately. Always exits 0 — like cmd_refresh,
# this is on the hook hot path and must never break a Claude Code session.
cmd_mark_compact() {
mkdir -p "$(dirname "$COMPACT_SIGNAL")" 2>/dev/null || return 0
date +%s > "$COMPACT_SIGNAL" 2>/dev/null || return 0
log "post-compact signal written: $COMPACT_SIGNAL"
return 0
}

# refresh is the hook hot path: NEVER fail the session, always exit 0
cmd_refresh() {
mkdir -p "$LOG_DIR" 2>/dev/null || true
Expand Down Expand Up @@ -1058,7 +1079,8 @@ cmd_install_hook() {

local revive_bin
revive_bin=$(command -v revive 2>/dev/null || echo "$HOME/.local/bin/revive")
local cmd_line="$revive_bin refresh"
local refresh_cmd="$revive_bin refresh"
local compact_cmd="$revive_bin mark-compact"

mkdir -p "$(dirname "$settings_path")"
[[ -f "$settings_path" ]] || echo '{}' > "$settings_path"
Expand All @@ -1070,34 +1092,38 @@ jq not found. Merge this into $settings_path manually:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "$cmd_line" }
]
}
{ "hooks": [ { "type": "command", "command": "$refresh_cmd" } ] }
],
"PostCompact": [
{ "hooks": [ { "type": "command", "command": "$compact_cmd" } ] }
]
}
}
EOF
exit 1
fi

if jq -e --arg cmd "$cmd_line" \
'[.hooks.UserPromptSubmit[]?.hooks[]? | select(.command == $cmd)] | length > 0' \
"$settings_path" >/dev/null 2>&1; then
echo "hook already installed in $settings_path"
return 0
fi
upsert() {
local event="$1" cmd="$2"
if jq -e --arg cmd "$cmd" --arg ev "$event" \
'[.hooks[$ev][]?.hooks[]? | select(.command == $cmd)] | length > 0' \
"$settings_path" >/dev/null 2>&1; then
echo "$event hook already installed in $settings_path"
return 0
fi
local tmp
tmp=$(mktemp)
jq --arg cmd "$cmd" --arg ev "$event" '
.hooks = (.hooks // {})
| .hooks[$ev] = (.hooks[$ev] // [])
| .hooks[$ev] += [{"hooks":[{"type":"command","command":$cmd}]}]
' "$settings_path" > "$tmp"
mv "$tmp" "$settings_path"
echo "installed $event hook in $settings_path"
}

local tmp
tmp=$(mktemp)
jq --arg cmd "$cmd_line" '
.hooks = (.hooks // {})
| .hooks.UserPromptSubmit = (.hooks.UserPromptSubmit // [])
| .hooks.UserPromptSubmit += [{"hooks":[{"type":"command","command":$cmd}]}]
' "$settings_path" > "$tmp"
mv "$tmp" "$settings_path"
echo "installed hook in $settings_path"
upsert "UserPromptSubmit" "$refresh_cmd"
upsert "PostCompact" "$compact_cmd"
}

# --- doctor -----------------------------------------------------------------
Expand Down Expand Up @@ -1145,33 +1171,34 @@ cmd_doctor() {
_doctor_fail "$STATIC_FILE missing — run \`revive init\` first"
fi

# 6. UserPromptSubmit hook installed somewhere (local or global settings).
# When jq is available, we structurally validate the hook lives under
# .hooks.UserPromptSubmit[].hooks[]. Without jq we fall back to grep —
# weaker, but `install-hook`'s manual-fallback path (printed when jq is
# missing) tells users to add a `"command": "... revive refresh"` entry,
# so the literal string is a reliable proxy.
local hook_found=0 f
for f in ".claude/settings.json" "$HOME/.claude/settings.json"; do
[[ -f "$f" ]] || continue
# Try jq first (structural). If jq is absent OR the structural query
# errors out (e.g., broken jq, malformed JSON), fall back to grep so a
# manually-installed hook still gets detected.
if command -v jq >/dev/null 2>&1 && \
jq -e '[.hooks.UserPromptSubmit[]?.hooks[]? | select(.command | test("revive[[:space:]]+refresh"))] | length > 0' \
"$f" >/dev/null 2>&1; then
:
elif grep -qE '"command"[[:space:]]*:[[:space:]]*"[^"]*revive[[:space:]]+refresh' "$f"; then
:
else
continue
# 6. Both hooks installed somewhere (local or global settings).
# When jq is available we structurally validate; without jq we fall back
# to grep on the literal `"command": "... <pattern>"` string — weaker,
# but `install-hook`'s manual-fallback path produces exactly that shape.
_doctor_check_hook() {
local event="$1" pattern="$2"
local found=0 f
for f in ".claude/settings.json" "$HOME/.claude/settings.json"; do
[[ -f "$f" ]] || continue
if command -v jq >/dev/null 2>&1 && \
jq -e --arg ev "$event" --arg pat "$pattern" \
'[.hooks[$ev][]?.hooks[]? | select(.command | test($pat))] | length > 0' \
"$f" >/dev/null 2>&1; then
:
elif grep -qE "\"command\"[[:space:]]*:[[:space:]]*\"[^\"]*${pattern}" "$f"; then
:
else
continue
fi
_doctor_ok "$event hook installed in $f"
found=1
done
if [[ "$found" == 0 ]]; then
_doctor_warn "no $event hook found — run \`revive install-hook\` (or \`--global\`)"
fi
_doctor_ok "UserPromptSubmit hook installed in $f"
hook_found=1
done
if [[ "$hook_found" == 0 ]]; then
_doctor_warn "no UserPromptSubmit hook found — run \`revive install-hook\` (or \`--global\`)"
fi
}
_doctor_check_hook "UserPromptSubmit" "revive[[:space:]]+refresh"
_doctor_check_hook "PostCompact" "revive[[:space:]]+mark-compact"

# 7. hook log: present + sane size
if [[ -f "$LOG_FILE" ]]; then
Expand Down Expand Up @@ -1208,6 +1235,7 @@ main() {
init) cmd_init "$@" ;;
show) cmd_show "$@" ;;
refresh) cmd_refresh "$@" ;;
mark-compact) cmd_mark_compact "$@" ;;
suggest) cmd_suggest "$@" ;;
audit) cmd_audit "$@" ;;
install-hook) cmd_install_hook "$@" ;;
Expand Down
88 changes: 87 additions & 1 deletion tests/revive.bats
Original file line number Diff line number Diff line change
Expand Up @@ -1336,11 +1336,97 @@ EOF
"$REVIVE" install-hook
run "$REVIVE" doctor
[ "$status" -eq 0 ] || return 1
[[ "$output" == *"hook installed in .claude/settings.json"* ]] || return 1
[[ "$output" == *"UserPromptSubmit hook installed in .claude/settings.json"* ]] || return 1
[[ "$output" == *"PostCompact hook installed in .claude/settings.json"* ]] || return 1
[[ "$output" != *"no UserPromptSubmit hook found"* ]] || return 1
[[ "$output" != *"no PostCompact hook found"* ]] || return 1
}

@test "doctor warns when only UserPromptSubmit is wired (upgrade gap)" {
# Simulates an upgraded install: settings.json has the legacy
# UserPromptSubmit entry but PostCompact has not been added yet.
"$REVIVE" init
mkdir -p .claude
cat > .claude/settings.json <<'JSON'
{ "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "revive refresh" } ] } ] } }
JSON
run "$REVIVE" doctor
[[ "$output" == *"UserPromptSubmit hook installed"* ]] || return 1
[[ "$output" == *"no PostCompact hook found"* ]] || return 1
}

@test "doctor appears in usage help" {
run "$REVIVE" help
[[ "$output" == *"doctor"* ]] || return 1
}

# --- post-compact trigger ---

@test "mark-compact writes signal file in .claude/" {
mkdir -p .claude
run "$REVIVE" mark-compact
[ "$status" -eq 0 ] || return 1
[ -f .claude/revive-compact.signal ] || return 1
}

@test "mark-compact silently exits 0 when .claude/ cannot be created" {
# parent dir un-writable: hook must still succeed (silent-failure contract).
# Trap restores write perm even if an early assertion fails — without it
# a failed chmod -w would leak a read-only WORKDIR and break teardown.
trap 'chmod +w . || true' RETURN
chmod -w . || return 1
run "$REVIVE" mark-compact
[ "$status" -eq 0 ] || return 1
}

@test "refresh emits immediately when post-compact signal exists" {
# bump cadence so we'd normally skip — signal must override
mkdir -p .claude
: > .claude/revive-compact.signal
REVIVE_REFRESH_EVERY=999 REVIVE_REFRESH_TIME_GAP=99999 \
run "$REVIVE" refresh
[ "$status" -eq 0 ] || return 1
[[ "$output" == *"<revive refresh="* ]] || return 1
}

@test "refresh removes the signal after consuming it" {
mkdir -p .claude
: > .claude/revive-compact.signal
"$REVIVE" refresh >/dev/null
[ ! -f .claude/revive-compact.signal ] || return 1
}

@test "post-compact emit advances the cadence counter" {
# signal forces emit; counter must still tick so subsequent calls behave normally
mkdir -p .claude
: > .claude/revive-compact.signal
"$REVIVE" refresh >/dev/null
[ -f .claude/revive-counter ] || return 1
run cat .claude/revive-counter
[[ "$output" == 1:* ]] || return 1
}

@test "install-hook adds PostCompact entry alongside UserPromptSubmit" {
"$REVIVE" install-hook
run cat .claude/settings.json
[[ "$output" == *"UserPromptSubmit"* ]] || return 1
[[ "$output" == *"PostCompact"* ]] || return 1
[[ "$output" == *"revive refresh"* ]] || return 1
[[ "$output" == *"revive mark-compact"* ]] || return 1
}

@test "install-hook is idempotent for both hooks" {
"$REVIVE" install-hook
"$REVIVE" install-hook
# exactly one of each, even after a second install
local rc cc
rc=$(grep -c 'revive refresh' .claude/settings.json)
cc=$(grep -c 'revive mark-compact' .claude/settings.json)
[ "$rc" -eq 1 ] || return 1
[ "$cc" -eq 1 ] || return 1
}

@test "mark-compact appears in usage help" {
run "$REVIVE" help
[[ "$output" == *"mark-compact"* ]] || return 1
}
Loading