A surfaced rule stops going permanently quiet (#3750, #3751, #3752) #149

Merged
bvandeusen merged 2 commits from dev into main 2026-09-09 22:44:29 -04:00
Owner

Two commits: c4908f0 and c1aa1d8. CI runs 6232 and 6253 green.

Milestone #386 steps 2–4. Step 1 (#3749) already shipped; step 5 (#3753) is live acceptance that only this merge makes possible.

The problem

Both rule arms recorded every rule id they had named in a per-session ledger and excluded it from then on. That is correct only while the session still holds what it was told — and there are two ways it stops holding.

A compaction summarizes the earlier injection away while the id stays on the ledger, leaving the rule absent from context AND unreachable for the rest of the session. #3749 closed that half by clearing the ledger on compact/clear.

The other half has no event at all: a long session where the rule was named two hundred turns ago and has simply fallen out of attention. Both halves land here.

#3750 — a repeat is referenced, not withheld

Both arms now render every hit; exclude_rule_ids decides only the tail. Only one clause of the existing line was ever false on a repeat — the claim that the rule is not in the session's loaded set — so only that clause changes:

…“Reach the forge through its MCP tools” (whenever a forge operation is in play). Read it with get_rule(161) before deciding it does not apply; it is not in this session's loaded set.

…“Reach the forge through its MCP tools” (whenever a forge operation is in play). You saw it earlier this session; pull it with get_rule(161) if you no longer hold it.

The fresh line is byte-identical to what it was. Both tails come from one _rule_hint_line — the arms phrase their heads differently on purpose and everything after must not differ, and #3497's history is that the pre-tool arm inherited a defect by being modelled on its sibling rather than sharing with it.

The budget decision, recorded at RULEHINT_LIMIT. A repeat competes for the single slot on rank alone: nothing is fetched behind it and it never rides alongside as a second line. Promoting a fresh rule past a better-ranked repeat would reinstate the withholding one rank deeper, and a second line is the one thing that limit exists to forbid. Neither branch injects the rule statement, so a reference costs the same ~40 tokens as a first surfacing.

#3752 — what a reference IS in the telemetry: nothing new

Decided before shipping rather than after a number moved, and asserted rather than described. results stays fresh, suppressed stays len(hits) - len(fresh), record_rule_surfaced still counts only what the arm freshly chose.

This was not free. The naive implementation renders repeats by dropping the fresh filter, which takes suppressed_count to zero everywhere — and #3739's near-miss fix identifies repeat-caused zeros by suppressed_count > 0. The contamination corrected on 2026-09-08 would have returned by a different route, in the same field, with the fix still in the code and no longer working.

#3751 — exclusions age

Ledger format becomes id<TAB>epoch, TTL 2700s (45 minutes). Both hooks read it through one helper in scribe_defs.sh: the flat tr '\n' ',' read would now send 156<TAB>1789002860 as an exclude id, so this could not be a per-hook edit.

The last entry for an id wins, which is what stops a rule ping-ponging — the file is append-only, so a rule that ages out and is re-surfaced has two lines, and reading the first would leave it permanently expired and re-announcing itself on every call.

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 them re-announce every rule at once — the exact noise this prevents, delivered on the day it ships.

Deploying

Two channels, and both matter. #3750/#3752 are server-side and need a deploy. #3751 is entirely hook-side and needs the plugin cache to pick up 2026.09.10.0221, which only refreshes on a version-string change — hence the mint.

No migrations.

#3753 is what this unblocks: surface a rule, compact, confirm it returns. CI cannot exercise a compaction.

🤖 Generated with Claude Code

https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ

Two commits: `c4908f0` and `c1aa1d8`. CI runs 6232 and 6253 green. Milestone #386 steps 2–4. Step 1 (#3749) already shipped; step 5 (#3753) is live acceptance that only this merge makes possible. ## The problem Both rule arms recorded every rule id they had named in a per-session ledger and excluded it from then on. That is correct only while the session still **holds** what it was told — and there are two ways it stops holding. A compaction summarizes the earlier injection away while the id stays on the ledger, leaving the rule absent from context AND unreachable for the rest of the session. #3749 closed that half by clearing the ledger on `compact`/`clear`. The other half has no event at all: a long session where the rule was named two hundred turns ago and has simply fallen out of attention. Both halves land here. ## #3750 — a repeat is referenced, not withheld Both arms now render every hit; `exclude_rule_ids` decides only the tail. Only one clause of the existing line was ever false on a repeat — the claim that the rule is not in the session's loaded set — so only that clause changes: > …“Reach the forge through its MCP tools” (whenever a forge operation is in play). **Read it with get_rule(161) before deciding it does not apply; it is not in this session's loaded set.** > …“Reach the forge through its MCP tools” (whenever a forge operation is in play). **You saw it earlier this session; pull it with get_rule(161) if you no longer hold it.** The fresh line is byte-identical to what it was. Both tails come from one `_rule_hint_line` — the arms phrase their heads differently on purpose and everything after must not differ, and #3497's history is that the pre-tool arm inherited a defect by being modelled on its sibling rather than sharing with it. **The budget decision, recorded at `RULEHINT_LIMIT`.** A repeat competes for the single slot on rank alone: nothing is fetched behind it and it never rides alongside as a second line. Promoting a fresh rule past a better-ranked repeat would reinstate the withholding one rank deeper, and a second line is the one thing that limit exists to forbid. Neither branch injects the rule `statement`, so a reference costs the same ~40 tokens as a first surfacing. ## #3752 — what a reference IS in the telemetry: nothing new Decided before shipping rather than after a number moved, and asserted rather than described. `results` stays `fresh`, `suppressed` stays `len(hits) - len(fresh)`, `record_rule_surfaced` still counts only what the arm freshly chose. This was not free. The naive implementation renders repeats by dropping the `fresh` filter, which takes `suppressed_count` to zero everywhere — and #3739's near-miss fix identifies repeat-caused zeros by `suppressed_count > 0`. The contamination corrected on 2026-09-08 would have returned by a different route, in the same field, with the fix still in the code and no longer working. ## #3751 — exclusions age Ledger format becomes `id<TAB>epoch`, TTL 2700s (45 minutes). Both hooks read it through one helper in `scribe_defs.sh`: the flat `tr '\n' ','` read would now send `156<TAB>1789002860` as an exclude id, so this could not be a per-hook edit. **The last entry for an id wins**, which is what stops a rule ping-ponging — the file is append-only, so a rule that ages out and is re-surfaced has two lines, and reading the first would leave it permanently expired and re-announcing itself on every call. **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 them re-announce every rule at once — the exact noise this prevents, delivered on the day it ships. ## Deploying **Two channels, and both matter.** #3750/#3752 are server-side and need a deploy. #3751 is entirely hook-side and needs the plugin cache to pick up **`2026.09.10.0221`**, which only refreshes on a version-string change — hence the mint. **No migrations.** **#3753 is what this unblocks:** surface a rule, compact, confirm it returns. CI cannot exercise a compaction. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
bvandeusen added 2 commits 2026-09-09 22:44:23 -04:00
feat(rules): a repeat is referenced, not withheld (#3750, #3752)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 37s
c4908f093f
Both arms used to drop a hit already on the session's exclusion ledger
and emit nothing. That is correct only while the session still HOLDS
what it was told, and a compaction breaks exactly that: the earlier
injection is summarized away while the id stays on the ledger, leaving
the rule absent from context AND unreachable for the rest of the
session. #3749 closed the compaction half by clearing the ledger; this
closes the ordinary half, where a session simply stops holding a line
it read an hour ago.

Only ONE CLAUSE of the existing line is false on a repeat — the claim
that the rule is not in the session's loaded set — so only that clause
changes. The fresh line is byte-identical to what it was.

Both tails now come from one `_rule_hint_line`. The arms phrase their
heads differently on purpose; everything after must not differ, and
#3497's history is that the pre-tool arm inherited a defect by being
modelled on its sibling rather than sharing with it.

THE BUDGET DECISION, recorded at RULEHINT_LIMIT. A repeat competes for
the single slot on rank alone: nothing is fetched behind it, and it
never rides alongside as a second line. Promoting a fresh rule past a
better-ranked repeat would reinstate the withholding one rank deeper,
and a second line is the one thing the limit exists to forbid. The
consequence is deliberate — a rule that keeps ranking first for a
recurring situation keeps being referenced, and its decay belongs to
exclusion ageing (#3751), not to a first-place rule being demoted for
having won before.

THE TELEMETRY, decided before shipping rather than after a number moved
(#3752): nothing changes. A reference is a RENDERING decision, not a
retrieval outcome. `results` stays `fresh`, `suppressed` stays
len(hits) - len(fresh), and `record_rule_surfaced` still counts only
what the arm freshly chose. This matters more than it reads: the naive
implementation drops the `fresh` filter and takes suppressed_count to
zero everywhere — and #3739's near-miss fix identifies repeat-caused
zeros by `suppressed_count > 0`, so the contamination corrected on
2026-09-08 would return by a different route, in the same field, with
the fix still in the code and no longer working. A test asserts the
counters as unmoved, because "nothing changed" is only worth something
if it is checkable.

Also corrects two comments that outlived #3702 — both arms still
claimed CONDITIONAL ONLY while the module-level note above
RULEHINT_LIMIT said the opposite, in the exact code this change edits.

GUARDS

- an already-held hit produces a rule line at all (the regression),
  asserted on `get_rule(<id>)` rather than a truthy context: the
  write-path arm fills its context from four other sources, so
  truthiness passes under the OLD behaviour and pins nothing there.
- the two tails are distinguishable, each excludes the other, and
  neither injects the rule statement — the budget claim, both branches.
- the counters are unmoved, per #3752.
- test_a_rule_the_session_already_holds_is_not_re_offered asserted the
  old contract (`"161" not in context`). Repinned rather than deleted:
  the telemetry half of what it protected still holds.

Repinned the #3497 log-placement guard on structure (rule 167). It
read `body.index("if not fresh:")` — a local variable NAME, not the
property. This change renames that guard to `if not hits:`, so the
old assertion would have raised ValueError and reported #3497 as
back while the arm was entirely correct. Now walks the AST for the
first early return after the search and asserts the call row is
written before it. Falsified both ways: it fails on the #3497
mutation, and it refuses to pass when no early return exists at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
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
c1aa1d8e92
#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
bvandeusen merged commit d8017f9b96 into main 2026-09-09 22:44:29 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/FabledScribe#149