feat(retrieval): the ledger records what was OPENED, not merely what was shown (#4100)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped

Milestone 386 made a repeat REFERENCED rather than withheld, and the line it
chose says "You saw it earlier this session". Nothing ever checked that. The
arms emit a TEASER — title, trigger, get_rule(N) — so a session can be shown a
rule twenty times and never read a word of it, and a compaction summarises the
teaser away leaving nothing behind. The server was asserting something about
the reader's context it had no way to know.

Three states now, where there were two:

  never surfaced   "it is not in this session's loaded set"
  named, unopened  "Mentioned earlier this session but not opened — read it…"
  opened           "You opened it earlier this session; pull it… again"

The middle one is the honest one and the one that was missing. It keeps the
full invitation, because a session that skipped a teaser is in nearly the
position of one never shown it.

HOW "OPENED" BECOMES OBSERVABLE. A new PostToolUse hook watches the get_rule
call itself and appends to `<sid>.opened.ids`. PostToolUse does fire for MCP
tools — the event's own output schema carries `updatedMCPToolOutput`, which
would be meaningless otherwise — and the matcher is `mcp__.*__get_rule` so the
server segment, which varies by install, is not pinned.

This is NOT the self-report 386 rejected. That objection was to ASKING a model
whether it holds a rule, which is unverifiable. A tool call is an event the
harness reports whether anyone asks. Recording what a session DID and believing
what it SAYS about itself are different kinds of evidence.

Both ledgers clear together on compact/clear. Keeping `.opened.ids` across a
compaction would have the arms telling a freshly-summarised session "you opened
it earlier" about a rule now nowhere in its context — a more confident version
of the bug being removed. Same reader (scribe_rules_live) for both, so ageing,
last-entry-wins and the bare-id format are defined once.

Also closes two smoke-coverage holes the checker was reporting as SKIP: the new
recorder, and scribe_precompact_preserve.sh from #3680. The latter needed
STATIC_FLOOR to become a set — PreCompact's contract is inverted, its stdout
BECOMES the summarizer's instructions, so silence is its failure mode and a
generic read of it looks like a leak.

Step 2 of milestone 416, and a hard prerequisite for step 4: while suppression
keys on shown, widening k marks records "seen" faster than they are read, and
the ledger would degrade in proportion to the improvement.

Plugin minted 2026.09.16.1232 -> 2026.09.16.2102.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-16 17:03:39 -04:00
co-authored by Claude Opus 5
parent bb6ab0f2a8
commit ad26b3f458
14 changed files with 406 additions and 23 deletions
+21 -3
View File
@@ -100,6 +100,13 @@ async def autoinject_retrieve():
purpose: one session keeps ONE rule ledger, so a
rule named by any arm is not re-announced by
another. Ages out (#3751), so salience decays.
held_rule_ids — comma-separated rule ids the session actually
(opt) OPENED, observed from the `get_rule` call itself
rather than claimed. A rule on exclude_rule_ids
was NAMED; a rule here was READ, and the two say
different things about what the reader holds —
so they get different lines (#4100). Shares the
ledger directory and the same clear-on-compact.
TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query
but nothing else: the notes menu can be disabled, thresholded and top-k'd
@@ -116,9 +123,10 @@ async def autoinject_retrieve():
project_id, _repo, _unbound = await _project_scope()
exclude_ids = _int_list(request.args.get("exclude_ids"))
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
rules = await plugin_ctx_svc.build_prompt_rule_hint(
g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids
g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids
)
result = await plugin_ctx_svc.build_autoinject_hint(
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
@@ -157,14 +165,20 @@ async def pre_tool_rules():
ledger on purpose: one session keeps one
list, so a rule named by either arm is not
re-offered by the other.
held_rule_ids (opt) — rule ids the session actually OPENED, as
against merely named. Named and read are
different claims about the reader's
context, so they get different lines
(#4100).
"""
tool = (request.args.get("tool") or "tool").strip()
command = request.args.get("command") or ""
project_id, _repo, _unbound = await _project_scope()
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
result = await plugin_ctx_svc.build_tool_rule_hint(
g.user.id, tool, command,
project_id=project_id, exclude_rule_ids=exclude_rule_ids,
project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
)
return jsonify(result)
@@ -202,6 +216,9 @@ async def write_path_prior_art():
above, and for the same reason: a rule named
twenty turns ago should not be re-offered on
every subsequent write.
held_rule_ids (opt) — RULE ids the session actually OPENED, as
against merely named; drives the third
reference wording (#4100).
exclude_derive (opt) — comma-separated derive keys (a derive group id
or `canon:<snippet_id>`) already named this
session by the ledger arm (#2900); its own
@@ -225,6 +242,7 @@ async def write_path_prior_art():
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
]
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
held_rule_ids = _int_list(request.args.get("held_rule_ids"))
shapes = _parse_shapes(request.args.get("shapes") or "")
api_key = getattr(g, "api_key", None)
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
@@ -235,7 +253,7 @@ async def write_path_prior_art():
stamp_shapes=shapes if may_stamp else None,
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
exclude_derive=exclude_derive,
exclude_rule_ids=exclude_rule_ids,
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
)
return jsonify(result)