Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 + 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)
```
Expand Down Expand Up @@ -167,6 +167,13 @@ 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.
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:
Expand Down Expand Up @@ -277,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
Expand Down
61 changes: 45 additions & 16 deletions bin/revive
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -437,16 +439,25 @@ 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
log "post-compact signal written: $COMPACT_SIGNAL"
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
Expand Down Expand Up @@ -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"
Expand All @@ -1147,34 +1159,49 @@ 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" } ] }
]
}
}
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 -----------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "$@" ;;
Expand Down
57 changes: 57 additions & 0 deletions tests/revive.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading