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
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:
@@ -53,6 +53,15 @@
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_after_write.sh\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__.*__get_rule",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_record_opened.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
|
||||
@@ -100,6 +100,8 @@ if [ -n "$session_id" ]; then
|
||||
# stops holding what it was told. scribe_rules_live carries the reasoning.
|
||||
rule_seen=$(scribe_rules_live "$rulefile")
|
||||
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}"
|
||||
# What the session actually OPENED, as against what it was shown (#4100).
|
||||
exclude_q="${exclude_q}$(scribe_held_query "$rule_state_dir/${safe_sid}.opened.ids")"
|
||||
fi
|
||||
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
|
||||
@@ -289,6 +289,25 @@ scribe_rules_append() {
|
||||
awk -v ts="$now" 'NF { print $1 "\t" ts }' >> "$f" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# The OPENED ledger's contribution to a rule arm's query string (#4100).
|
||||
#
|
||||
# TWO LEDGERS, BECAUSE THEY RECORD TWO DIFFERENT FACTS. `.rules.ids` holds
|
||||
# every id an arm has NAMED; `.opened.ids` holds the ids the session actually
|
||||
# read, written by scribe_record_opened.sh from the `get_rule` call itself.
|
||||
# Named is not read: the injected line is a teaser, and one skimmed past
|
||||
# leaves nothing behind — least of all across a compaction. Sending both lets
|
||||
# the server tell a reader who opened a rule from one who was only shown it,
|
||||
# instead of telling them both the same untrue thing.
|
||||
#
|
||||
# Same reader as the naming ledger on purpose, so ageing, the last-entry-wins
|
||||
# rule and the bare-id format are defined once and cannot drift apart.
|
||||
scribe_held_query() {
|
||||
local ids
|
||||
ids=$(scribe_rules_live "$1")
|
||||
[ -n "$ids" ] && printf '&held_rule_ids=%s' "$ids"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHICH PROJECT IS THIS DIRECTORY'S? (#4085)
|
||||
#
|
||||
|
||||
@@ -207,6 +207,8 @@ if [ -n "$session_id" ]; then
|
||||
# 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}"
|
||||
# What the session actually OPENED, as against what it was shown (#4100).
|
||||
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
|
||||
fi
|
||||
|
||||
# Not `|| exit 0`: an unreachable instance must not discard a local finding
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe — record that this session OPENED a rule, not merely saw it named (#4100).
|
||||
#
|
||||
# WHAT THIS CLOSES
|
||||
#
|
||||
# The rule arms keep a ledger of every id they have NAMED, and the injected
|
||||
# line used to tell the reader "You saw it earlier this session". That claim
|
||||
# was never checked. The line those arms emit is a TEASER — title, trigger,
|
||||
# `get_rule(N)` — so a session can be named a rule twenty times and never read
|
||||
# one word of it, and after a compaction the teaser is summarised away leaving
|
||||
# nothing at all. The server was asserting something about the reader's
|
||||
# context that it had no way to know.
|
||||
#
|
||||
# This is the observable half. PostToolUse fires for MCP tools (the event's own
|
||||
# output schema carries `updatedMCPToolOutput`, which would be meaningless
|
||||
# otherwise), so the `get_rule` CALL can be watched directly.
|
||||
#
|
||||
# WHY THIS IS NOT THE SELF-REPORT MILESTONE 386 REJECTED
|
||||
#
|
||||
# 386 ruled out asking the session whether it holds a rule, because a model
|
||||
# asked "do you still hold rule 156?" will say yes and the answer is
|
||||
# unverifiable self-report. That objection is about ASKING. This asks nobody:
|
||||
# a tool call happened or it did not, and the harness reports it either way.
|
||||
# Recording what a session DID is a different kind of evidence from believing
|
||||
# what it says about itself.
|
||||
#
|
||||
# WHAT IT DELIBERATELY DOES NOT DO
|
||||
#
|
||||
# It does not prove the rule is still in context — nothing can, and a
|
||||
# compaction can drop it moments later. That is why `.opened.ids` ages exactly
|
||||
# like `.rules.ids` and is cleared on the same events (#3749): both ledgers
|
||||
# describe a context that no longer exists once the context is destroyed. The
|
||||
# claim it supports is only ever "you opened this, pull it again if you no
|
||||
# longer hold it", which stays true in every case and carries its own remedy.
|
||||
#
|
||||
# EXIT 0, ALWAYS. This decorates a ledger; a bookkeeping failure must never
|
||||
# turn a successful tool call into a hook error. Worst case the id is missed
|
||||
# and the reader is offered a rule it already read — the cost of a wrong guess
|
||||
# here is one extra line, in the direction that shows more rather than less.
|
||||
set -uo pipefail
|
||||
|
||||
event=$(cat 2>/dev/null || true)
|
||||
[ -n "$event" ] || exit 0
|
||||
|
||||
# No jq, no ledger — and no complaint. Every other hook degrades the same way
|
||||
# rather than printing a tooling error in front of the operator's work (#4107
|
||||
# tracks making that dependency honest; this is not the place to diverge).
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
|
||||
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
||||
[ -n "$session_id" ] || exit 0
|
||||
|
||||
# The matcher in hooks.json already narrows to the get_rule tools, but the
|
||||
# server segment of an MCP tool name varies with how the plugin was installed,
|
||||
# so the id is read from whichever field is actually present rather than from
|
||||
# an assumed tool name. An event that carries none simply records nothing.
|
||||
rule_id=$(printf '%s' "$event" \
|
||||
| jq -r '(.tool_input.rule_id // empty) | tostring' 2>/dev/null) || rule_id=""
|
||||
rule_id=$(printf '%s' "$rule_id" | tr -cd '0-9')
|
||||
[ -n "$rule_id" ] || exit 0
|
||||
|
||||
# The same directory the naming ledger uses. One session keeps its state in one
|
||||
# place, and the prior-art name is kept for the reason scribe_tool_rules.sh
|
||||
# gives: renaming it would orphan every live session's state for a cosmetic
|
||||
# gain.
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
|
||||
# Stamped and append-only, exactly like the naming ledger — so the same reader
|
||||
# (`scribe_rules_live`) ages both, and the last entry for an id wins.
|
||||
printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.opened.ids"
|
||||
exit 0
|
||||
@@ -105,6 +105,13 @@ case "$source" in
|
||||
# Best-effort, like every other filesystem touch in these hooks: a ledger
|
||||
# that cannot be removed costs a repeated exclusion, never a session.
|
||||
rm -f "${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.rules.ids" 2>/dev/null || true
|
||||
# BOTH ledgers, for one reason (#4100). `.opened.ids` records what the
|
||||
# session read; a compaction is exactly the event that takes it away
|
||||
# again. Clearing the naming ledger while keeping this one would leave
|
||||
# the surfacing arms telling a freshly-summarised session "you opened
|
||||
# it earlier" about a rule that is no longer anywhere in its context —
|
||||
# a more confident version of the claim this milestone removed.
|
||||
rm -f "${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.opened.ids" 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -87,6 +87,8 @@ if [ -n "$session_id" ]; then
|
||||
# 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}"
|
||||
# What the session actually OPENED, as against what it was shown (#4100).
|
||||
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
|
||||
fi
|
||||
|
||||
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
|
||||
|
||||
Reference in New Issue
Block a user