feat(plugin): rule exclusions age, so salience decays without a context event (#3751)
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / integration (push) Successful in 45s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 27s

#3749 clears the ledger when an EVENT destroys context — a compaction, a
/clear. This is the case with no event at all: a long session where a
rule was named two hundred turns ago and has simply fallen out of
attention. It is #3702's argument at the tier level — present in context
and salient at the moment are different properties — applied to time
instead of to tier.

FORMAT: `id<TAB>epoch`, per entry.

Not a whole-file mtime: that is one line of shell and wrong in exactly
the session that needs it, since a single recent write keeps every stale
id alive, and the ids that go stale first come from the rules that fire
most.

Not a turn counter, though it would be the truer model — an idle session
does not forget. A hook has no turn number without keeping its own, which
is a second piece of session state to write, read, clear on compaction
and get wrong. Wall time costs a `date` call. The failure it accepts is a
session left idle over lunch treating its rules as forgotten, worth one
extra full line per rule and nothing else.

TTL 2700s (45 minutes), reasoned rather than picked (rule 32). About one
working stretch on a single task: long enough that a rule does not
re-announce itself while you are still doing the thing it governs, short
enough that a multi-hour session gets a refresh rather than one 9am
mention. It leans short because since #3750 being wrong on the short side
is the cheaper error — an expired entry costs one full line instead of
one short one, and the exclusion re-arms the moment it is spent. There is
no data on this yet; #3807's near-miss listing is what should revise it.

BOTH READERS THROUGH ONE HELPER, in scribe_defs.sh. The two hooks share
one ledger so a rule named by one arm is not re-offered by the other; a
format only one of them understood would break that on the first read.
The flat `tr '\n' ','` read would now send `156<TAB>1789002860` as an
exclude id — verified, which is why this is not a per-hook edit.

THE LAST ENTRY FOR AN ID WINS. The file is append-only, so a rule that
ages out, is surfaced fresh and is appended again has two lines. Reading
the first leaves it permanently expired, and it then re-announces itself
on every call for the rest of the session — the mechanism meant to
quieten things becoming the loudest thing in the hint.

A BARE ID IS LIVE. That is the pre-#3751 format, and every session in
flight when this ships has a ledger full of them. Reading unknown as
EXPIRED would make all of those sessions re-announce every rule they had
already been told, at once — the exact noise this prevents, delivered by
the feature on the day it ships. Unknown means "not measured", never
"old", the same discipline the nullable retrieval_logs columns use.

GUARDS (tests/test_rule_ledger_ageing.py, real shell, no credentials)

- old ages out AND recent survives, in ONE assertion (rule 167): either
  half alone passes against a broken helper — "old is gone" passes
  against one returning nothing, "recent survives" passes against the
  flat read this replaces, i.e. against the defect itself.
- the ping-pong case, which is the one that costs the most to get wrong.
- a bare id is live, including beside a stale stamped one.
- missing/empty ledger excludes nothing.
- an id repeated in the ledger appears once, with no empty list element.
- structural: neither hook reads the rule ledger flat again — pinned on
  the flat-read SHAPE, so a rename of the helper is not a failure and a
  hook that ages correctly some other way is not either.

The TTL's VALUE is deliberately not asserted. The tests read the constant
out of the shell and assert the property around it, so a later tuning
change stays a tuning change instead of a red build.

Dropped a boundary test (`ttl` vs `ttl + 1`) before committing: racy by
construction, since a ledger written at T is read at T+n and the two
cases swap. A one-second distinction on a 45-minute window is also not
observable behaviour, so it pinned a flake rather than a property.

Plugin version minted to 2026.09.10.0221 — this is entirely hook-side,
so without the bump the cache would never pick it up and the merge would
ship nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
2026-09-09 22:21:32 -04:00
co-authored by Claude Opus 5
parent c4908f093f
commit c1aa1d8e92
5 changed files with 333 additions and 11 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "2026.09.09.0408",
"version": "2026.09.10.0221",
"author": {
"name": "Bryan Van Deusen"
},
+103
View File
@@ -16,6 +16,9 @@
# scribe_reached STATE SID the server answered: the next outage speaks again
# scribe_config sets `url` + `token` from the env, returns 0
# only if BOTH are usable (#2278)
# scribe_rules_live FILE live rule ids from the exclusion ledger,
# comma-joined; entries age out (#3751)
# scribe_rules_append FILE stdin ids -> the ledger, timestamped
#
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
@@ -175,3 +178,103 @@ scribe_unreached() {
scribe_reached() {
rm -f "$1/$2.unreached" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# THE RULE EXCLUSION LEDGER, and how entries in it AGE (#3751).
#
# Two hooks write this file and two read it, which is the whole reason the
# parsing lives here: `scribe_prior_art.sh` and `scribe_tool_rules.sh` share
# one ledger so a rule named by one arm is not re-offered by the other, and a
# format only one of them understood would break that on the first read.
#
# WHAT PROBLEM AGEING SOLVES. #3749 clears the ledger when an EVENT destroys
# context — a compaction or a /clear. This is the case with no event at all: a
# long session where the rule was named two hundred turns ago and has simply
# fallen out of attention. It is #3702's argument at the tier level (present in
# context and salient at the moment are different properties) applied to time
# instead of to tier.
#
# PER-ENTRY TIMESTAMPS, NOT A FILE MTIME. Clearing the whole ledger when the
# file is old is one line of shell and wrong in exactly the session that needs
# it: a single recent write keeps every stale id alive, and the ids that go
# stale first are the ones from the rules that fire most.
#
# WALL TIME, NOT A TURN COUNT, and the trade is real rather than dismissed. A
# turn count is a truer model of salience — an idle session does not forget —
# but a hook has no turn number without keeping its own counter, which is a
# second piece of session state to write, read, clear on compaction and get
# wrong. Wall time is available from `date` and costs nothing. The failure mode
# it accepts is a session left idle over lunch treating its rules as forgotten,
# which produces one extra full line per rule and no other harm.
_SCRIBE_RULE_TTL=2700
# 45 MINUTES, and the reasoning rather than the number (rule 32).
#
# There is no data on this yet, so it is a judgement made to be revised — the
# telemetry that would settle it is the one #3807 just built, and a reading of
# how often an aged-out rule gets PULLED after it returns is what should move
# this.
#
# Too short and the exclusion stops existing and the repetition it prevents
# comes back. Too long and it never fires at all in a session short enough to
# matter. 45 minutes is about one working stretch on a single task: long enough
# that a rule does not re-announce itself while you are still doing the thing
# it governs, short enough that a multi-hour session gets a genuine refresh
# rather than one 9am mention.
#
# Being wrong on the short side is now the cheaper error, which is why this
# leans short. Since #3750 an excluded rule is REFERENCED rather than withheld,
# so the ledger is no longer the only thing standing between a session and a
# rule it has forgotten — an expired entry costs one full line instead of one
# short one, and the exclusion re-arms the moment it is spent.
# Live ids from a ledger, comma-joined for `exclude_rule_ids`. Empty output for
# a missing, empty or fully-aged file — the callers already treat "" as "send
# no exclusions".
#
# THE LAST ENTRY FOR AN ID WINS, and this is what stops a rule ping-ponging.
# The file is append-only, so a rule that ages out, gets surfaced fresh and is
# appended again has TWO lines. Reading the first would leave it permanently
# expired and it would re-announce itself on every single call from then on —
# the loudest possible failure, from the mechanism meant to quieten things.
# Appends are chronological, so the last line for an id is its most recent.
#
# A BARE ID — no tab, no timestamp — IS LIVE. That is the pre-#3751 format, and
# a session in flight when this ships has a ledger full of them. Treating
# unknown as expired would make every one of those sessions re-announce every
# rule it had already been told, all at once, which is precisely the noise this
# exists to prevent. Unknown means "not measured" and never "old" — the same
# null discipline the retrieval_logs columns use. Those entries simply never
# age, which is bounded: the session ends.
scribe_rules_live() {
local f="$1" now
[ -n "$f" ] && [ -f "$f" ] || return 0
now=$(date +%s 2>/dev/null) || now=0
awk -F'\t' -v now="$now" -v ttl="$_SCRIBE_RULE_TTL" '
{
id = $1
gsub(/[^0-9]/, "", id)
if (id == "") next
if (!(id in seen)) { seen[id] = 1; seq[++n] = id }
stamp[id] = ($2 ~ /^[0-9]+$/) ? $2 : ""
}
END {
out = ""
for (i = 1; i <= n; i++) {
id = seq[i]
if (stamp[id] != "" && now > 0 && (now - stamp[id]) > ttl) continue
out = out (out == "" ? "" : ",") id
}
print out
}
' "$f" 2>/dev/null || true
}
# Append surfaced ids, stamped. Reads ids on stdin, one per line — the shape
# `jq -r '(.rule_ids // [])[]?'` already produces at both call sites.
scribe_rules_append() {
local f="$1" now
[ -n "$f" ] || return 0
now=$(date +%s 2>/dev/null) || now=0
awk -v ts="$now" 'NF { print $1 "\t" ts }' >> "$f" 2>/dev/null || true
}
+7 -5
View File
@@ -204,10 +204,11 @@ if [ -n "$session_id" ]; then
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
if [ -f "$rulefile" ]; then
rule_seen=$(tr '\n' ',' < "$rulefile" 2>/dev/null | sed 's/,$//')
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
fi
# Ageing, not a flat read (#3751), and the ONLY ledger here that ages: the
# note channels above are a different question with a different answer, and
# this arm's sibling hook reads the same rule file through the same helper.
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
fi
# Not `|| exit 0`: an unreachable instance must not discard a local finding
@@ -239,7 +240,8 @@ if [ -n "$body" ]; then
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
fi
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null >> "$rulefile" || true
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null \
| scribe_rules_append "$rulefile"
fi
if [ -n "$derivefile" ]; then
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
+6 -5
View File
@@ -86,10 +86,10 @@ rule_exclude_q=""
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rulefile="$state_dir/${safe_sid}.rules.ids"
if [ -f "$rulefile" ]; then
rule_seen=$(tr '\n' ',' < "$rulefile" 2>/dev/null | sed 's/,$//')
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
fi
# Ageing, not a flat read (#3751): an id named two hours ago is not one the
# session is still holding. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
fi
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
@@ -104,7 +104,8 @@ context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
# Remember what was named so it is not repeated this session.
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null >> "$rulefile" || true
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null \
| scribe_rules_append "$rulefile"
fi
jq -cn --arg ctx "$context" '{