CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.
Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.
The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
17 KiB
Bash
Executable File
352 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Grimoire 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 Grimoire
|
|
# 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. 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. 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: it names the
|
|
# definitions being written (`shapes=`), and the server — only when the
|
|
# session has PULLED a snippet this code references or resembles — records
|
|
# it as a PROPOSAL on them ("looks like #N"), never a status.
|
|
# The agent's own end-of-turn judgment is the verdict; the same names go to
|
|
# the written-shapes ledger the Stop hook asks about.
|
|
#
|
|
# 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 (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 curl >/dev/null 2>&1 || exit 0
|
|
|
|
# Shared with the after-write hook: 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. Sourced FIRST because the JSON reader is there
|
|
# too.
|
|
# shellcheck source=plugin/hooks/scribe_defs.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
|
|
|
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
|
|
# One parse, five fields. `.tool_input.content` on a Write is the entire file
|
|
# being written, so parsing per field would mean reading it five times.
|
|
event=$(cat 2>/dev/null || true)
|
|
event_flat=$(printf '%s' "$event" | scribe_json_flat)
|
|
file_path=$(scribe_json_pick "$event_flat" '.tool_input.file_path')
|
|
session_id=$(scribe_json_pick "$event_flat" '.session_id')
|
|
event_cwd=$(scribe_json_pick "$event_flat" '.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=$(scribe_json_pick "$event_flat" '.tool_input.content')
|
|
[ -n "$code" ] || code=$(scribe_json_pick "$event_flat" '.tool_input.file_content')
|
|
[ -n "$code" ] || code=$(scribe_json_pick "$event_flat" '.tool_input.new_string')
|
|
[ -n "$code" ] || code=$(scribe_json_pick "$event_flat" '.tool_input.new_str')
|
|
|
|
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: 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
|
|
# `|| true` inside: an early-exiting `head` must not void its own output.
|
|
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
|
|
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. 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 records it as a proposal;
|
|
# with no pulled canon in play, nothing is recorded. Titles only still — this sends
|
|
# names, not bodies.
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# THE ENCLOSING DEFINITION IS THE LAST ONE AT OR ABOVE THE EDIT: extract
|
|
# forward and take the tail. No `tac`, which is GNU-only and absent on macOS,
|
|
# and no `head -1`, whose early exit is a SIGPIPE trap under pipefail.
|
|
shapes="$names"
|
|
if [ -z "$shapes" ] && [ -f "$file_path" ]; then
|
|
old_first=$(scribe_json_pick "$event_flat" '.tool_input.old_string')
|
|
[ -n "$old_first" ] || old_first=$(scribe_json_pick "$event_flat" '.tool_input.old_str')
|
|
old_first=$(printf '%s' "$old_first" | grep -m1 -v '^[[:space:]]*$' || true)
|
|
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" | scribe_defs | tail -1 || true)
|
|
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}' \
|
|
| scribe_urlenc) || 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
|
|
scribe_json_out PreToolUse "$local_context"
|
|
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)
|
|
|
|
# Encoded whole, never line by line: a payload encoded per line comes back as
|
|
# separately-encoded lines joined by raw newlines, an invalid URL, and code is
|
|
# full of newlines. scribe_urlenc reads bytes and has no notion of a line.
|
|
path_enc=$(printf '%s' "$rel_path" | scribe_urlenc)
|
|
[ -n "$path_enc" ] || exit 0
|
|
code_enc=$(printf '%s' "$q" | scribe_urlenc)
|
|
|
|
# Scope to this directory's project — a `.scribe` marker, else the git remote.
|
|
scope=$(scribe_scope_query "$lookup_dir")
|
|
repo_q=""
|
|
[ -n "$scope" ] && repo_q="&${scope}"
|
|
|
|
# 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. 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: 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: 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.
|
|
#
|
|
# A FIFTH: the Systems whose RULINGS were shown in full. SHARED
|
|
# with scribe_tool_rules.sh, like the rule file: an area's rulings shown before
|
|
# a command must not be repeated in full before the next edit there.
|
|
idfile=""
|
|
syncfile=""
|
|
derivefile=""
|
|
rulefile=""
|
|
exclude_q=""
|
|
sync_exclude_q=""
|
|
derive_exclude_q=""
|
|
rule_exclude_q=""
|
|
rulingsfile=""
|
|
rulings_q=""
|
|
if [ -n "$session_id" ]; then
|
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
|
# What this write defined, for the end-of-turn question. A
|
|
# Write onto a path that does not exist yet creates a file, which may be a
|
|
# unit in its own right (see scribe_written_append). Written before any
|
|
# server call: the ask at the end of the turn must not depend on this
|
|
# write's hint having been answered.
|
|
fresh=""
|
|
[ -e "$file_path" ] || fresh="new"
|
|
printf '%s\n' "$shapes" | scribe_written_append "$safe_sid" "$rel_path" "$fresh" "$code"
|
|
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"
|
|
rulingsfile="$state_dir/${safe_sid}.rulings.ids"
|
|
rulings_q=$(scribe_rulings_query "$rulingsfile")
|
|
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/,$//' | scribe_urlenc) || derive_seen=""
|
|
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
|
fi
|
|
# Ageing, not a flat read, 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}"
|
|
# What the session actually OPENED, as against what it was shown.
|
|
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
|
|
fi
|
|
|
|
# Not `|| exit 0`: an unreachable instance must not discard a local finding
|
|
# that needed no instance to produce. And not silence either: 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}${rulings_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=""
|
|
body_flat=""
|
|
if [ -n "$body" ]; then
|
|
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
|
context=$(scribe_json_pick "$body_flat" '.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
|
|
scribe_json_list_minus "$body_flat" '.note_ids' '.sync_note_ids' >> "$idfile" || true
|
|
fi
|
|
if [ -n "$syncfile" ]; then
|
|
scribe_json_list "$body_flat" '.sync_note_ids' >> "$syncfile" || true
|
|
fi
|
|
if [ -n "$rulefile" ]; then
|
|
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
|
|
fi
|
|
if [ -n "$derivefile" ]; then
|
|
scribe_json_list "$body_flat" '.derive_keys' >> "$derivefile" || true
|
|
fi
|
|
if [ -n "$rulingsfile" ]; then
|
|
scribe_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ARM 1½ — the RECORD nudge. The local arm just proved the thing being
|
|
# written already exists elsewhere in this repo, and Grimoire 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: "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=$(scribe_json_len "$body_flat" '.note_ids')
|
|
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
|
|
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Grimoire. 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
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ARM 0 — THE CONTRACT AROUND THE CHANGE.
|
|
#
|
|
# "You altered the arity, name or shape of something — here is everything that
|
|
# reads it." An interface-contract check between a definition and its
|
|
# callers.
|
|
#
|
|
# LOCAL AND SERVERLESS, like ARM 1. It needs the working tree and nothing
|
|
# else — the server has no checkout, so this is the only place the question
|
|
# can be asked at all.
|
|
#
|
|
# EDITS ONLY. The comparison is between what the definition exposed before and
|
|
# what it exposes now, so it needs both texts; a Write that creates a file has
|
|
# no "before" and nothing to break.
|
|
contract_context=""
|
|
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
|
old_code=$(scribe_json_pick "$event_flat" '.tool_input.old_string')
|
|
[ -n "$old_code" ] || old_code=$(scribe_json_pick "$event_flat" '.tool_input.old_str')
|
|
# The SUBJECT is the definition whose contract may have moved. `shapes`
|
|
# already holds either the definitions in the payload or — for an edit
|
|
# inside a function body — the one enclosing the edit, which is exactly the
|
|
# thing whose callers matter. CSS rows are skipped: a class has readers, but
|
|
# they are markup files and `scribe_local_dups` already speaks for those.
|
|
subject=$(printf '%s\n' "$shapes" \
|
|
| awk -F'\t' 'NF>=2 && $1!="css" {print $2; exit}')
|
|
if [ -n "$old_code" ] && [ -n "$subject" ]; then
|
|
contract_file=""
|
|
[ -n "${safe_sid:-}" ] && contract_file="$state_dir/${safe_sid}.contract.ids"
|
|
contract_context=$(scribe_contract_block \
|
|
"$repo_root" "$rel_path" "$subject" "$old_code" "$code" "$contract_file" \
|
|
2>/dev/null) || contract_context=""
|
|
fi
|
|
fi
|
|
|
|
# CONTRACT FIRST of the three, and the order is the strength of the claim.
|
|
# This one says something may already be BROKEN by the edit in hand. The local
|
|
# arm says a copy exists. The recorded arms say something resembles this. A
|
|
# reader who reads one line should read that one.
|
|
combined="$contract_context"
|
|
if [ -n "$local_context" ]; then
|
|
[ -n "$combined" ] && combined="${combined}"$'\n'
|
|
combined="${combined}${local_context}"
|
|
fi
|
|
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.
|
|
scribe_json_out PreToolUse "$combined"
|
|
exit 0
|