Files
FabledScribe/plugin/hooks/scribe_defs.sh
T
bvandeusenandClaude Opus 5 c61f7301bc
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m4s
CI & Build / Build & push image (push) Skipped
fix(plugin): a compaction clears every session ledger, not the two on the list (#4101)
`scribe_session_context.sh` cleared `.rules.ids` and `.opened.ids` by name and
left `.ids`, `.sync.ids` and `.derive.ids` standing, under a comment asserting
that was a decision. Reading the note arms says it was not: their exclusions go
straight into `semantic_search_notes`, so a surfaced note leaves the result set
rather than being rendered as a reference the way #3750 gave a repeated rule,
and unlike the rules ledger they never age. Hard, permanent, never cleared — a
note surfaced in a session's first minute is unreachable for the rest of it,
which is milestone 386's own defect alive on the arms that fire most often.

The list was the bug, so the fix is not a longer list. `scribe_clear_session_
ledgers` matches the naming convention instead — a per-session ledger is
`<sid>[.<kind>].ids` — which covers all five and covers the sixth on the day it
is written. `<sid>.unreached` is deliberately outside it: that records an
outage, not held context, and #2932 needs it to survive.

tests/test_session_ledger_clear.py runs the hook rather than grepping it for
`rm -f`, since grepping for the names is the pattern being removed. It pins
both directions — `compact`/`clear` take all five, `startup`/`resume` take
none — plus the convention the glob rests on, checked against the hooks
themselves so a ledger named outside it fails loudly instead of silently never
clearing.

Also drops a stale comment pointing at a rules-etag marker that milestone 394
retired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-16 21:00:42 -04:00

461 lines
23 KiB
Bash

#!/usr/bin/env bash
# shellcheck shell=bash
# Scribe plugin — the pieces the hooks share (#2901, #2278).
#
# scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh
# fires AFTER a Bash tool call and diffs the working tree, so code written by
# sed/heredocs/scripts gets the same prior-art and ledger checks. Both need the
# same three things, kept here so they cannot drift apart:
#
# scribe_skip_path PATH formats that hold prose or data, not shapes
# scribe_defs stdin code → "kind<TAB>name" per definition
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
# local-duplicate lines (ARM 1, #2280)
# scribe_unreached STATE SID SECS REL the "Scribe didn't answer" line, once
# per outage (#2932) — or nothing, if said lately
# scribe_reached STATE SID the server answered: the next outage speaks again
# scribe_config sets `url` + `token` from the env, returns 0
# only if BOTH are usable (#2278)
# scribe_rules_live FILE live rule ids from the exclusion ledger,
# comma-joined; entries age out (#3751)
# scribe_rules_append FILE stdin ids -> the ledger, timestamped
# scribe_scope_query DIR `project_id=N` or `repo=<enc>` for DIR — the
# project-scope key EVERY hook sends (#4085).
# Helpers: scribe_marker_file, scribe_url_host,
# scribe_marker_read (id<TAB>why-not),
# scribe_marker_project (the id alone)
#
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
# Skip formats that hold prose or data rather than reusable code. Purely to
# avoid a pointless round-trip — the server would return nothing for these
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
# often exactly the thing worth reusing.
scribe_skip_path() {
case "$1" in
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
return 0 ;;
esac
return 1
}
# ---------------------------------------------------------------------------
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
# program, two consumers: the local duplicate arm (every definition in the
# payload) and the ledger feed (#2791, below: the definitions being written,
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
# sees, so the two must agree on what counts as a definition.
scribe_defs() {
awk '
{
# CSS class definition: .name { or .name,
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
if (t != "") print "css\t" t; next
}
line = $0; sub(/^[[:space:]]+/, "", line)
# Strip leading declaration modifiers so the definition keyword is the
# first word regardless of language (export/pub/private/suspend/...).
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
# Go method with receiver: func (r *T) Name(
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
sub(/[^A-Za-z0-9_].*$/, "", t)
if (t != "") print "sym\t" t; next
}
# Keyword-announced definitions, functions and named types alike.
# Dunders are skipped: every class defines __init__, so "already defined
# in N other files" is guaranteed noise for them — and noise is what
# teaches sessions to skip the hint.
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
# `type` defines only when something follows the name (= or {); an
# import specifier `type Foo,` is the same two words and defines
# nothing (mirror of coverage.py, #2904).
if (line ~ /^type[[:space:]]/) {
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
if (rest !~ /[={]/) next
}
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
}
# Arrow/expression assignment: const name = (…) / let name = async (
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
if (t != "") print "sym\t" t; next
}
}
' 2>/dev/null
}
# ---------------------------------------------------------------------------
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
#
# The recorded arms ask Scribe what was RECORDED; the ledger arm (#2900) asks
# what a BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody
# bound, is invisible to both — which is how `.btn-primary` came to be defined
# four times, already diverged. This arm asks the one question only the
# developer's machine can answer, inside the repo, holding the code about to
# be written: no index, no storage, no server — it runs even on an install
# that has never configured Scribe.
#
# Definition-shaped patterns only. Grepping for bare occurrences would match
# every CALL site and drown the real finding — and a hint that is mostly noise
# is one people learn to skip, which is worse than none. ALL code, not a
# language shortlist (#2682): the same keyword family scribe_defs announces.
#
# $1 repo root, $2 repo-relative path of the file being written (excluded from
# the grep — it would always match itself on an Edit). Definitions on stdin.
# Prints one "> - `name` is already defined in N other file(s): …" per hit.
scribe_local_dups() {
local root="$1" rel="$2" kind name pat hits count label files
while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue
case "$kind" in
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
esac
# -I skips binaries; :(exclude) drops the file being written.
# `|| true` INSIDE the substitution, not `|| hits=""` outside it (#4042):
# under the hooks' `pipefail`, `head` exiting after four lines kills a
# git grep that is still writing, the pipeline reports SIGPIPE, and an
# outer fallback then wipes the four hits head already printed. The name
# most duplicated — the one this arm exists for — was the one it dropped.
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null | head -4 || true)
[ -n "$hits" ] || continue
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
printf '> - `%s` is already defined in %s other file(s): %s\n' "$label" "$count" "$files"
done
}
# ---------------------------------------------------------------------------
# The blind spot made visible (#2932). Both write-path hooks fail OPEN when the
# instance is slow or down — right for noise, wrong for silence: a session
# cannot tell "the ledger checked and found nothing" from "the ledger never
# answered", and a self-surfacing system cannot afford an invisible miss (the
# first write after a redeploy lost its derive line to a 4s cold start and
# nobody knew). So a failed call says so — ONCE per outage: the marker holds
# the time it last spoke; within ten minutes of that it stays quiet, and a
# successful call clears it so the next outage announces itself afresh.
# Unconfigured installs never reach this: no URL/token means no call was owed.
# Where every hook gets its endpoint and credential. Four lines, and each of
# the five hooks carried its own copy until #2278 — which is exactly the
# missing-sibling shape: the `${...}` guard below is a correctness detail a
# sixth hook would have forgotten, and nothing would have failed loudly.
#
# Sets `url` and `token` as globals rather than echoing them: a token must not
# pass through a subshell's output, where it could land in a log or an `xtrace`
# line. Returns 0 only when both are usable, so a caller can either bail
# (`scribe_config || exit 0`) or carry on degraded — the session-context hook
# still owes its static floor when Scribe is unconfigured.
# Declared here, not just assigned inside the function: `scribe_defs.sh` owns
# these two names, and a sourcing hook should have them defined the moment it
# sources — before any code path that might reference them. It also lets
# the linter see the assignment, which it cannot follow into a function in
# another file without -x (SC2154).
url=""
token=""
scribe_config() {
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# An unexpanded `${...}` placeholder arriving as a literal would be sent as a
# garbage Bearer token and 401. Treat it as unset.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
[ -n "$url" ] && [ -n "$token" ]
}
_SCRIBE_UNREACHED_QUIET=600
scribe_unreached() {
local marker="$1/$2.unreached" now last
now=$(date +%s 2>/dev/null) || now=0
if [ -f "$marker" ]; then
last=$(cat "$marker" 2>/dev/null) || last=0
case "$last" in ''|*[!0-9]*) last=0 ;; esac
[ $((now - last)) -lt "$_SCRIBE_UNREACHED_QUIET" ] && return 0
fi
printf '%s' "$now" > "$marker" 2>/dev/null || true
printf '> Scribe did not answer the prior-art check for `%s` within %ss — this write went UNCHECKED against the record and the shape ledger (the local by-name arm, if it spoke above, needed no server). If the name matters, check it yourself: `search` for the concept, `list_shapes(project_id, path=…)` for the ledger. Said once per outage; if it keeps happening the instance is slow or down.' "$4" "$3"
}
scribe_reached() {
rm -f "$1/$2.unreached" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# THE RULE EXCLUSION LEDGER, and how entries in it AGE (#3751).
#
# Two hooks write this file and two read it, which is the whole reason the
# parsing lives here: `scribe_prior_art.sh` and `scribe_tool_rules.sh` share
# one ledger so a rule named by one arm is not re-offered by the other, and a
# format only one of them understood would break that on the first read.
#
# WHAT PROBLEM AGEING SOLVES. #3749 clears the ledger when an EVENT destroys
# context — a compaction or a /clear. This is the case with no event at all: a
# long session where the 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.
#
# PER-ENTRY TIMESTAMPS, NOT A FILE MTIME. Clearing the whole ledger when the
# file is old is one line of shell and wrong in exactly the session that needs
# it: a single recent write keeps every stale id alive, and the ids that go
# stale first are the ones from the rules that fire most.
#
# WALL TIME, NOT A TURN COUNT, and the trade is real rather than dismissed. A
# turn count is a truer model of salience — an idle session does not forget —
# but a hook has no turn number without keeping its own counter, which is a
# second piece of session state to write, read, clear on compaction and get
# wrong. Wall time is available from `date` and costs nothing. The failure mode
# it accepts is a session left idle over lunch treating its rules as forgotten,
# which produces one extra full line per rule and no other harm.
_SCRIBE_RULE_TTL=2700
# 45 MINUTES, and the reasoning rather than the number (rule 32).
#
# There is no data on this yet, so it is a judgement made to be revised — the
# telemetry that would settle it is the one #3807 just built, and a reading of
# how often an aged-out rule gets PULLED after it returns is what should move
# this.
#
# Too short and the exclusion stops existing and the repetition it prevents
# comes back. Too long and it never fires at all in a session short enough to
# matter. 45 minutes is 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 genuine refresh
# rather than one 9am mention.
#
# Being wrong on the short side is now the cheaper error, which is why this
# leans short. Since #3750 an excluded rule is REFERENCED rather than withheld,
# so the ledger is no longer the only thing standing between a session and a
# rule it has forgotten — an expired entry costs one full line instead of one
# short one, and the exclusion re-arms the moment it is spent.
# Live ids from a ledger, comma-joined for `exclude_rule_ids`. Empty output for
# a missing, empty or fully-aged file — the callers already treat "" as "send
# no exclusions".
#
# THE LAST ENTRY FOR AN ID WINS, and this is what stops a rule ping-ponging.
# The file is append-only, so a rule that ages out, gets surfaced fresh and is
# appended again has TWO lines. Reading the first would leave it permanently
# expired and it would re-announce itself on every single call from then on —
# the loudest possible failure, from the mechanism meant to quieten things.
# Appends are chronological, so the last line for an id is its most recent.
#
# A BARE ID — no tab, no timestamp — IS LIVE. That is the pre-#3751 format, and
# a session in flight when this ships has a ledger full of them. Treating
# unknown as expired would make every one of those sessions re-announce every
# rule it had already been told, all at once, which is precisely the noise this
# exists to prevent. Unknown means "not measured" and never "old" — the same
# null discipline the retrieval_logs columns use. Those entries simply never
# age, which is bounded: the session ends.
scribe_rules_live() {
local f="$1" now
[ -n "$f" ] && [ -f "$f" ] || return 0
now=$(date +%s 2>/dev/null) || now=0
awk -F'\t' -v now="$now" -v ttl="$_SCRIBE_RULE_TTL" '
{
id = $1
gsub(/[^0-9]/, "", id)
if (id == "") next
if (!(id in seen)) { seen[id] = 1; seq[++n] = id }
stamp[id] = ($2 ~ /^[0-9]+$/) ? $2 : ""
}
END {
out = ""
for (i = 1; i <= n; i++) {
id = seq[i]
if (stamp[id] != "" && now > 0 && (now - stamp[id]) > ttl) continue
out = out (out == "" ? "" : ",") id
}
print out
}
' "$f" 2>/dev/null || true
}
# Append surfaced ids, stamped. Reads ids on stdin, one per line — the shape
# `jq -r '(.rule_ids // [])[]?'` already produces at both call sites.
scribe_rules_append() {
local f="$1" now
[ -n "$f" ] || return 0
now=$(date +%s 2>/dev/null) || now=0
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
}
# Drop EVERY per-session ledger, matched by convention rather than listed (#4101).
#
# A LIST IS THE BUG. Until now the compact/clear branch named its files one at
# a time, and it named two of the five: `.rules.ids` and `.opened.ids` were
# cleared while `.ids` (notes), `.sync.ids` (shape signals) and `.derive.ids`
# survived. So milestone 386's defect — "a compaction destroys the context but
# not the ledger, so the most applicable records become permanently
# unreachable mid-session" — was fixed for rules and left standing on the note
# and snippet surfaces, which are the ones that fire most often.
#
# Nobody decided that. The list was written when rules were the only ledger
# that mattered and was never revisited when the others arrived, which is what
# a hand-maintained list of "things to remember to clean up" does. Adding three
# more `rm` lines would rebuild the same trap for the sixth ledger.
#
# So the rule is the NAME: a per-session ledger is `<sid>[.<kind>].ids`, and
# everything matching that goes. A new ledger following the convention is
# covered the day it is written, by nobody. One that does not follow it is a
# deliberate exception and has to say so.
#
# Scoped to `.ids` rather than `<sid>.*` so a marker that is REWRITTEN on
# compact rather than discarded can still live in this directory without
# being swept away by a glob that was never told about it.
scribe_clear_session_ledgers() {
local dir="$1" sid="$2"
[ -n "$dir" ] && [ -n "$sid" ] || return 0
rm -f "$dir/$sid"*.ids 2>/dev/null || true
return 0
}
# ---------------------------------------------------------------------------
# WHICH PROJECT IS THIS DIRECTORY'S? (#4085)
#
# Every hook here scopes its request to a project, and until this existed all
# six did it the same single way: `git remote get-url origin`, resolved
# server-side through the repo bindings. That works well inside a bound repo
# and not at all outside one — a session in a plain directory got no project
# scope from ANY hook, silently, because the one key the whole chain turns on
# only exists in a git repo.
#
# The second key is a `.scribe` file in the directory (or above it, the way
# git finds its root), naming the project the work belongs to. It is a
# POINTER, not a copy: an id and enough to check the id means what it says.
# Nothing Scribe should be holding goes in it.
#
# {"instance": "https://scribe.example.com", "project_id": 2,
# "project": "FabledScribe"}
#
# instance WHICH Scribe the id belongs to, and the reason this file is
# not just a number. A project id means nothing on its own: id 2
# is a different project on every instance, so a marker that
# travels — a copied directory, a shared machine, a repo someone
# else clones — would silently scope a session to the wrong
# project. Compared HOST-ONLY against the configured endpoint, so
# http/https and a trailing slash don't cause a false mismatch.
# A mismatch drops the id: no project beats the wrong project.
# project_id the pointer itself. Required.
# project a human label. NOTHING READS IT. It is there so the file
# answers "what is this?" when opened, and so a stale one is
# visible rather than inert.
#
# A bare integer is also accepted (`echo 2 > .scribe`), because it is what a
# person writes by hand and it parses as JSON already. It skips the instance
# check by having nothing to check — deliberate, and the reason the written
# form carries `instance`.
#
# THE MARKER WINS over a git remote. Someone put the file there on purpose;
# a remote is just where the code happens to be pushed. That also makes the
# marker the way to override a binding for one directory.
# Nearest `.scribe` at or above DIR. Walks up to the filesystem root, capped so
# a pathological path cannot spin.
scribe_marker_file() {
local dir="$1" depth=0
[ -n "$dir" ] || return 0
while [ "$depth" -lt 40 ]; do
[ -f "$dir/.scribe" ] && { printf '%s' "$dir/.scribe"; return 0; }
case "$dir" in ""|"/") return 0 ;; esac
dir=$(dirname -- "$dir" 2>/dev/null) || return 0
depth=$((depth + 1))
done
return 0
}
# Host of a URL, lowercased, port kept. "" for empty input.
scribe_url_host() {
printf '%s' "${1:-}" \
| sed -e 's#^[A-Za-z][A-Za-z0-9+.-]*://##' -e 's#^[^/@]*@##' -e 's#[/?].*$##' \
| tr 'A-Z' 'a-z'
}
# Read a marker file: prints "ID<TAB>REASON", at most one of them non-empty.
#
# "7\t" use project 7
# "\tnames no …" a file is there and deliberately NOT used; say why
# "\t" no marker file at all — the ordinary case, say nothing
#
# One line rather than an id plus a global, because every caller reads this
# through `$( )` and a global set inside a command substitution dies with the
# subshell. The caller would then read an unset variable, which under the
# `set -u` these hooks all run with aborts the hook and costs the whole
# session's context — a failure far larger than the message it was fetching.
scribe_marker_read() {
local f="$1" id inst want
[ -n "$f" ] && [ -f "$f" ] || { printf '\t'; return 0; }
command -v jq >/dev/null 2>&1 || { printf '\t'; return 0; }
# A bare integer is valid JSON, so one filter reads both forms.
id=$(jq -r 'if type=="number" then (.|floor|tostring)
elif type=="object" then (.project_id // empty | tostring)
else empty end' "$f" 2>/dev/null) || id=""
case "$id" in ''|*[!0-9]*) id="" ;; esac
if [ -z "$id" ] || [ "$id" = "0" ]; then
printf '\tnames no project_id'
return 0
fi
inst=$(jq -r 'if type=="object" then (.instance // empty) else empty end' "$f" 2>/dev/null) || inst=""
if [ -n "$inst" ]; then
want=$(scribe_url_host "${url:-}")
inst=$(scribe_url_host "$inst")
if [ -n "$want" ] && [ "$inst" != "$want" ]; then
printf '\tpoints at %s, but this session is configured for %s' "$inst" "$want"
return 0
fi
fi
printf '%s\t' "$id"
}
# Just the id a marker names, or "" — the half scribe_scope_query needs.
scribe_marker_project() {
scribe_marker_read "$1" | cut -f1
}
# The query args identifying DIR's project — `project_id=N` or `repo=<enc>` —
# with NO leading `?` or `&`, so each caller keeps its own separator. Empty
# when neither key is available. Requires `url` to be set (scribe_config) for
# the marker's instance check; without it a marker is still honoured, since an
# unconfigured hook is not going to send the request anyway.
scribe_scope_query() {
local dir="$1" id repo enc
id=$(scribe_marker_project "$(scribe_marker_file "$dir")")
if [ -n "$id" ]; then
printf 'project_id=%s' "$id"
return 0
fi
repo=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
[ -n "$repo" ] || return 0
command -v jq >/dev/null 2>&1 || return 0
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && printf 'repo=%s' "$enc"
}