Files
FabledScribe/plugin/hooks/scribe_tool_rules.sh
T
bvandeusenandClaude Opus 5 91bc0fb01e
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped
feat(plugin): a high-confidence rule is put in front of a command, not beside its result (#4214)
Milestone 419 step 3, the pre-act checkpoint. Every rule surface in this
plugin returns `additionalContext`, which Claude Code delivers alongside the
tool RESULT — so the rule is read after the call is written and lands as
commentary on a decision already made. That is the milestone's central
finding, measured over a session with seven misses, three caught by the
operator and none by this system.

The action arm can now return a `deny` instead. The act does not run, the
rule's text can be read before the call exists, and the remedy is one
`get_rule` call after which the act may be re-submitted unchanged. Nothing
reaches the operator: a deny is a message to the model.

"CONSEQUENTIAL" IS DERIVED, NOT ENUMERATED. The obvious implementation lists
act kinds — a write to product code, a schema change, a bulk classification, a
merge. Every one of those is consequential because THIS operator wrote rules
about it, and shipping that list is this instance's corpus hard-coded into the
product (rule 115). So the corpus decides: an act is consequential when the
install's own rules speak to it above the checkpoint bar. A fresh install with
no rules never stops anything.

FOUR CONDITIONS, EACH PREVENTING A DIFFERENT WRONG. Above the bar; a rule and
never a preference (which claims no such force); the band's top hit only (the
ranker's confidence claim attaches to its first element); and only a rule the
session has NOT opened — `held` is observable from the get_rule PostToolUse
hook (#4100), not self-report.

WHY "NOT OPENED" RATHER THAN "NO OUTCOME RECORDED". An outcome can be
satisfied with one cheap call asserting compliance without producing any, and
a checkpoint dismissible that way manufactures exactly the compliance data
step 2 was built to measure. Reading a rule cannot be faked in that direction:
after `get_rule` the statement is in context, which is the whole of what was
wanted.

THE BAR IS MEASURED. `retrieval_telemetry(days=30)`: write_path_rule p90
0.7628 max 0.8817; pre_tool_rule p90 0.7373 max 0.8293. 0.80 is above p90 on
both and below max on both, so it selects from the top decile of an already
selective arm and is still reachable. It ships as a setting with a Settings
card, because a cosine distance in one model's geometry over one corpus cannot
transfer.

TWO GUARDS ON THE WORST CASE: at most one hold per rule and five per session,
so a mis-set floor degrades to a noisy session rather than one that cannot
proceed. The ledger lives in the swept directory and is named `.ids`, so the
existing compaction-clear guards cover it.

WRITES ARE NOT HELD, AND THAT IS THE OPERATOR'S DECISION RATHER THAN MINE.
`scribe_prior_art.sh` carries a tested property that it never returns a
permissionDecision — a recall aid may not stand in the way of a write. Three
of the milestone's seven misses were file edits and none are reachable from
the command side, so there is a live argument for extending this; that
argument is exactly why the boundary is now asserted by a test rather than
left to memory. The write-path arm computes and returns the same block so the
decision can be revisited with evidence; the hook ignores it, and a change of
mind is a hook edit rather than a feature.

Verified by lifting `checkpoint_for` and `_rule_band` out of source with `ast`
and exercising the shipped functions over 17 populations, by running the
ledger and deny envelope in bash (10 cases, including that a refused hold is
not written and that a garbled rule id fails closed), and by
scripts/check_plugin.py — which caught the unminted plugin version, 0300 ->
0426.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-21 00:26:54 -04:00

148 lines
7.3 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 preload instead, back when there was one.
#
# 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 curl >/dev/null 2>&1 || exit 0
# Sourced FIRST, because the JSON reader below lives there (#4107). It defines
# functions and clears `url`/`token`; nothing here runs before it is needed.
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
tool_name=$(scribe_json_pick "$event_flat" '.tool_name')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.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=$(scribe_json_pick "$event_flat" '.tool_input.command')
[ -n "$command_text" ] || command_text=$(scribe_json_pick "$event_flat" '.tool_input.url')
[ -n "$command_text" ] || command_text=$(scribe_json_pick "$event_flat" '.tool_input.prompt')
[ -n "$command_text" ] || exit 0
# 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)
# Whole, never line by line: the predecessor (`jq -rR`) encoded a multi-line
# command one line at a time and joined them with raw newlines — an invalid URL.
# scribe_urlenc reads bytes and has no notion of a line.
cmd_enc=$(printf '%s' "$command_text" | scribe_urlenc)
tool_enc=$(printf '%s' "$tool_name" | scribe_urlenc)
[ -n "$cmd_enc" ] && [ -n "$tool_enc" ] || exit 0
repo_q=""
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
scope=$(scribe_scope_query "$lookup_dir")
[ -n "$scope" ] && repo_q="&${scope}"
# 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}"
# 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
# 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
body_flat=$(printf '%s' "$body" | scribe_json_flat)
context=$(scribe_json_pick "$body_flat" '.context')
# Remember what was named so it is not repeated this session. BEFORE the
# checkpoint branch and before the empty-context exit: the ledger records what
# the server chose to surface, which happened whichever way this hook then
# renders it. Doing it inside one branch is how the two arms' ledgers came to
# disagree once already.
if [ -n "$rulefile" ]; then
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
fi
# ── The pre-act checkpoint (#4214, milestone 419) ────────────────────────
#
# WHY THIS ARM AND NOT THE WRITE PATH. scribe_prior_art.sh carries an explicit,
# tested property that it never returns a permissionDecision — a recall aid may
# not stand in the way of a write, which is the operator's decision and is
# guarded by test_hook_never_returns_a_permission_decision. No equivalent
# decision covers this arm, and the misses that motivated the milestone on the
# command side are the ones a stop actually reaches: a commit message asserting
# what CI said, a verification script that checks nothing.
#
# WHAT THE STOP IS FOR. Everything else this plugin emits is additionalContext,
# which Claude Code delivers alongside the tool RESULT — so the rule is read
# after the call is written and reads as commentary on a decision already made.
# That is milestone 419's central finding. A deny returns the reason to the
# model with the call unrun, so the rule's own text can be read before the act
# exists. It costs the operator nothing: no prompt reaches them, and the model
# clears it by reading one record and re-submitting.
#
# It cannot recur. `scribe_checkpoint_allowed` holds at most one act per rule
# and at most five per session, so the worst case of a mis-set floor is a noisy
# session rather than one that cannot move.
cp_rule=$(scribe_json_pick "$body_flat" '.checkpoint.rule_id')
cp_reason=$(scribe_json_pick "$body_flat" '.checkpoint.reason')
if [ -n "$cp_rule" ] && [ -n "$cp_reason" ] && [ -n "$session_id" ]; then
if scribe_checkpoint_allowed "$state_dir/${safe_sid}.checkpoint.ids" "$cp_rule"; then
scribe_json_deny PreToolUse "$cp_reason"
exit 0
fi
fi
[ -n "$context" ] || exit 0
scribe_json_out PreToolUse "$context"
exit 0