diff --git a/bin/revive b/bin/revive index 482bd0f..0f9b6f8 100755 --- a/bin/revive +++ b/bin/revive @@ -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 @@ -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 @@ -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 @@ -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 @@ -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" @@ -1070,11 +1092,10 @@ 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" } ] } ] } } @@ -1082,22 +1103,27 @@ 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 ----------------------------------------------------------------- @@ -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": "... "` 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 @@ -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 "$@" ;; diff --git a/tests/revive.bats b/tests/revive.bats index 2c59d6f..8b0aad0 100644 --- a/tests/revive.bats +++ b/tests/revive.bats @@ -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" == *" .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 +}