fix(plugin): an early-exiting head no longer voids its own output under pipefail (#4042)
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>
This commit is contained in:
2026-09-14 18:54:49 -04:00
co-authored by Claude Opus 5
parent c7531d3700
commit 0fab08276c
6 changed files with 85 additions and 6 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.14.2237",
"version": "2026.09.14.2254",
"author": {
"name": "Bryan Van Deusen"
},
+2 -1
View File
@@ -138,7 +138,8 @@ while IFS= read -r rel_path; do
code=$(cat "$file_path" 2>/dev/null) || code=""
fi
[ -n "$code" ] || continue
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
# `|| true` inside: an early-exiting `head` must not void its own output (#4042).
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
# nothing to say; the arms are about shapes.
[ -n "$names" ] || continue
+6 -1
View File
@@ -114,7 +114,12 @@ scribe_local_dups() {
*) 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.
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null | head -4) || hits=""
# `|| 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")
+4 -3
View File
@@ -75,7 +75,8 @@ fi
# 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=""
# `|| true` inside: an early-exiting `head` must not void its own output (#4042).
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
fi
local_lines=""
@@ -105,11 +106,11 @@ 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=""
| 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" | tac | scribe_defs | head -1) || shapes=""
shapes=$(head -n "$ln" "$file_path" | tac | scribe_defs | head -1 || true)
fi
fi
fi