#!/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 → "kindname" per definition # scribe_local_dups ROOT REL "kindname" 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 # # 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 } # --------------------------------------------------------------------------- # kindname 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. hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null | head -4) || hits="" [ -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 }