From c7d13c29d56ae0ab12d761b0a9e6307f8f647b3e Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Mon, 27 Apr 2026 15:27:05 +0900 Subject: [PATCH 1/3] feat: trigger refresh immediately after `/compact` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third documented refresh trigger (alongside the every-5-prompts counter and the >10-min gap) was deferred in CLAUDE.md as an open question. Claude Code's hook system actually exposes `PostCompact` with a JSON payload — feasibility is no longer a question. Wire-up: - New `cmd_mark_compact` writes `.claude/revive-compact.signal` with a unix timestamp. Like `cmd_refresh`, it is on the hook hot path: always exits 0, fails silently, mkdir best-effort. - `cadence_gate` checks for the signal first. If present, it removes the signal, logs `post-compact: forcing emit`, and emits regardless of counter or time-gap. The counter still ticks so subsequent calls resume normal cadence. - `cmd_install_hook` now also wires `PostCompact` → `revive mark-compact` alongside the existing `UserPromptSubmit` → `revive refresh` entry. Refactored the upsert into a small inner helper so both events go through the same idempotency check. Why post-compact specifically: agents lose most of their context when the user runs `/compact` (or AutoCompact fires). That is precisely when re-injecting a brief produces the highest ROI — waiting for the next 5-prompt boundary throws that opportunity away. 8 new tests cover signal lifecycle (write, consume, counter advance), the silent-failure contract, install-hook idempotence for both hooks, and the help-text surface. 125 / 125 pass; shellcheck clean. --- bin/revive | 75 ++++++++++++++++++++++++++++++++--------------- tests/revive.bats | 69 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 24 deletions(-) diff --git a/bin/revive b/bin/revive index 482bd0f..b116c8c 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 ----------------------------------------------------------------- @@ -1208,6 +1234,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..abc3887 100644 --- a/tests/revive.bats +++ b/tests/revive.bats @@ -1344,3 +1344,72 @@ EOF 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) + chmod -w . + run "$REVIVE" mark-compact + chmod +w . + [ "$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 +} From e9d27d9f06f91de32c91a8df722bbea4d3cb86b9 Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Mon, 27 Apr 2026 15:36:36 +0900 Subject: [PATCH 2/3] fix(doctor): also check PostCompact hook (codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged that doctor only validates `UserPromptSubmit`, so an upgraded install that hasn't re-run `install-hook` would silently miss the new `PostCompact` wire-up — `/compact` refresh would never fire and doctor would still say "all checks passed". Refactored the hook-check into an inner helper `_doctor_check_hook` that takes (event, command-pattern). Calls it twice — once for UserPromptSubmit/refresh, once for PostCompact/mark-compact. Same jq-then-grep fallback semantics as before. New test "doctor warns when only UserPromptSubmit is wired (upgrade gap)" reproduces the exact scenario codex called out: hand-craft a settings.json with just the legacy hook, confirm doctor surfaces the missing PostCompact entry. --- bin/revive | 53 ++++++++++++++++++++++++----------------------- tests/revive.bats | 17 ++++++++++++++- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/bin/revive b/bin/revive index b116c8c..0f9b6f8 100755 --- a/bin/revive +++ b/bin/revive @@ -1171,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 diff --git a/tests/revive.bats b/tests/revive.bats index abc3887..71dbb94 100644 --- a/tests/revive.bats +++ b/tests/revive.bats @@ -1336,8 +1336,23 @@ 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" { From 2350c17a835d30482d9c30ed5ab7ceffae36173b Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Mon, 27 Apr 2026 16:00:11 +0900 Subject: [PATCH 3/3] test(mark-compact): assert chmod -w succeeded (copilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silent-failure test could pass without exercising the silent-failure path if `chmod -w .` itself failed (e.g., FS quirk on CI). Add `|| return 1` so the test fails fast, and a RETURN trap so the write permission is restored even if a later assertion bails — otherwise a leaked read-only WORKDIR breaks teardown. --- tests/revive.bats | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/revive.bats b/tests/revive.bats index 71dbb94..8b0aad0 100644 --- a/tests/revive.bats +++ b/tests/revive.bats @@ -1370,10 +1370,12 @@ JSON } @test "mark-compact silently exits 0 when .claude/ cannot be created" { - # parent dir un-writable: hook must still succeed (silent-failure contract) - chmod -w . + # 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 - chmod +w . [ "$status" -eq 0 ] || return 1 }