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
#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
286 lines
14 KiB
Bash
Executable File
286 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Scribe plugin — PreToolUse write-path trigger (prior-art recall).
|
|
#
|
|
# Auto-inject (scribe_autoinject.sh) fires on the operator's prompt. The moment
|
|
# reuse is actually lost is later: when the AGENT decides mid-task to write a
|
|
# helper. This hook fires there — on Write/Edit — and asks the operator's Scribe
|
|
# instance what prior art is already recorded for the target file: a snippet at
|
|
# that path or in its directory, plus snippets resembling the code about to be
|
|
# written. Titles + ids only, never bodies.
|
|
#
|
|
# The answer comes in two framings (#2708). A snippet recorded AT the exact
|
|
# file being edited is the SYNC class — "you are editing the recorded file;
|
|
# updating the record is part of the edit" — which is how records stay current
|
|
# on an instance with no forge connection (decision #2707). Everything else is
|
|
# the REUSE menu. The two dedup separately (see the state files below).
|
|
#
|
|
# It is also the shape ledger's write-path feed (#2791): it names the
|
|
# definitions being written (`shapes=`), and the server — only when the
|
|
# session has PULLED a snippet this code references or resembles — records
|
|
# them as instance rows, classified_by=hook. Evidence, not judgment; the
|
|
# context line says what landed so a wrong stamp is corrected in the moment.
|
|
#
|
|
# NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so
|
|
# the write proceeds untouched and Claude sees the note beside the tool result.
|
|
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
|
|
# recall aid must not be able to stop the operator's work.
|
|
#
|
|
# Config (same as the other hooks), exported to the hook by Claude Code with the
|
|
# userConfig key UPPERCASED (see #2198 — the lowercase spelling reads as empty
|
|
# and this hook then exits 0 in silence, looking exactly like "no prior art"):
|
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
|
set -uo pipefail
|
|
|
|
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)
|
|
file_path=$(printf '%s' "$event" | jq -r '.tool_input.file_path // 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 "$file_path" ] || exit 0
|
|
|
|
# The code about to be written. Write and Edit name this field differently, and
|
|
# the names have changed across Claude Code versions — take whichever is present
|
|
# rather than betting on one shape.
|
|
code=$(printf '%s' "$event" | jq -r '
|
|
.tool_input.content // .tool_input.file_content //
|
|
.tool_input.new_string // .tool_input.new_str // empty' 2>/dev/null) || code=""
|
|
|
|
# Shared with the after-write hook (#2901): the prose/data skip list, the
|
|
# definition extractor and the local by-name duplicate arm live in
|
|
# scribe_defs.sh so the two hooks cannot drift apart.
|
|
# shellcheck source=plugin/hooks/scribe_defs.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
|
scribe_skip_path "$file_path" && exit 0
|
|
|
|
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
|
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
|
# because the local arm below needs the repo root and needs no server at all.
|
|
lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true)
|
|
[ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
|
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
|
|
rel_path="$file_path"
|
|
if [ -n "$repo_root" ]; then
|
|
case "$file_path" in
|
|
"$repo_root"/*) rel_path="${file_path#"$repo_root"/}" ;;
|
|
esac
|
|
fi
|
|
|
|
# ARM 1 — BY NAME, LOCALLY (#2280): does a definition of this already exist
|
|
# in the repo? (scribe_local_dups in scribe_defs.sh carries the why.)
|
|
names=""
|
|
if [ -n "$code" ]; then
|
|
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
|
|
fi
|
|
|
|
local_lines=""
|
|
if [ -n "$repo_root" ] && [ -n "$names" ]; then
|
|
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
|
[ -n "$local_lines" ] && local_lines="${local_lines}"$'\n'
|
|
fi
|
|
|
|
local_context=""
|
|
if [ -n "$local_lines" ]; then
|
|
local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# THE LEDGER FEED (#2791). The server keeps a shape ledger — every definition
|
|
# in the bound repo, classified against recorded canon — and this hook is the
|
|
# one place that sees a shape AT THE MOMENT IT IS WRITTEN. So it names the
|
|
# shapes in play: every definition in the payload, or — for an Edit that
|
|
# changes the inside of a function rather than its signature — the definition
|
|
# enclosing the edit, found by walking the target file upward from the edited
|
|
# lines. The server decides whether evidence exists (the session pulled a
|
|
# snippet this code references or resembles) and stamps instance rows; with
|
|
# no pulled canon in play, nothing is recorded. Titles only still — this sends
|
|
# names, not bodies.
|
|
# ---------------------------------------------------------------------------
|
|
shapes="$names"
|
|
if [ -z "$shapes" ] && [ -f "$file_path" ] && command -v tac >/dev/null 2>&1; then
|
|
old_first=$(printf '%s' "$event" \
|
|
| jq -r '.tool_input.old_string // .tool_input.old_str // empty' 2>/dev/null \
|
|
| grep -m1 -v '^[[:space:]]*$') || old_first=""
|
|
if [ -n "$old_first" ]; then
|
|
ln=$(grep -nF -m1 -- "$old_first" "$file_path" 2>/dev/null | cut -d: -f1) || ln=""
|
|
if [ -n "$ln" ]; then
|
|
shapes=$(head -n "$ln" "$file_path" | tac | scribe_defs | head -1) || shapes=""
|
|
fi
|
|
fi
|
|
fi
|
|
shapes_q=""
|
|
if [ -n "$shapes" ]; then
|
|
enc=$(printf '%s\n' "$shapes" \
|
|
| awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \
|
|
| jq -sRr '@uri' 2>/dev/null) || enc=""
|
|
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
|
|
fi
|
|
|
|
scribe_config || : # sets url/token; unconfigured is handled just below
|
|
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
|
|
# arm above already ran and may have something to say.
|
|
if [ -z "$url" ] || [ -z "$token" ]; then
|
|
if [ -n "$local_context" ]; then
|
|
jq -n --arg c "$local_context" \
|
|
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
# Cap the code sent as the semantic query. The embedder truncates at its own
|
|
# token limit well before this, so a bigger slice buys no extra signal — and the
|
|
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
|
|
# plugin hook works with a read key).
|
|
# `head -c`, not `cut -c1-1200`: cut is line-oriented and caps each line
|
|
# separately, so a 400-line edit sailed past the "1200 char" budget entirely and
|
|
# built a URL from the whole payload. head -c caps the total, which is the point.
|
|
q=$(printf '%s' "$code" | head -c 1200)
|
|
|
|
# `-sRr`, not `-rR`: jq -R reads input LINE BY LINE, so a multi-line payload came
|
|
# back as several separately-encoded lines joined by raw newlines — an invalid
|
|
# URL that made curl fail, and this hook then exited 0 in silence. -s slurps the
|
|
# whole input into one string first. Newlines are exactly what code contains, so
|
|
# this hook could never have worked without it (issue #2198 / #2082).
|
|
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
|
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
|
|
|
|
# Resolve the working repo's remote so the server can scope to the bound project.
|
|
repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
|
|
repo_q=""
|
|
if [ -n "$repo" ]; then
|
|
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
|
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
|
fi
|
|
|
|
# Per-session dedup, in its own file rather than sharing auto-inject's. Each
|
|
# surface shows a given snippet at most once per session, but they don't silence
|
|
# each other: a title that flew past in a prompt menu twenty turns ago is
|
|
# exactly what should reappear at the moment the duplicate is being written.
|
|
#
|
|
# TWO channels, not one (#2708). The server answers in two classes — REUSE
|
|
# ("something similar/nearby is recorded") and SYNC ("a snippet records the
|
|
# exact file being edited — updating the record is part of the edit"). They
|
|
# dedup separately: a reuse hint shown early in the session must not suppress
|
|
# the sync nudge when the recorded file itself is edited later.
|
|
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|
mkdir -p "$state_dir" 2>/dev/null || true
|
|
#
|
|
# A THIRD channel (#2900): the ledger's derive arm names a duplicate family
|
|
# (a derive group id) or a canon elsewhere (`canon:<snippet_id>`) for the
|
|
# shapes being written. Keyed by that token, not a note id, so it dedups on
|
|
# its own file and a family is named once per session, not at every edit.
|
|
#
|
|
# A FOURTH channel (milestone 307): standing RULES the write resembles. Its own
|
|
# file for the same reason as the others — a rule named once should not be
|
|
# re-offered on every subsequent write in the session.
|
|
idfile=""
|
|
syncfile=""
|
|
derivefile=""
|
|
rulefile=""
|
|
exclude_q=""
|
|
sync_exclude_q=""
|
|
derive_exclude_q=""
|
|
rule_exclude_q=""
|
|
if [ -n "$session_id" ]; then
|
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
|
idfile="$state_dir/${safe_sid}.ids"
|
|
syncfile="$state_dir/${safe_sid}.sync.ids"
|
|
derivefile="$state_dir/${safe_sid}.derive.ids"
|
|
rulefile="$state_dir/${safe_sid}.rules.ids"
|
|
if [ -f "$idfile" ]; then
|
|
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
|
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
|
fi
|
|
if [ -f "$syncfile" ]; then
|
|
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
|
|
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
|
|
fi
|
|
if [ -f "$derivefile" ]; then
|
|
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
|
|
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
|
fi
|
|
# Ageing, not a flat read (#3751), and the ONLY ledger here that ages: the
|
|
# note channels above are a different question with a different answer, and
|
|
# this arm's sibling hook reads the same rule file through the same helper.
|
|
rule_seen=$(scribe_rules_live "$rulefile")
|
|
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
|
|
fi
|
|
|
|
# Not `|| exit 0`: an unreachable instance must not discard a local finding
|
|
# that needed no instance to produce. And not silence either (#2932): a call
|
|
# that was owed and didn't come back is said, once per outage, so the session
|
|
# knows this write went unchecked.
|
|
reached=1
|
|
body=$(curl -fsS --max-time 5 \
|
|
-H "Authorization: Bearer ${token}" \
|
|
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${rule_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
|
unreached_context=""
|
|
if [ "$reached" = 1 ]; then
|
|
scribe_reached "$state_dir" "${safe_sid:-nosession}"
|
|
else
|
|
unreached_context=$(scribe_unreached "$state_dir" "${safe_sid:-nosession}" 5 "$rel_path")
|
|
fi
|
|
|
|
context=""
|
|
if [ -n "$body" ]; then
|
|
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
|
# Remember what was surfaced so it isn't shown again this session — each
|
|
# class into its own channel: sync ids (snippets recording the edited file)
|
|
# to the sync file, everything else to the reuse file.
|
|
if [ -n "$context" ]; then
|
|
if [ -n "$idfile" ]; then
|
|
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
|
|
fi
|
|
if [ -n "$syncfile" ]; then
|
|
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
|
fi
|
|
if [ -n "$rulefile" ]; then
|
|
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null \
|
|
| scribe_rules_append "$rulefile"
|
|
fi
|
|
if [ -n "$derivefile" ]; then
|
|
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ARM 1½ — the RECORD nudge (#2664). The local arm just proved the thing being
|
|
# written already exists elsewhere in this repo, and Scribe returned no record
|
|
# of anything for it. That is the one moment "record it" is earned rather than
|
|
# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so
|
|
# an ordinary new helper (no other copies) and an already-recorded one (the
|
|
# server spoke) stay nudge-free — a reflex that fires on everything is one
|
|
# that gets skipped. A server that did not ANSWER earns no nudge (#2932): "none
|
|
# of those copies is recorded" is a claim only an answer can back — the
|
|
# unreached line says what actually happened instead.
|
|
if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
|
|
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
|
|
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
|
|
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version being written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet (name, code, when-to-reach-for-it, location) so the next session is offered it instead of writing another copy."
|
|
fi
|
|
fi
|
|
|
|
# Local first. It answers "this already EXISTS", which is a stronger claim than
|
|
# "this resembles something recorded" — and it is the one the recorded arms are
|
|
# structurally unable to make.
|
|
combined="$local_context"
|
|
if [ -n "$context" ]; then
|
|
[ -n "$combined" ] && combined="${combined}"$'\n'
|
|
combined="${combined}${context}"
|
|
fi
|
|
if [ -n "$unreached_context" ]; then
|
|
[ -n "$combined" ] && combined="${combined}"$'\n'
|
|
combined="${combined}${unreached_context}"
|
|
fi
|
|
[ -n "$combined" ] || exit 0
|
|
|
|
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
|
|
jq -n --arg c "$combined" \
|
|
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
|
exit 0
|