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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Pinned by `tests/test_save_session_marker_arithmetic_322.py`, which asserts on the diagnostic rather than on whether a save happened — falling back to `0` makes a corrupt marker read as "very old", so corrupt and clean-but-ancient reach the same decision and only the stderr separates broken from fixed.

**A third reader of the same marker, in `scripts/post-tool-hook.sh`.** It has carried the digits-only `case` since [#230](https://github.com/Digital-Process-Tools/claude-remember/issues/230) and carried no `10#`, so `08`/`09` reached its arithmetic as octal at both of its file-sourced operands — `tmp/last-save-ts` at the fork throttle and `tmp/no-transcript-notice` at the once-an-hour report. The abandoned body there swallows an `exit 0`, so the hook ran on past it and the line below the `fi` deleted the notice marker on the assumption a transcript had been found. Both are pinned on behaviour rather than on the diagnostic alone: the diagnostic goes to `hook-errors.log` and not to stderr, so a stderr-only assertion is green against the unfixed hook. `SAVE_COOLDOWN` on the next line looks identical and was left alone — it is the right-hand operand of `[ … -lt … ]`, and `test` parses base 10 without evaluating (`[ 9 -lt 010 ]` is true), so a `10#` there would advertise a gap that does not exist.

Still open, deliberately and named rather than left silent: the same missing `10#` in `50-git-backup.sh`'s #258 guard and in `50-git-restore.sh`'s fetch-state read. Neither is this issue's marker, and `test_a_corrupt_cooldown_marker_does_not_kill_the_hook` currently writes `<store>/.last-git-backup-ts` while the hook reads the state dir under the git common dir — so that regression cannot be pinned until the test is pointed at the path the hook uses.

- **The marker scan's list of field names was assumed exhaustive, and the assumption failed silently** ([#320](https://github.com/Digital-Process-Tools/claude-remember/issues/320)) — [#318](https://github.com/Digital-Process-Tools/claude-remember/issues/318) narrowed `_failure_haystack` to the fields the CLI itself authors (`error` / `result` / `message`, the `errors` list, stderr) and kept the raw-stdout fallback behind `if not authored`. That guard needs **every** recognised field to be empty. So a terminal record reporting an auth failure in some field this does not read, while a field it does read holds something benign, is scanned, found clean, and reported as "not an isolation problem" — the un-isolated retry never runs, capture fails permanently, and nothing says why. [#316](https://github.com/Digital-Process-Tools/claude-remember/issues/316)'s outage exactly, reached through the field set instead of through the spelling list.

**Not a present defect, and it is fixed anyway.** Every input that reproduces it names a field the measured CLI does not emit. The point is that no test asserted the set was exhaustive and no comment recorded it as an assumption, so the day it stopped being true it would have stopped being true in silence — which is the one property this repo keeps paying for.
Expand Down
14 changes: 12 additions & 2 deletions scripts/post-tool-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,13 @@ if [ -z "$LATEST_JSONL" ]; then
read -r NOTICE_LAST < "$NOTICE_MARKER" 2>/dev/null
case "$NOTICE_LAST" in ''|*[!0-9]*) NOTICE_LAST=0 ;; esac
fi
if [ $(( $(_remember_date +%s) - NOTICE_LAST )) -ge "$NOTICE_TTL" ]; then
# 10# after the case, never instead of it (#322): "08"/"09" are all digits,
# so they clear the guard and are then read as octal. Bash abandons the rest
# of this if body at that point -- including the `exit 0` below -- so the
# notice is never sent and the line under `fi` deletes the marker instead.
# 10# on an empty string is itself an error on bash 5, which is why the case
# stays in front.
if [ $(( $(_remember_date +%s) - 10#$NOTICE_LAST )) -ge "$NOTICE_TTL" ]; then
mkdir -p "$REMEMBER_DIR/tmp" 2>/dev/null
_remember_date +%s > "$NOTICE_MARKER" 2>/dev/null
if [ -d "$SESSION_DIR" ]; then
Expand Down Expand Up @@ -322,7 +328,11 @@ if [ -f "$COOLDOWN_MARKER" ]; then
case "$LAST_TS" in ''|*[!0-9]*) LAST_TS=0 ;; esac
SAVE_COOLDOWN=$(config ".cooldowns.save_seconds" 120)
case "$SAVE_COOLDOWN" in ''|*[!0-9]*) SAVE_COOLDOWN=120 ;; esac
[ $(( $(_remember_date +%s) - LAST_TS )) -lt "$SAVE_COOLDOWN" ] && IN_COOLDOWN=true
# 10# for the same reason as the notice marker above (#322). Only LAST_TS
# needs it: SAVE_COOLDOWN is the right-hand operand of `[ ... -lt ... ]`,
# and `test` parses base 10 without evaluating -- measured, `[ 9 -lt 010 ]`
# is true. Marking it too would advertise a gap that is not there.
[ $(( $(_remember_date +%s) - 10#$LAST_TS )) -lt "$SAVE_COOLDOWN" ] && IN_COOLDOWN=true
fi

# --- Fire save if delta exceeds threshold and no save already running ---
Expand Down
31 changes: 28 additions & 3 deletions tests/test_case_divergence_298.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,13 +523,38 @@ def test_doctor_is_quiet_when_the_spellings_agree(tmp_path):

def test_the_per_tool_call_path_is_not_touched(tmp_path):
"""#299 asserts this and it stays asserted. `session_dir_slug` runs on every
tool call; this check runs once per session and nowhere near it."""
tool call; this check runs once per session and nowhere near it.

For `post-tool-hook.sh` the property is now asserted directly rather than as
byte-equality with `origin/main` (#322). Byte-equality says "nobody has
edited this file since main", which is a different claim from "the hot path
is still cheap" and fails in both directions: it stopped an unrelated
one-token arithmetic guard in that file, and it goes vacuous the moment the
edit it objected to lands on main. What #298/#299 actually care about is
that the divergence check is unreachable from the per-tool-call path and
that the path spawns no git, so that is what is checked. The other two arms
keep the byte compare — they were not what blocked, and widening a guard
that belongs to another issue is not this change's business.
"""
body = POST_TOOL.read_text(encoding="utf-8")
code = "\n".join(line for line in body.splitlines()
if not line.lstrip().startswith("#"))
assert "lib-case-divergence" not in code, (
"the per-tool-call hook now sources the divergence library — it runs on "
"every tool call and the check is a once-per-session cost (#299)"
)
assert "case_divergence" not in code, (
"the per-tool-call hook now calls into the divergence check (#299)"
)
assert "git " not in code, (
"the per-tool-call hook now spawns git — that is the cost #298 moved to "
"session start in the first place"
)
base = subprocess.run(
["git", "-C", str(REPO_ROOT), "show", f"origin/main:scripts/post-tool-hook.sh"],
["git", "-C", str(REPO_ROOT), "show", "origin/main:scripts/post-tool-hook.sh"],
capture_output=True, text=True)
if base.returncode != 0:
pytest.skip("origin/main not available")
assert base.stdout == POST_TOOL.read_text(encoding="utf-8")
for path, rel in ((LIB_SLUG, "scripts/lib-slug.sh"),
(LIB_MEMORY_DIR, "scripts/lib-memory-dir.sh")):
ref = subprocess.run(["git", "-C", str(REPO_ROOT), "show", f"origin/main:{rel}"],
Expand Down
160 changes: 148 additions & 12 deletions tests/test_save_session_marker_arithmetic_322.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@
`tmp/last-ndc.ts` at the NDC gate.

Bash evaluates a bare variable's VALUE inside `$(( ))` as an arithmetic
expression, so a stray byte is a syntax error. Bash then abandons the rest of the
current command list and resumes at the NEXT LINE. Measured with `-e`, without
it, and with `-u`: identical, so errexit plays no part in this. What separates
the two files is the absence of `set -u` here —
expression, so a stray byte is a syntax error. Bash then abandons the ENTIRE
`if … then … fi` body and resumes after `fi`. Measured with `-e`, without it, and
with `-u`: identical, so neither errexit nor nounset plays any part.

* `save-session.sh` (no `-u`) — `ELAPSED=$(( ... ))` is abandoned, `ELAPSED`
stays unset, and the `[ "$ELAPSED" -lt ... ]` on the next line complains and is
false. This run skips its cooldown.
* `50-git-backup.sh` (`-u`) — the next line's `$ELAPSED` is an unbound variable
and the shell exits, which is the permanent stop #258 documented.
That is one mechanism, not two, and it is the same in both files. `$ELAPSED` is
itself inside the abandoned body, so `50-git-backup.sh`'s `set -u` never sees an
unbound variable and nothing dies there either — the consequence in both places
is a cooldown that is not applied and one line in the log.

Scope it honestly: both gates rewrite their marker on exactly the path a corrupt
one allows, so each SELF-HEALS. The cost is one skipped cooldown per corruption
Expand All @@ -25,9 +23,13 @@

`"08"`/`"09"` are here because a digits-only guard is not enough on its own: they
pass it and are then read as octal (`value too great for base`), failing
identically. That is why the guard carries `10#` — and why the same `10#` is
added to #258's existing guard in `50-git-backup.sh`, where that shape is not a
skipped cooldown but the unbound-variable exit above.
identically. That is why the guard carries `10#`. The same gap is still open in
#258's guard in `50-git-backup.sh` and in `50-git-restore.sh`'s fetch-state read
— both are `case`-guarded and neither carries `10#` — and is deliberately NOT
closed here: those two are not this issue's markers, the change is untestable
until `test_a_corrupt_cooldown_marker_does_not_kill_the_hook` stops writing to
`<store>/.last-git-backup-ts` while the hook reads `<store>/.git/remember/…`, and
an unpinned one-token edit is how a guard rots back out.

CONTROL: ` 1785512249 ` is in the list on purpose and must stay GREEN against
the unfixed script. Arithmetic skips surrounding whitespace, so that marker works
Expand Down Expand Up @@ -201,3 +203,137 @@ def test_a_leading_zero_marker_is_read_as_decimal(tmp_path):
"a marker of \"08\" should read as 8 seconds past the epoch — ancient, so "
f"the cooldown is long expired and the save proceeds. calls: {ran!r}"
)


# ═════════════════════════════════════════════════════════════════════════════
# The third reader of tmp/last-save-ts: scripts/post-tool-hook.sh
# ═════════════════════════════════════════════════════════════════════════════
#
# That file already carries the digits-only `case` (#230 reads it with `read`,
# not `cat`), so the non-digit half of #322 never reaches its arithmetic. It
# carries no `10#`, which is the other half: "08"/"09" are all digits, clear the
# guard, and are then read as octal. Two of its operands come from a file and
# are exposed by that:
#
# NOTICE_LAST <- tmp/no-transcript-notice
# LAST_TS <- tmp/last-save-ts, the same marker the save gate above reads
#
# Pinned on behaviour, not only on the diagnostic: both sites decide something
# with the number, and read as octal the decision is not made at all — the
# abandoned body leaves the throttle off and the notice unsent.
#
# SAVE_COOLDOWN on the line after LAST_TS has the same shape and is NOT exposed:
# it is the right-hand operand of `[ ... -lt ... ]`, and the `test` builtin
# parses base 10 without evaluating. Measured rather than assumed — `[ 9 -lt
# 010 ]` is true, so 010 is ten there and eight inside `$(( ))`. Left alone on
# purpose; a `10#` there would advertise a gap that is not there.

import os # noqa: E402
import subprocess # noqa: E402

from test_post_tool_cooldown import HOOK, _reap, _run_post_tool, _slug # noqa: E402


def _leading_zero_stamp(epoch: int) -> str:
"""A marker of the one shape that survives a digits-only guard.

Asserted rather than assumed: a leading zero is octal-invalid only if an 8
or a 9 follows it, and a parameter that cannot fail either way is not a
test — it is the decoy this repo keeps paying for.
"""
stamp = "0" + str(epoch)
assert any(d in stamp for d in "89"), (
f"{stamp!r} is a valid octal literal, so it proves nothing about 10#; "
"pick an epoch whose digits include an 8 or a 9"
)
return stamp


def _hook_diagnostics(proc, remember) -> str:
"""stderr AND hook-errors.log — the hook redirects bash's own diagnostic to
the log, so an assertion on stderr alone is green against the unfixed file
and pins nothing. Measured that way before it was written."""
log = remember / "logs" / "hook-errors.log"
return proc.stderr + (log.read_text(encoding="utf-8", errors="replace")
if log.is_file() else "")


def _hook_env(home, project, remember) -> dict:
return {
**os.environ,
"HOME": str(home),
"CLAUDE_PROJECT_DIR": str(project),
"CLAUDE_PLUGIN_ROOT": str(REPO_ROOT),
"REMEMBER_DIR": str(remember),
"_LIB_MEMORY_DIR_LOADED": "1",
}


def test_the_post_tool_hook_still_throttles_on_a_leading_zero_marker(tmp_path):
"""The cooldown marker written seconds ago, with a leading zero.

Read as decimal it is now, and the fork is suppressed. Read as octal the
arithmetic fails, the rest of the `if` body is abandoned, IN_COOLDOWN stays
false, and the hook forks a save the cooldown existed to prevent — the fork
storm #125 was filed for, reached through the marker instead.
"""
stamp = _leading_zero_stamp(int(time.time()))
proc, remember = _run_post_tool(tmp_path, cooldown_ts=stamp)
_reap(remember)

assert proc.returncode == 0, proc.stderr
seen = _hook_diagnostics(proc, remember)
for phrase in ARITHMETIC_DIAGNOSTICS:
assert phrase not in seen, (
f"tmp/last-save-ts reached $(( )) as octal: bash reported {phrase!r}"
f"\n--- stderr + hook-errors.log ---\n{seen.strip()}"
)
assert not (remember / "tmp" / "save-session.pid").exists(), (
f"a cooldown marker of {stamp!r} — written seconds ago — did not "
"suppress the fork, so the hook is spawning saves inside the window"
)


def test_the_post_tool_no_transcript_notice_is_read_as_decimal(tmp_path):
"""The other file-sourced operand in the same hook, same gap.

With no transcript the hook reports once per NOTICE_TTL and then exits. Read
as octal, the whole body is abandoned — including that `exit 0` — so the
report is never made, and execution falls through to the line below the
`fi`, which DELETES the marker on the grounds that a transcript was found.
The user whose project slug does not match a session directory gets silence
from the one line that would have told them (#212).

The assertion is therefore that the marker survives and is refreshed. Its
deletion is the fingerprint of the abandoned body and cannot happen on the
path this test drives.
"""
home = tmp_path / "home"
project = tmp_path / "project"
remember = project / ".remember"
(home / ".claude" / "projects" / _slug(str(project))).mkdir(parents=True)
(remember / "tmp").mkdir(parents=True)

notice = remember / "tmp" / "no-transcript-notice"
stale = _leading_zero_stamp(int(time.time()) - 7200) # past the 3600s TTL
notice.write_text(stale, encoding="utf-8")

proc = subprocess.run(["bash", str(HOOK)], env=_hook_env(home, project, remember),
capture_output=True, text=True, timeout=60)

assert proc.returncode == 0, proc.stderr
seen = _hook_diagnostics(proc, remember)
for phrase in ARITHMETIC_DIAGNOSTICS:
assert phrase not in seen, (
f"tmp/no-transcript-notice reached $(( )) as octal: bash reported "
f"{phrase!r}\n--- stderr + hook-errors.log ---\n{seen.strip()}"
)
assert notice.is_file(), (
"the no-transcript notice marker was deleted — the hook ran past its "
"own `exit 0` and took the branch that assumes a transcript was found"
)
refreshed = notice.read_text(encoding="utf-8").strip()
assert refreshed.isdigit() and refreshed != stale, (
f"a notice marker two hours old was not refreshed (still {refreshed!r}) "
"— the hourly no-transcript report never fired"
)