feat(plugin): after-write hook — PostToolUse on Bash diffs the working tree and runs the prior-art + ledger arms on what was just written; shared scribe_defs.sh; plugin 0.1.39 (#2901, milestone 299 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 40s
CI & Build / Build & push image (push) Skipped

Edits made through sed/heredocs/scripts never reached the PreToolUse
Write|Edit hook, so a whole class of writes got no prior-art hint, no
ledger feed and no duplicate-family warning. scribe_after_write.sh asks git
what changed since it last looked (per-session path+blob snapshot; first
call = files touched in the last minute), extracts the definitions in the
added lines and calls /api/plugin/prior-art with the same three dedup
channels the pre hook keeps. Never blocks; silent on any failure. The
extractor, the prose/data skip list and the local by-name arm move to
scribe_defs.sh, sourced by both hooks. Version bump covers the step-2 hook
change too (run 4239 failed only on the bump check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 13:35:45 -04:00
co-authored by Claude Fable 5
parent 2324c15418
commit 5925335ca0
7 changed files with 503 additions and 96 deletions
+10 -95
View File
@@ -51,14 +51,12 @@ 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=""
# 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.
case "$file_path" in
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
exit 0 ;;
esac
# 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
@@ -73,78 +71,8 @@ if [ -n "$repo_root" ]; then
esac
fi
# ---------------------------------------------------------------------------
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
#
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
# of the codebase, so a helper nobody thought to record is invisible to them —
# which is how `.btn-primary` came to be defined four times, in four scoped
# stylesheets, already diverged. It was never a snippet, so no threshold and no
# query rewrite could ever have surfaced it.
#
# This arm closes that by asking the only question the record cannot answer,
# in the only place that can: the hook already runs on the developer's machine,
# inside the repo, holding the code about to be written. No index, no storage,
# no staleness, and no server — it deliberately 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 detector was born covering
# only the languages of the repo it was written in, which silently amputated
# this whole arm — and the record nudge gated on it — for every Go/Kotlin/Rust
# project. Definitions are announced by a small keyword family across
# languages (func/fun/fn/function/def/sub · class/struct/trait/interface/
# enum/object/protocol/type), so one modifier-strip + keyword match covers
# them all. Known out of scope: keyword-less declaration syntax (C/Java/Dart
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
# because several per type is normal Rust, not duplication.
# ---------------------------------------------------------------------------
# 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)
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
# 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=""
@@ -152,21 +80,8 @@ fi
local_lines=""
if [ -n "$repo_root" ] && [ -n "$names" ]; then
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, which would
# otherwise always match itself on an Edit.
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 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/ $//')
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
done <<< "$names"
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
[ -n "$local_lines" ] && local_lines="${local_lines}"$'\n'
fi
local_context=""