CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 14s
In a repo where 3,000 files define `slug`, scribe_local_dups printed nothing; with 3 files it printed the duplicate line. Every hook runs under `set -uo pipefail`, and `hits=$(git grep -l … | head -4) || hits=""` lost head's four lines whenever git grep was still writing when head exited. That is a SIGPIPE, the substitution fails, and the outer fallback wipes the result. So the by-name duplicate arm went silent for exactly the most-duplicated names. Found while fixing the same trap in the new Stop hook (#4041). - The fallback moves inside the substitution, `$(… | head -N || true)`, at all five sites: scribe_defs.sh (local dups), scribe_prior_art.sh (names, old_first, shapes) and scribe_after_write.sh (names). A real upstream failure still yields empty output. - check_plugin gains a known-bad pattern for the shape, so no hook can bring it back. - Tests: the real function against a 3,000-file repo (past the pipe buffer), and the pattern shown to flag the old shape and pass the fix. Plugin version minted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
286 lines
15 KiB
Bash
286 lines
15 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
|
|
#
|
|
# 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
|
|
}
|