CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 1m34s
CI & Build / Python tests (push) Successful in 2m21s
CI & Build / Build & push image (push) Successful in 25s
DRY pass 3's remainder. Both halves start from enumeration, because the task's
candidate list was hypotheses and the process requires counting before
proposing — and counting changed the answer twice.
## The list_* family: a limit with no offset
Enumerated all 19 `list_*` MCP tools first. They are genuinely heterogeneous —
8 take `project_id`, 6 take `limit`, six take no arguments at all — so a
common-parameter guard would invent a convention the API does not have, which
is the over-DRY trap (§5). One contract IS real: a `limit` without an `offset`
is a truncation with no continuation. The caller is told there are 250 results,
handed 50, and given no way to ask for the rest.
Two tools had it, and both were capped over a service that already accepted an
offset: `snippets_svc.list_snippets(offset=0)` was simply not exposed, and
`list_processes` passed a hardcoded `offset=0` into `query_knowledge`. The
capability existed one layer down in both; only the door was missing — the
missing-sibling shape exactly. Both now expose it.
`tests/test_mcp_list_family.py` guards it, with `list_tags` exempted for a
stated reason (a ranked top-N over a bounded vocabulary has no "rest" to page
into). Candidates derived, decision explicit, same design as test_mcp_auth —
plus the reverse checks: a stale exemption, and an offset with no limit, which
would page through an unbounded result set. Verified non-vacuous by running the
sweep against the pre-fix tree, where it fails naming both tools.
## The verb pairs: no finding, which is the finding
`preview`/`apply` and `dry_run`/`commit` do not exist anywhere in the 102
tools — those were guesses about a shape Scribe never adopted. `count_*` does
not exist either. Of the create/delete stems only `project_rule` lacks a
`delete_X`, and deliberately: a project rule IS a rule, `delete_rule` removes
it, and the docstring says so. `force` sits on 6 of 7 duplicate-gated creates;
the exception is `create_system`, whose gate is an exact normalized-NAME match
rather than a semantic near-match — forcing it would split one area's records
across two piles, which its own message explains. No guard added: it would
need a seven-entry exemption list to defend against a hypothetical. Recorded
on the leave-alone list instead, which the process asks for by name.
## The hook config preamble
Not 3 of 6 hooks as recorded — all FIVE carried their own copy, and of four
lines rather than two. The extra two are a guard treating an unexpanded
`${...}` placeholder as unset, so it is never sent as a garbage Bearer token:
precisely the correctness detail a sixth hook would omit with nothing failing
loudly. Now `scribe_config` in scribe_defs.sh, which also declares the two
names it owns. It sets globals rather than echoing, so a token never passes
through a subshell's output where xtrace or a log could catch it, and returns
a status so a caller can bail (`|| exit 0`) or continue degraded — the
session-context hook still owes its static floor when Scribe is unconfigured.
`check_plugin.py` now runs shellcheck with `-x`. Without it the shared helpers
were invisible: every variable they set read as unassigned and every bug inside
them went unlinted at the call site, which is the opposite of what sharing them
was for. All twelve fail-open scenarios still pass, and all five hooks were
probed live against the instance — prior_art and after_write both still name
canon, autoinject returns context, session_context serves 11k chars of rules,
sync_processes stays silent. Plugin 0.1.46 (#2209).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
241 lines
11 KiB
Bash
241 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
# Scribe plugin — PostToolUse write-path trigger on Bash (#2901).
|
|
#
|
|
# scribe_prior_art.sh fires before a Write/Edit TOOL CALL. Code written any
|
|
# other way — sed, heredocs, python edit scripts, `cat > file` — never reached
|
|
# it, so a whole class of edits (the ones a long session makes most) got no
|
|
# prior-art hint, no ledger feed and no duplicate-family warning. This hook
|
|
# closes that: after EVERY Bash call it asks git what changed in the working
|
|
# tree since it last looked, and runs the same arms on the definitions that
|
|
# were just written — the local by-name duplicate arm, the recorded prior-art
|
|
# arms and the ledger's derive/divergence checks (#2900/#2793), via the same
|
|
# /api/plugin/prior-art endpoint the pre-write hook uses.
|
|
#
|
|
# Post-hoc by a few seconds, in the same moment and the same session: "the
|
|
# copy just landed; here is its family" — not "an audit found it later".
|
|
#
|
|
# Cheap when nothing changed: one `git status`. State per session, beside the
|
|
# pre-write hook's (its three dedup channels are SHARED, so a family named by
|
|
# one hook is not named again by the other):
|
|
# ${TMPDIR:-/tmp}/scribe-afterwrite/<sid>.snap path<TAB>blob-hash of every
|
|
# dirty/untracked file last seen
|
|
# ${TMPDIR:-/tmp}/scribe-priorart/<sid>.* the dedup channels
|
|
#
|
|
# NEVER BLOCKS. It returns `additionalContext` only (no decision — there is
|
|
# nothing left to decide, the write already happened). Any failure —
|
|
# unconfigured, unreachable, not a git repo, malformed — exits 0 in silence.
|
|
#
|
|
# Config (same as the other hooks):
|
|
# 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 git >/dev/null 2>&1 || exit 0
|
|
|
|
# shellcheck source=plugin/hooks/scribe_defs.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
|
|
|
# PostToolUse delivers { session_id, cwd, tool_name, tool_input, tool_response }.
|
|
event=$(cat 2>/dev/null || true)
|
|
tool_name=$(printf '%s' "$event" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
|
|
[ "$tool_name" = "Bash" ] || 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=""
|
|
work_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
|
repo_root=$(git -C "$work_dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
[ -n "$repo_root" ] || exit 0
|
|
|
|
safe_sid=$(printf '%s' "${session_id:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
|
|
snap_dir="${TMPDIR:-/tmp}/scribe-afterwrite"
|
|
mkdir -p "$snap_dir" 2>/dev/null || true
|
|
snap="$snap_dir/${safe_sid}.snap"
|
|
|
|
# What is dirty now: every modified / added / untracked path, with the blob
|
|
# hash of its working-tree content. Hash, not mtime: portable (no stat
|
|
# flags), exact (a touch is not a change), and untracked files hash the same
|
|
# way tracked ones do.
|
|
current=""
|
|
while IFS= read -r line; do
|
|
[ -n "$line" ] || continue
|
|
status=${line:0:2}
|
|
path=${line:3}
|
|
case "$status" in
|
|
D*|*D) continue ;; # a deletion defines nothing
|
|
esac
|
|
case "$path" in
|
|
*" -> "*) path=${path##* -> } ;; # rename: the new name
|
|
esac
|
|
# Porcelain quotes paths with special characters; those are skipped rather
|
|
# than unquoted badly — a filename needing quotes is not where shapes live.
|
|
case "$path" in
|
|
\"*) continue ;;
|
|
esac
|
|
[ -f "$repo_root/$path" ] || continue
|
|
sha=$(git -C "$repo_root" hash-object -- "$path" 2>/dev/null) || continue
|
|
current="${current}${path}"$'\t'"${sha}"$'\n'
|
|
done < <(git -C "$repo_root" status --porcelain --untracked-files=all 2>/dev/null)
|
|
|
|
previous=""
|
|
[ -f "$snap" ] && previous=$(cat "$snap" 2>/dev/null || true)
|
|
first_run=0
|
|
[ -f "$snap" ] || first_run=1
|
|
# Write the new snapshot NOW, before anything can fail below — the next call
|
|
# must compare against this tree, whatever happens to this one's hint.
|
|
printf '%s' "$current" > "$snap" 2>/dev/null || true
|
|
|
|
# Changed = a (path, hash) pair not in the previous snapshot. On the very
|
|
# first call of a session there is no previous snapshot; rather than report
|
|
# every pre-existing dirty file as "just written", take only files touched in
|
|
# the last minute — the Bash call that just ran is the likely author.
|
|
changed=""
|
|
while IFS=$'\t' read -r path sha; do
|
|
[ -n "${path:-}" ] || continue
|
|
if [ "$first_run" = 1 ]; then
|
|
[ -n "$(find "$repo_root/$path" -mmin -1 2>/dev/null)" ] || continue
|
|
else
|
|
case "$previous" in
|
|
*"${path}"$'\t'"${sha}"*) continue ;;
|
|
esac
|
|
fi
|
|
scribe_skip_path "$path" && continue
|
|
changed="${changed}${path}"$'\n'
|
|
done <<< "$current"
|
|
[ -n "$changed" ] || exit 0
|
|
|
|
scribe_config || : # sets url/token; the call below is guarded on them
|
|
repo=$(git -C "$repo_root" 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
|
|
|
|
# The dedup channels are the PRE-write hook's files, on purpose (see header).
|
|
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|
mkdir -p "$state_dir" 2>/dev/null || true
|
|
idfile="$state_dir/${safe_sid}.ids"
|
|
syncfile="$state_dir/${safe_sid}.sync.ids"
|
|
derivefile="$state_dir/${safe_sid}.derive.ids"
|
|
|
|
combined=""
|
|
n_files=0
|
|
while IFS= read -r rel_path; do
|
|
[ -n "${rel_path:-}" ] || continue
|
|
# A Bash call that rewrote many files is a refactor or a generator, not a
|
|
# shape being instantiated; four is enough to name what matters.
|
|
n_files=$((n_files + 1))
|
|
[ "$n_files" -le 4 ] || break
|
|
file_path="$repo_root/$rel_path"
|
|
|
|
# The code just written: the ADDED lines of the uncommitted diff for a
|
|
# tracked file (sed, not cut: this strips one marker char per line, it is
|
|
# not a payload cap), the whole file when untracked.
|
|
if git -C "$repo_root" ls-files --error-unmatch -- "$rel_path" >/dev/null 2>&1; then
|
|
code=$(git -C "$repo_root" diff -U0 -- "$rel_path" 2>/dev/null | grep '^+' | grep -v '^+++' | sed 's/^+//') || code=""
|
|
else
|
|
code=$(cat "$file_path" 2>/dev/null) || code=""
|
|
fi
|
|
[ -n "$code" ] || continue
|
|
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
|
|
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
|
# nothing to say; the arms are about shapes.
|
|
[ -n "$names" ] || continue
|
|
|
|
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
|
local_context=""
|
|
if [ -n "$local_lines" ]; then
|
|
local_context="> Already defined elsewhere in this repo — \`${rel_path}\` (just written) adds another copy; check before keeping it (\`git grep\` shown; a nudge, not a gate):"$'\n'"${local_lines}"
|
|
fi
|
|
|
|
context=""
|
|
body=""
|
|
reached="" # "" unconfigured (no call owed) · 1 answered · 0 did not
|
|
unreached_context=""
|
|
if [ -n "$url" ] && [ -n "$token" ]; then
|
|
q=$(printf '%s' "$code" | head -c 1200)
|
|
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || path_enc=""
|
|
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
|
|
shapes_q=""
|
|
enc=$(printf '%s\n' "$names" \
|
|
| 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}"
|
|
exclude_q=""; sync_exclude_q=""; derive_exclude_q=""
|
|
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
|
|
if [ -n "$path_enc" ]; then
|
|
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
|
|
# gates nothing the session is waiting on, and the first prior-art call
|
|
# after a redeploy is a cold start (embedding warm-up, ~4.6s observed)
|
|
# that a 4s cap turned into a silent fail-open — the one write a
|
|
# session most wants the ledger's word on lost it.
|
|
reached=1
|
|
body=$(curl -fsS --max-time 8 \
|
|
-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}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
|
# A call that was owed and didn't come back is said, once per outage
|
|
# (#2932) — shared marker with the pre-write hook, so one outage is one
|
|
# line however the code was written.
|
|
if [ "$reached" = 1 ]; then
|
|
scribe_reached "$state_dir" "$safe_sid"
|
|
else
|
|
unreached_context=$(scribe_unreached "$state_dir" "$safe_sid" 8 "$rel_path")
|
|
fi
|
|
fi
|
|
if [ -n "$body" ]; then
|
|
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
|
if [ -n "$context" ]; then
|
|
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
|
|
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
|
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
|
|
# Several files in one call may name the same family: keep each
|
|
# token once, so the next request's exclude list stays exact.
|
|
for f in "$idfile" "$syncfile" "$derivefile"; do
|
|
[ -s "$f" ] && { sort -u -o "$f" "$f" 2>/dev/null || true; }
|
|
done
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# The record nudge (#2664), same gate as the pre-write hook: duplication
|
|
# demonstrated locally AND nothing recorded for it — and (#2932) never on a
|
|
# call that did not answer; "nothing recorded" is a claim only an answer
|
|
# can back.
|
|
if [ -n "$local_lines" ] && [ "$reached" != 0 ]; 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 just written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet so the next session is offered it instead of writing another copy."
|
|
fi
|
|
fi
|
|
|
|
part="$local_context"
|
|
if [ -n "$context" ]; then
|
|
[ -n "$part" ] && part="${part}"$'\n'
|
|
part="${part}${context}"
|
|
fi
|
|
if [ -n "$unreached_context" ]; then
|
|
[ -n "$part" ] && part="${part}"$'\n'
|
|
part="${part}${unreached_context}"
|
|
fi
|
|
[ -n "$part" ] || continue
|
|
[ -n "$combined" ] && combined="${combined}"$'\n'
|
|
combined="${combined}${part}"
|
|
done <<< "$changed"
|
|
|
|
[ -n "$combined" ] || exit 0
|
|
jq -n --arg c "$combined" \
|
|
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $c}}'
|
|
exit 0
|