From 61de2eb28440e87f6f1b505cb7a9c471104fdee4 Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Mon, 27 Apr 2026 22:27:50 +0900 Subject: [PATCH 1/4] docs(readme): document the post-compact refresh trigger Cadence section now lists four triggers (was three): first prompt, every-N counter, time-gap, and the new post-compact override. Quick start mentions install-hook now wires both UserPromptSubmit and PostCompact. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5615ce6..afd19dc 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ cd your-project revive init # scaffold .revive/static.md; PURPOSE auto-detected, 3 sections left as placeholders revive suggest | pbcopy # paste into active agent — agent rewrites PURPOSE/DIFFERENTIATORS/INVARIANTS/GOTCHAS revive audit | pbcopy # paste into a FRESH session — agent proposes bullets the first pass missed -revive install-hook # wire UserPromptSubmit hook into .claude/settings.json +revive install-hook # wire UserPromptSubmit + PostCompact hooks into .claude/settings.json revive doctor # sanity-check the install (git, static.md, hook, log) revive show # preview the assembled brief (forced emit, ignores cadence) ``` @@ -167,6 +167,10 @@ Emits when ANY of these is true: 2. **Every 5th prompt after that**, via `REVIVE_REFRESH_EVERY` (default `5`). 3. **Gap of >10 minutes** since the last emit, via `REVIVE_REFRESH_TIME_GAP` (default `600` seconds). +4. **Right after `/compact`** (or AutoCompact). The `PostCompact` hook + drops `.claude/revive-compact.signal` and the next refresh consumes it, + bypassing cadence — that's the moment the agent has lost the most + context, so re-injecting the brief gives the highest ROI. Prompts between emits see nothing from revive — silent skip, zero cost. Tune in your shell: From 5e94a0d68649805788e5cee993314799aad9e147 Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Mon, 27 Apr 2026 22:35:38 +0900 Subject: [PATCH 2/4] feat: refresh trigger after `/clear` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to the post-compact trigger from #30. `/clear` wipes more than `/compact` (full conversation reset), but the recovery path is identical — re-inject the brief on the next prompt, bypassing cadence. Wired through Claude Code's `SessionStart` hook with `matcher: "clear"`, the documented dedicated signal. Implementation: - New `cmd_mark_clear` writes the same `.claude/revive-compact.signal` as `cmd_mark_compact`. Two commands so settings.json reads naturally and hook.log distinguishes the source. - `cmd_install_hook` now wires three hooks. Refactored the upsert helper to take an optional `matcher`, so SessionStart{clear} stays distinct from any user-added SessionStart{startup} or {resume} entry (idempotency key: event + matcher + command). - `cmd_doctor` adds a third hook check via the existing `_doctor_check_hook` helper (`SessionStart` + `revive mark-clear`). - README cadence section gains trigger #5; install-hook line now lists all three events. 6 new tests cover: signal write, help-text surface, settings.json shape (matcher field present), 3-hook idempotency, doctor warn on missing SessionStart, doctor green path with all three hooks. --- README.md | 5 +++- bin/revive | 59 +++++++++++++++++++++++++++++++++++------------ tests/revive.bats | 57 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index afd19dc..fe68400 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ cd your-project revive init # scaffold .revive/static.md; PURPOSE auto-detected, 3 sections left as placeholders revive suggest | pbcopy # paste into active agent — agent rewrites PURPOSE/DIFFERENTIATORS/INVARIANTS/GOTCHAS revive audit | pbcopy # paste into a FRESH session — agent proposes bullets the first pass missed -revive install-hook # wire UserPromptSubmit + PostCompact hooks into .claude/settings.json +revive install-hook # wire UserPromptSubmit + PostCompact + SessionStart(clear) into .claude/settings.json revive doctor # sanity-check the install (git, static.md, hook, log) revive show # preview the assembled brief (forced emit, ignores cadence) ``` @@ -171,6 +171,9 @@ Emits when ANY of these is true: drops `.claude/revive-compact.signal` and the next refresh consumes it, bypassing cadence — that's the moment the agent has lost the most context, so re-injecting the brief gives the highest ROI. +5. **Right after `/clear`**. `SessionStart` with `matcher: "clear"` drops + the same signal — `/clear` wipes more than `/compact`, same recovery + path applies. Prompts between emits see nothing from revive — silent skip, zero cost. Tune in your shell: diff --git a/bin/revive b/bin/revive index 866e705..cf55e34 100755 --- a/bin/revive +++ b/bin/revive @@ -37,8 +37,10 @@ Commands: refresh Hook entry point — cadence-gated, silent on failure 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/) + mark-clear SessionStart(matcher=clear) hook entry — same signal + semantics as mark-compact, fires after `/clear` + install-hook Wire UserPromptSubmit + PostCompact + SessionStart(clear) + 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 @@ -437,9 +439,11 @@ 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. +# PostCompact / SessionStart(clear) hook entry points. Both drop the same +# signal file so the next refresh bypasses cadence and emits immediately. +# Two commands so the hook entries in settings.json read naturally and +# logs distinguish the source. Always exit 0 — these are 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 @@ -447,6 +451,13 @@ cmd_mark_compact() { return 0 } +cmd_mark_clear() { + mkdir -p "$(dirname "$COMPACT_SIGNAL")" 2>/dev/null || return 0 + date +%s > "$COMPACT_SIGNAL" 2>/dev/null || return 0 + log "post-clear 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 @@ -1132,6 +1143,7 @@ cmd_install_hook() { revive_bin=$(command -v revive 2>/dev/null || echo "$HOME/.local/bin/revive") local refresh_cmd="$revive_bin refresh" local compact_cmd="$revive_bin mark-compact" + local clear_cmd="$revive_bin mark-clear" mkdir -p "$(dirname "$settings_path")" [[ -f "$settings_path" ]] || echo '{}' > "$settings_path" @@ -1147,6 +1159,9 @@ jq not found. Merge this into $settings_path manually: ], "PostCompact": [ { "hooks": [ { "type": "command", "command": "$compact_cmd" } ] } + ], + "SessionStart": [ + { "matcher": "clear", "hooks": [ { "type": "command", "command": "$clear_cmd" } ] } ] } } @@ -1154,27 +1169,39 @@ EOF exit 1 fi + # Idempotency key: (event, matcher, command). Different matchers for the + # same event are distinct entries — e.g. SessionStart{clear} alongside a + # user-added SessionStart{startup} must not collapse into one. upsert() { - local event="$1" cmd="$2" - if jq -e --arg cmd "$cmd" --arg ev "$event" \ - '[.hooks[$ev][]?.hooks[]? | select(.command == $cmd)] | length > 0' \ + local event="$1" cmd="$2" matcher="${3:-}" + if jq -e --arg cmd "$cmd" --arg ev "$event" --arg m "$matcher" \ + '[.hooks[$ev][]? | select(((.matcher // "") == $m) and (.hooks[]?.command == $cmd))] | length > 0' \ "$settings_path" >/dev/null 2>&1; then - echo "$event hook already installed in $settings_path" + echo "$event${matcher:+($matcher)} 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" + if [[ -n "$matcher" ]]; then + jq --arg cmd "$cmd" --arg ev "$event" --arg m "$matcher" ' + .hooks = (.hooks // {}) + | .hooks[$ev] = (.hooks[$ev] // []) + | .hooks[$ev] += [{"matcher":$m,"hooks":[{"type":"command","command":$cmd}]}] + ' "$settings_path" > "$tmp" + else + jq --arg cmd "$cmd" --arg ev "$event" ' + .hooks = (.hooks // {}) + | .hooks[$ev] = (.hooks[$ev] // []) + | .hooks[$ev] += [{"hooks":[{"type":"command","command":$cmd}]}] + ' "$settings_path" > "$tmp" + fi mv "$tmp" "$settings_path" - echo "installed $event hook in $settings_path" + echo "installed $event${matcher:+($matcher)} hook in $settings_path" } upsert "UserPromptSubmit" "$refresh_cmd" upsert "PostCompact" "$compact_cmd" + upsert "SessionStart" "$clear_cmd" "clear" } # --- doctor ----------------------------------------------------------------- @@ -1250,6 +1277,7 @@ cmd_doctor() { } _doctor_check_hook "UserPromptSubmit" "revive[[:space:]]+refresh" _doctor_check_hook "PostCompact" "revive[[:space:]]+mark-compact" + _doctor_check_hook "SessionStart" "revive[[:space:]]+mark-clear" # 7. hook log: present + sane size if [[ -f "$LOG_FILE" ]]; then @@ -1287,6 +1315,7 @@ main() { show) cmd_show "$@" ;; refresh) cmd_refresh "$@" ;; mark-compact) cmd_mark_compact "$@" ;; + mark-clear) cmd_mark_clear "$@" ;; suggest) cmd_suggest "$@" ;; audit) cmd_audit "$@" ;; install-hook) cmd_install_hook "$@" ;; diff --git a/tests/revive.bats b/tests/revive.bats index 424bb70..e069d87 100644 --- a/tests/revive.bats +++ b/tests/revive.bats @@ -1496,3 +1496,60 @@ JSON [[ "$output" == *"not writable"* ]] || return 1 [ -f .revive/static.md ] || return 1 } + +# --- /clear trigger --- + +@test "mark-clear writes signal file in .claude/" { + mkdir -p .claude + run "$REVIVE" mark-clear + [ "$status" -eq 0 ] || return 1 + [ -f .claude/revive-compact.signal ] || return 1 +} + +@test "mark-clear appears in usage help" { + run "$REVIVE" help + [[ "$output" == *"mark-clear"* ]] || return 1 +} + +@test "install-hook adds SessionStart entry with matcher=clear" { + "$REVIVE" install-hook + run cat .claude/settings.json + [[ "$output" == *"SessionStart"* ]] || return 1 + [[ "$output" == *"\"matcher\":"*"\"clear\""* ]] || return 1 + [[ "$output" == *"revive mark-clear"* ]] || return 1 +} + +@test "install-hook is idempotent across all three hooks" { + "$REVIVE" install-hook + "$REVIVE" install-hook + local rc cc xc + rc=$(grep -c 'revive refresh' .claude/settings.json) + cc=$(grep -c 'revive mark-compact' .claude/settings.json) + xc=$(grep -c 'revive mark-clear' .claude/settings.json) + [ "$rc" -eq 1 ] || return 1 + [ "$cc" -eq 1 ] || return 1 + [ "$xc" -eq 1 ] || return 1 +} + +@test "doctor warns when SessionStart hook is missing" { + "$REVIVE" init + mkdir -p .claude + cat > .claude/settings.json <<'JSON' +{ "hooks": { + "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "revive refresh" } ] } ], + "PostCompact": [ { "hooks": [ { "type": "command", "command": "revive mark-compact" } ] } ] +} } +JSON + run "$REVIVE" doctor + [[ "$output" == *"no SessionStart hook found"* ]] || return 1 +} + +@test "doctor recognises all three hooks after a fresh install-hook" { + "$REVIVE" init + "$REVIVE" install-hook + run "$REVIVE" doctor + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"UserPromptSubmit hook installed"* ]] || return 1 + [[ "$output" == *"PostCompact hook installed"* ]] || return 1 + [[ "$output" == *"SessionStart hook installed"* ]] || return 1 +} From e90753de53bde653374a8f8d853d7a089c98f410 Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Mon, 27 Apr 2026 22:35:56 +0900 Subject: [PATCH 3/4] release: v0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headline: context-loss recovery. Refresh now fires immediately after `/compact` (#30) and `/clear` — the two moments when an agent has just lost most of its working memory and the brief gives the highest ROI. Three hook events wired by `install-hook`: UserPromptSubmit, PostCompact, SessionStart(matcher=clear). Also since v0.1.19: - `revive init` auto-fixes `.gitignore` so static.md is trackable (#31) - README tagline rewrite (#31) --- README.md | 5 +++-- bin/revive | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fe68400..ba5a333 100644 --- a/README.md +++ b/README.md @@ -284,8 +284,9 @@ works on any dev machine without installing a language toolchain. ## Status -Pre-alpha. Weekend MVP in active dogfooding. **v0.1.19** — `revive doctor` -sanity-check command; the repo now dogfoods its own static.md; see +Pre-alpha. Weekend MVP in active dogfooding. **v0.2.0** — context-loss +recovery: refresh fires after `/compact` and `/clear`; `revive init` +auto-fixes `.gitignore`; sharper tagline; see [Releases](https://github.com/justi/context-revive/releases) for history. ## License diff --git a/bin/revive b/bin/revive index cf55e34..44adceb 100755 --- a/bin/revive +++ b/bin/revive @@ -3,7 +3,7 @@ # https://github.com/justi/context-revive set -euo pipefail -VERSION="0.1.19" +VERSION="0.2.0" STATIC_FILE=".revive/static.md" COMMANDS_FILE=".revive/commands.md" BRIEF_CHAR_BUDGET=2200 From c514f751083cf499a449a52ec4bd93deb7465968 Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Mon, 27 Apr 2026 22:47:47 +0900 Subject: [PATCH 4/4] fix(doctor): validate SessionStart matcher (codex P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_doctor_check_hook` accepted any SessionStart entry pointing at `revive mark-clear` regardless of its `matcher` value. A user who hand-wired SessionStart{startup} → mark-clear (or no matcher at all) would pass doctor, but `/clear` would never trigger that hook. Codex flagged the false-positive on the v0.2.0 PR. Two changes: 1. The helper takes an optional 3rd arg `matcher`. When set, the jq query also asserts `(.matcher // "") == $m` so only the intended entry counts. Doctor labels include the matcher in parens — `SessionStart(clear) hook installed`. 2. The grep fallback used to override a legitimate "no match" from jq because it ran on any non-zero jq exit. Distinguish jq's exit codes: - 0 → match; - 1 → legitimate no-match (trust it, skip grep); - ≥2 → jq error or absent → grep fallback (best-effort, can't tie matcher to command across JSON lines). Without that, the matcher check would have been silently defeated whenever jq returned 1 and grep happened to find the command string anywhere in the file. Also: `set -e` interaction. The naive `jq ...; rc=$?` pattern would abort the function on jq's non-zero exit before the assignment. Wrapping in `if jq ...; then rc=0; else rc=$?; fi` preserves the code. Tests: - "doctor warns when SessionStart entry has wrong matcher" — new regression test for codex P3, hand-crafts settings.json with matcher=startup pointing at mark-clear, asserts the warning. - Existing missing-SessionStart and all-three-hooks tests updated to expect the `(clear)` label. --- bin/revive | 49 ++++++++++++++++++++++++++++++++++------------- tests/revive.bats | 21 ++++++++++++++++++-- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/bin/revive b/bin/revive index 44adceb..e93ad7f 100755 --- a/bin/revive +++ b/bin/revive @@ -1253,31 +1253,54 @@ cmd_doctor() { # 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. + # Optional 3rd arg: required `matcher` value (e.g. "clear" for + # SessionStart). When provided, the jq path verifies the hook entry + # carries that exact matcher — otherwise a SessionStart{startup} or + # {resume} entry pointing at mark-clear would falsely pass. The grep + # fallback can't structurally tie matcher to command (they sit on + # separate JSON lines), so it stays command-only — accepted limitation + # for jq-less environments. + # jq exit codes: 0 = match, 1 = legitimate no-match, ≥2 = jq error + # (broken binary, malformed JSON). The grep fallback is for jq-error + # paths only — a legitimate "1" must NOT be overridden by grep, or a + # SessionStart{startup}+mark-clear setup would falsely pass when we're + # asking specifically for matcher=clear. _doctor_check_hook() { - local event="$1" pattern="$2" - local found=0 f + local event="$1" pattern="$2" matcher="${3:-}" + local label="$event${matcher:+($matcher)}" + local found=0 f jq_rc 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 - : + if command -v jq >/dev/null 2>&1; then + # `if jq ...; then` keeps `set -e` from aborting on a non-zero + # exit — we need to preserve the code (1 vs ≥2) to distinguish + # "no match" from "jq broke". + if jq -e --arg ev "$event" --arg pat "$pattern" --arg m "$matcher" \ + '[.hooks[$ev][]? | select(((.matcher // "") == $m) and ((.hooks // []) | any(.command | test($pat))))] | length > 0' \ + "$f" >/dev/null 2>&1 + then jq_rc=0 + else jq_rc=$? + fi else - continue + jq_rc=2 fi - _doctor_ok "$event hook installed in $f" + case "$jq_rc" in + 0) ;; # match + 1) continue ;; # jq says no — trust it + *) # jq broke / absent: grep fallback + grep -qE "\"command\"[[:space:]]*:[[:space:]]*\"[^\"]*${pattern}" "$f" || continue + ;; + esac + _doctor_ok "$label hook installed in $f" found=1 done if [[ "$found" == 0 ]]; then - _doctor_warn "no $event hook found — run \`revive install-hook\` (or \`--global\`)" + _doctor_warn "no $label hook found — run \`revive install-hook\` (or \`--global\`)" fi } _doctor_check_hook "UserPromptSubmit" "revive[[:space:]]+refresh" _doctor_check_hook "PostCompact" "revive[[:space:]]+mark-compact" - _doctor_check_hook "SessionStart" "revive[[:space:]]+mark-clear" + _doctor_check_hook "SessionStart" "revive[[:space:]]+mark-clear" "clear" # 7. hook log: present + sane size if [[ -f "$LOG_FILE" ]]; then diff --git a/tests/revive.bats b/tests/revive.bats index e069d87..d21c0f9 100644 --- a/tests/revive.bats +++ b/tests/revive.bats @@ -1541,7 +1541,24 @@ JSON } } JSON run "$REVIVE" doctor - [[ "$output" == *"no SessionStart hook found"* ]] || return 1 + [[ "$output" == *"no SessionStart(clear) hook found"* ]] || return 1 +} + +@test "doctor warns when SessionStart entry has wrong matcher (codex P3)" { + # mark-clear is wired, but matcher is "startup" instead of "clear" — + # /clear will never trigger this hook. doctor must surface the gap + # rather than silently passing on command match alone. + "$REVIVE" init + mkdir -p .claude + cat > .claude/settings.json <<'JSON' +{ "hooks": { + "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "revive refresh" } ] } ], + "PostCompact": [ { "hooks": [ { "type": "command", "command": "revive mark-compact" } ] } ], + "SessionStart": [ { "matcher": "startup", "hooks": [ { "type": "command", "command": "revive mark-clear" } ] } ] +} } +JSON + run "$REVIVE" doctor + [[ "$output" == *"no SessionStart(clear) hook found"* ]] || return 1 } @test "doctor recognises all three hooks after a fresh install-hook" { @@ -1551,5 +1568,5 @@ JSON [ "$status" -eq 0 ] || return 1 [[ "$output" == *"UserPromptSubmit hook installed"* ]] || return 1 [[ "$output" == *"PostCompact hook installed"* ]] || return 1 - [[ "$output" == *"SessionStart hook installed"* ]] || return 1 + [[ "$output" == *"SessionStart(clear) hook installed"* ]] || return 1 }