Every ledger is cleared on a compact, and a retrieval floor becomes something the model maintains #163
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||||
"version": "2026.09.17.0100",
|
"version": "2026.09.17.0111",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -329,12 +329,26 @@ scribe_held_query() {
|
|||||||
# deliberate exception and has to say so.
|
# deliberate exception and has to say so.
|
||||||
#
|
#
|
||||||
# Scoped to `.ids` rather than `<sid>.*` so a marker that is REWRITTEN on
|
# Scoped to `.ids` rather than `<sid>.*` so a marker that is REWRITTEN on
|
||||||
# compact rather than discarded can still live in this directory without
|
# compact rather than discarded can still live in these directories without
|
||||||
# being swept away by a glob that was never told about it.
|
# being swept away by a glob that was never told about it. `<sid>.unreached`
|
||||||
|
# is the live example: it records that the instance could not be reached, not
|
||||||
|
# what the session holds, and #2932 needs it to outlive a compaction.
|
||||||
|
#
|
||||||
|
# TWO DIRECTORIES, WHICH IS ITS OWN LESSON. The first cut of this swept only
|
||||||
|
# `scribe-priorart` — every ledger named in the hooks was there, so the list
|
||||||
|
# looked complete. `scribe_autoinject.sh` keeps its note ledger in
|
||||||
|
# `scribe-autoinject`, so the arm that fires most (598 calls in five days) was
|
||||||
|
# the one the clear could not reach, and the fix read as finished. Hence the
|
||||||
|
# roster here rather than a path at the call site: one place to add to, and
|
||||||
|
# the tests read THIS string rather than a copy of it.
|
||||||
|
SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject"
|
||||||
|
|
||||||
scribe_clear_session_ledgers() {
|
scribe_clear_session_ledgers() {
|
||||||
local dir="$1" sid="$2"
|
local sid="$1" dir
|
||||||
[ -n "$dir" ] && [ -n "$sid" ] || return 0
|
[ -n "$sid" ] || return 0
|
||||||
rm -f "$dir/$sid"*.ids 2>/dev/null || true
|
for dir in $SCRIBE_LEDGER_DIRS; do
|
||||||
|
rm -f "${TMPDIR:-/tmp}/$dir/$sid"*.ids 2>/dev/null || true
|
||||||
|
done
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,14 +121,16 @@ source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=
|
|||||||
# a ledger that cannot be removed costs a repeated exclusion, never a session.
|
# a ledger that cannot be removed costs a repeated exclusion, never a session.
|
||||||
#
|
#
|
||||||
# NOT swept: `<sid>.unreached`, which records that the instance was unreachable
|
# NOT swept: `<sid>.unreached`, which records that the instance was unreachable
|
||||||
# rather than what the session holds, and survives on purpose.
|
# rather than what the session holds, and survives on purpose. The directories
|
||||||
|
# swept are `scribe_defs.sh`'s `SCRIBE_LEDGER_DIRS` — plural, because the
|
||||||
|
# auto-inject arm keeps its ledger somewhere else and a single-directory sweep
|
||||||
|
# missed exactly the arm that fires most.
|
||||||
case "$source" in
|
case "$source" in
|
||||||
compact|clear)
|
compact|clear)
|
||||||
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
||||||
if [ -n "$sid" ]; then
|
if [ -n "$sid" ]; then
|
||||||
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_')
|
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
scribe_clear_session_ledgers \
|
scribe_clear_session_ledgers "$safe_sid"
|
||||||
"${TMPDIR:-/tmp}/scribe-priorart" "$safe_sid"
|
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -108,14 +108,29 @@ def test_the_rules_ledger_survives_exactly_when_the_context_does(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_rule_ledger_is_cleared_and_the_note_ledgers_are_left(tmp_path):
|
def test_the_note_ledgers_are_cleared_too_now_that_a_repeat_is_shown(tmp_path):
|
||||||
"""Scope, asserted rather than described.
|
"""The decision this test used to pin, reversed — and why it could be.
|
||||||
|
|
||||||
The same directory holds `.ids`, `.sync.ids` and `.derive.ids` for the note
|
It read: the note arms were deliberately left out of #3749, whether a
|
||||||
arms. Whether a surfaced NOTE should come back after a compaction is a
|
surfaced NOTE should come back after a compaction is a different question,
|
||||||
different question with a different answer, and it is not being answered.
|
and a glob over `<sid>.*` would decide it by accident. That was right when
|
||||||
A `rm` glob over `<sid>.*` would pass every assertion in the test above
|
it was written, and the reason was the note arms' behaviour rather than the
|
||||||
while silently deciding it.
|
note arms' nature: a note repeat was WITHHELD, so clearing their ledger
|
||||||
|
meant re-injecting whole menu lines the session had already been given.
|
||||||
|
Against that, leaving them was the cheaper mistake.
|
||||||
|
|
||||||
|
#4101 removed the premise. A note repeat is now rendered again with a
|
||||||
|
`seen` marker instead of dropped from the search, so the ledger no longer
|
||||||
|
decides whether a record is reachable — only whether its line says the
|
||||||
|
session has met it before. Across a compaction that marker becomes a false
|
||||||
|
statement: it tells a freshly-summarised session it has already seen a
|
||||||
|
record that is nowhere in its context. Keeping the ledger buys nothing and
|
||||||
|
asserts something untrue.
|
||||||
|
|
||||||
|
Kept as a named test rather than deleted, because the roster it guards
|
||||||
|
moved to tests/test_session_ledger_clear.py and what is worth keeping HERE
|
||||||
|
is the record that the answer changed, with the reason attached — a
|
||||||
|
deleted test takes the old decision's reasoning with it.
|
||||||
"""
|
"""
|
||||||
env = _env(tmp_path)
|
env = _env(tmp_path)
|
||||||
sid = "sess-scope"
|
sid = "sess-scope"
|
||||||
@@ -127,10 +142,9 @@ def test_only_the_rule_ledger_is_cleared_and_the_note_ledgers_are_left(tmp_path)
|
|||||||
_fire("compact", sid, env)
|
_fire("compact", sid, env)
|
||||||
|
|
||||||
assert not rules.exists(), "the rule ledger should have been cleared"
|
assert not rules.exists(), "the rule ledger should have been cleared"
|
||||||
assert notes.exists() and sync.exists() and derive.exists(), (
|
assert not (notes.exists() or sync.exists() or derive.exists()), (
|
||||||
"a note ledger was cleared too. The note arms were deliberately left "
|
"a note ledger outlived the context it describes, so its records are "
|
||||||
"out of #3749 — clearing them is a decision about a different surface, "
|
"marked `seen` to a session that can no longer see them"
|
||||||
"and a glob that takes them along makes it by accident."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,25 +51,43 @@ import pytest
|
|||||||
|
|
||||||
HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
|
HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
|
||||||
SESSION_START = HOOKS / "scribe_session_context.sh"
|
SESSION_START = HOOKS / "scribe_session_context.sh"
|
||||||
|
DEFS = HOOKS / "scribe_defs.sh"
|
||||||
|
|
||||||
# Every ledger the hooks write today. Named here so a failure READS as "this
|
# Every ledger the hooks write today, and where. Named here so a failure READS
|
||||||
# one survived", but never used to build the hook's own delete list — the
|
# as "this one survived", but never used to build the hook's own delete list —
|
||||||
# convention test below is what keeps this roster honest as it grows.
|
# the convention tests below are what keep this roster honest as it grows.
|
||||||
LEDGERS = (".ids", ".rules.ids", ".opened.ids", ".sync.ids", ".derive.ids")
|
LEDGERS = {
|
||||||
|
"scribe-priorart": (".ids", ".rules.ids", ".opened.ids", ".sync.ids",
|
||||||
|
".derive.ids"),
|
||||||
|
"scribe-autoinject": (".ids",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _swept_dirs() -> set[str]:
|
||||||
|
"""The directories the SHIPPED hook sweeps, read from the hook itself.
|
||||||
|
|
||||||
|
Read rather than restated: a test carrying its own copy of the roster would
|
||||||
|
agree with itself forever, which is precisely the failure that let the
|
||||||
|
auto-inject ledger sit in a directory nothing cleared.
|
||||||
|
"""
|
||||||
|
line = re.search(r'SCRIBE_LEDGER_DIRS="([^"]*)"', DEFS.read_text())
|
||||||
|
assert line, "SCRIBE_LEDGER_DIRS is gone; the clear has no roster"
|
||||||
|
return set(line.group(1).split())
|
||||||
|
|
||||||
|
|
||||||
def _run_session_start(source: str, tmp: Path) -> Path:
|
def _run_session_start(source: str, tmp: Path) -> Path:
|
||||||
"""Run the SessionStart hook for real, with a populated ledger directory."""
|
"""Run the SessionStart hook for real, with the ledger directories filled."""
|
||||||
for tool in ("bash", "jq"):
|
for tool in ("bash", "jq"):
|
||||||
if shutil.which(tool) is None:
|
if shutil.which(tool) is None:
|
||||||
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
||||||
|
|
||||||
state = tmp / "scribe-priorart"
|
for dirname, suffixes in LEDGERS.items():
|
||||||
state.mkdir(parents=True, exist_ok=True)
|
state = tmp / dirname
|
||||||
for suffix in LEDGERS:
|
state.mkdir(parents=True, exist_ok=True)
|
||||||
(state / f"s1{suffix}").write_text("42\t1789600000\n")
|
for suffix in suffixes:
|
||||||
|
(state / f"s1{suffix}").write_text("42\t1789600000\n")
|
||||||
# Not a ledger: an outage marker that must outlive the clear.
|
# Not a ledger: an outage marker that must outlive the clear.
|
||||||
(state / "s1.unreached").write_text("1\n")
|
(tmp / "scribe-priorart" / "s1.unreached").write_text("1\n")
|
||||||
|
|
||||||
env = {"PATH": os.environ["PATH"], "HOME": str(tmp), "TMPDIR": str(tmp)}
|
env = {"PATH": os.environ["PATH"], "HOME": str(tmp), "TMPDIR": str(tmp)}
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
@@ -78,14 +96,15 @@ def _run_session_start(source: str, tmp: Path) -> Path:
|
|||||||
capture_output=True, text=True, env=env, timeout=60,
|
capture_output=True, text=True, env=env, timeout=60,
|
||||||
)
|
)
|
||||||
assert out.returncode == 0, out.stderr
|
assert out.returncode == 0, out.stderr
|
||||||
return state
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("source", ["compact", "clear"])
|
@pytest.mark.parametrize("source", ["compact", "clear"])
|
||||||
def test_a_context_destroying_source_clears_every_ledger(source, tmp_path):
|
def test_a_context_destroying_source_clears_every_ledger(source, tmp_path):
|
||||||
"""The step, stated as behaviour: all five, not the two that were listed."""
|
"""The step, stated as behaviour: all of them, in both directories."""
|
||||||
state = _run_session_start(source, tmp_path)
|
root = _run_session_start(source, tmp_path)
|
||||||
survived = [s for s in LEDGERS if (state / f"s1{s}").exists()]
|
survived = [f"{d}/s1{s}" for d, suffixes in LEDGERS.items()
|
||||||
|
for s in suffixes if (root / d / f"s1{s}").exists()]
|
||||||
assert not survived, (
|
assert not survived, (
|
||||||
f"{survived} survived a {source!r} that destroyed what they describe"
|
f"{survived} survived a {source!r} that destroyed what they describe"
|
||||||
)
|
)
|
||||||
@@ -100,11 +119,13 @@ def test_a_source_that_kept_the_context_keeps_the_ledgers(source, tmp_path):
|
|||||||
restore that lost nothing. A blanket glob makes over-clearing cheap to
|
restore that lost nothing. A blanket glob makes over-clearing cheap to
|
||||||
write, which is exactly why this direction needs a test of its own.
|
write, which is exactly why this direction needs a test of its own.
|
||||||
"""
|
"""
|
||||||
state = _run_session_start(source, tmp_path)
|
root = _run_session_start(source, tmp_path)
|
||||||
for suffix in LEDGERS:
|
for dirname, suffixes in LEDGERS.items():
|
||||||
assert (state / f"s1{suffix}").exists(), (
|
for suffix in suffixes:
|
||||||
f"s1{suffix} was cleared on {source!r}, which lost no context"
|
assert (root / dirname / f"s1{suffix}").exists(), (
|
||||||
)
|
f"{dirname}/s1{suffix} was cleared on {source!r}, which lost "
|
||||||
|
"no context"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_outage_marker_is_not_swept_with_them(tmp_path):
|
def test_the_outage_marker_is_not_swept_with_them(tmp_path):
|
||||||
@@ -115,33 +136,79 @@ def test_the_outage_marker_is_not_swept_with_them(tmp_path):
|
|||||||
distinguishable, and a clear that took this file out would quietly answer
|
distinguishable, and a clear that took this file out would quietly answer
|
||||||
the second with the first.
|
the second with the first.
|
||||||
"""
|
"""
|
||||||
state = _run_session_start("compact", tmp_path)
|
root = _run_session_start("compact", tmp_path)
|
||||||
assert (state / "s1.unreached").exists()
|
assert (root / "scribe-priorart" / "s1.unreached").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_every_ledger_any_hook_builds_follows_the_naming_convention():
|
# ── the two assumptions the sweep rests on ─────────────────────────────────
|
||||||
"""The assumption the glob rests on, asserted where it can actually fail.
|
#
|
||||||
|
# Both are checked against the hooks rather than trusted, because neither fails
|
||||||
|
# loudly: a ledger outside them simply never clears, on the arm whose author had
|
||||||
|
# no reason to know a convention existed.
|
||||||
|
|
||||||
A ledger named outside `<sid>[.<kind>].ids` does not break loudly — it just
|
def _dir_vars(text: str) -> dict[str, str]:
|
||||||
never clears, on the arm whose author had no reason to know a convention
|
"""`var="${TMPDIR:-/tmp}/<name>"` → {var: name}, per script."""
|
||||||
existed. So the convention is checked against the hooks themselves rather
|
return dict(re.findall(
|
||||||
than trusted: every path any hook composes from the session-id stem has to
|
r'(\w+)="\$\{TMPDIR:-/tmp\}/([A-Za-z0-9_-]+)"', text))
|
||||||
end in `.ids`, or be named below as a deliberate non-ledger.
|
|
||||||
|
|
||||||
|
def _session_paths(text: str):
|
||||||
|
"""Every `$<var>/${safe_sid}<suffix>` a script composes."""
|
||||||
|
return re.findall(r'\$(\w+)"?/\$\{safe_sid\}([A-Za-z0-9_.]*)', text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_directory_holding_a_ledger_is_one_the_clear_visits():
|
||||||
|
"""The failure that shipped once already, in the same step that fixed it.
|
||||||
|
|
||||||
|
The first cut swept `scribe-priorart` alone. Every ledger NAMED in the
|
||||||
|
hooks was there, so it read as complete — and `scribe_autoinject.sh` keeps
|
||||||
|
its note ledger in `scribe-autoinject`, which meant the arm that fires most
|
||||||
|
was the only one still carrying the bug. Nothing said so: the clear ran,
|
||||||
|
found nothing to remove, and exited 0.
|
||||||
"""
|
"""
|
||||||
# Files built from the safe session id that are NOT per-session ledgers.
|
swept = _swept_dirs()
|
||||||
NOT_LEDGERS = {".unreached"}
|
|
||||||
|
|
||||||
offenders = []
|
offenders = []
|
||||||
for script in sorted(HOOKS.glob("*.sh")):
|
for script in sorted(HOOKS.glob("*.sh")):
|
||||||
for suffix in re.findall(r'\$\{safe_sid\}([A-Za-z0-9_.]*)',
|
text = script.read_text()
|
||||||
script.read_text()):
|
dirs = _dir_vars(text)
|
||||||
if suffix in NOT_LEDGERS or suffix.endswith(".ids"):
|
for var, suffix in _session_paths(text):
|
||||||
|
if not suffix.endswith(".ids"):
|
||||||
continue
|
continue
|
||||||
offenders.append(f"{script.name}: ${{safe_sid}}{suffix}")
|
where = dirs.get(var)
|
||||||
|
if where not in swept:
|
||||||
|
offenders.append(f"{script.name}: ${var} -> {where or '?'}")
|
||||||
|
|
||||||
assert not offenders, (
|
assert not offenders, (
|
||||||
"these session files clear on no compaction, because the clear matches "
|
"these ledgers live in directories the compaction clear never visits, "
|
||||||
f"on the `.ids` convention and they do not follow it: {offenders}"
|
f"so they outlive the context they describe: {offenders}. Add the "
|
||||||
|
"directory to SCRIBE_LEDGER_DIRS in scribe_defs.sh."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_session_file_in_a_swept_directory_follows_the_convention():
|
||||||
|
"""The other half: the sweep matches `.ids`, so a ledger must be named it.
|
||||||
|
|
||||||
|
Stated as its own test because the two assumptions fail differently — this
|
||||||
|
one lets a ledger sit in the right directory and still never clear.
|
||||||
|
"""
|
||||||
|
# Session files in a swept directory that are NOT per-session ledgers.
|
||||||
|
NOT_LEDGERS = {".unreached"}
|
||||||
|
|
||||||
|
swept = _swept_dirs()
|
||||||
|
offenders = []
|
||||||
|
for script in sorted(HOOKS.glob("*.sh")):
|
||||||
|
text = script.read_text()
|
||||||
|
dirs = _dir_vars(text)
|
||||||
|
for var, suffix in _session_paths(text):
|
||||||
|
if dirs.get(var) not in swept:
|
||||||
|
continue # a different directory, a different question
|
||||||
|
if suffix in NOT_LEDGERS or suffix.endswith(".ids"):
|
||||||
|
continue
|
||||||
|
offenders.append(f"{script.name}: ${var}/${{safe_sid}}{suffix}")
|
||||||
|
|
||||||
|
assert not offenders, (
|
||||||
|
"these session files sit in a swept directory but clear on no "
|
||||||
|
f"compaction, because the sweep matches `.ids`: {offenders}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -149,13 +216,13 @@ def test_the_clear_is_derived_and_not_a_list_of_names():
|
|||||||
"""The regression that would look like a fix.
|
"""The regression that would look like a fix.
|
||||||
|
|
||||||
Appending an `rm -f` per ledger passes every behavioural test above while
|
Appending an `rm -f` per ledger passes every behavioural test above while
|
||||||
rebuilding the trap for the sixth one. The property worth keeping is that
|
rebuilding the trap for the next one. The property worth keeping is that
|
||||||
the hook names no ledger at all.
|
the hook names no ledger at all.
|
||||||
"""
|
"""
|
||||||
sh = SESSION_START.read_text()
|
sh = SESSION_START.read_text()
|
||||||
block = sh.split('case "$source" in')[1].split("esac")[0]
|
block = sh.split('case "$source" in')[1].split("esac")[0]
|
||||||
assert "scribe_clear_session_ledgers" in block
|
assert "scribe_clear_session_ledgers" in block
|
||||||
for suffix in LEDGERS:
|
for suffix in {s for suffixes in LEDGERS.values() for s in suffixes}:
|
||||||
assert suffix not in block, (
|
assert suffix not in block, (
|
||||||
f"the clear names {suffix} again — a list, not a convention"
|
f"the clear names {suffix} again — a list, not a convention"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user