Files
FabledScribe/plugin/hooks/scribe_tool_rules.sh
T
bvandeusenandClaude Opus 5 c1aa1d8e92
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
feat(plugin): rule exclusions age, so salience decays without a context event (#3751)
#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
2026-09-09 22:21:32 -04:00

118 lines
5.2 KiB
Bash

#!/usr/bin/env bash
# Scribe — PreToolUse rule arm for ACTIONS (#3476).
#
# The sibling of scribe_prior_art.sh. That hook is registered on Write|Edit and
# asks "what is recorded about the file being written". This one asks "does a
# standing rule speak to the command about to be run" — the question nothing
# could ask before, and the reason every rule about which tool to reach for had
# to live in the always-on preload instead.
#
# WHY A HOOK AND NOT AN INSTRUCTION. A reflex generates no query (note #3089):
# you reach for `curl` confidently, with no moment of doubt, so a surface that
# waits to be asked never fires. Here nothing is asked — the tool call IS the
# query, and the reflex has to become a tool call before it can do anything.
#
# SILENT ON OUTAGE, deliberately, unlike the prior-art hook. A write is
# occasional; a Bash call is not, and an "instance did not answer" line before
# every command is the noise that gets a channel muted. scribe_prior_art.sh
# still speaks for both when the instance is down.
#
# Env:
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
event=$(cat 2>/dev/null || true)
tool_name=$(printf '%s' "$event" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
[ -n "$tool_name" ] || exit 0
# The action, as text. `.command` is Bash's field; the fallbacks let the matcher
# in hooks.json widen to other tools without this script changing — which is the
# whole reason the server side takes a name and a string rather than a schema.
command_text=$(printf '%s' "$event" | jq -r '
.tool_input.command //
.tool_input.url //
.tool_input.prompt //
empty' 2>/dev/null) || command_text=""
[ -n "$command_text" ] || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# scribe_config, not a hand-rolled pair of parameter expansions: it also treats
# an UNEXPANDED `${...}` placeholder as unset, which would otherwise be sent as
# a garbage Bearer token and 401 on every call (#2198's class).
scribe_config || exit 0
# Bounded before encoding: a heredoc or a pasted script can be enormous, and
# the verb and its target — the part a rule is about — sit at the front. The
# server bounds it again; this keeps a huge payload off the wire in the first
# place. `head -c`, never `cut -c`: cut truncates each LINE and caps nothing.
command_text=$(printf '%s' "$command_text" | head -c 2000)
# -sRr, never -rR: jq -R without -s reads LINE BY LINE, so a multi-line command
# would encode per line and join with raw newlines — an invalid URL.
cmd_enc=$(printf '%s' "$command_text" | jq -sRr '@uri' 2>/dev/null) || exit 0
tool_enc=$(printf '%s' "$tool_name" | jq -sRr '@uri' 2>/dev/null) || exit 0
repo_q=""
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
repo_remote=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
if [ -n "$repo_remote" ]; then
repo_enc=$(printf '%s' "$repo_remote" | jq -sRr '@uri' 2>/dev/null) || repo_enc=""
[ -n "$repo_enc" ] && repo_q="&repo=${repo_enc}"
fi
# THE SHARED SESSION LEDGER, and the thing most worth getting right here.
#
# scribe_prior_art.sh keeps the rules it has already named in
# <state>/<sid>.rules.ids and passes them as exclude_rule_ids. This hook reads
# and appends to that SAME file rather than keeping its own: two ledgers would
# mean a rule named by one arm gets re-offered by the other, and the hint that
# fires most often is exactly the one that must not repeat itself.
#
# The directory keeps the prior-art name on purpose — 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
rulefile=""
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"
# 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
# finding would be discarded, and an outage line before every command is worse
# than silence. See the header.
body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/tool-rules?tool=${tool_enc}&command=${cmd_enc}${repo_q}${rule_exclude_q}" 2>/dev/null) || exit 0
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
[ -n "$context" ] || 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 \
| scribe_rules_append "$rulefile"
fi
jq -cn --arg ctx "$context" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: $ctx
}
}' 2>/dev/null || true
exit 0