fix(plugin): the hooks need no jq and no tac (#4107)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped

Every hook opened `command -v jq >/dev/null 2>&1 || exit 0`, so on a machine
without jq the operator got no session context, no rules, no prior art and no
process sync — and not one word saying why, because `exit 0` is
indistinguishable from "ran fine, nothing to say". jq is absent by default on
macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI containers.
That is not a prerequisite to document; it is the plugin handing its own
packaging problem to whoever installs it.

`tac` was worse: GNU-only, so the prior-art hook's enclosing-definition arm
did nothing at all on every Mac, silently, from the day it shipped. It is not
replaced but removed — scribe_defs judges each line independently, so
extracting forward and taking `tail -1` is the same answer as reversing and
taking the head, and it drops the early-exit `head` that #4042 was filed for.

No server contract changed, so a lagging plugin cache keeps working.

  scribe_json.awk   JSON -> IDX<TAB>PATH<TAB>VALUE. Two modes: `whole` for an
                    event or a response body, `lines` for a transcript, where
                    an unparseable record is dropped and the rest still read —
                    the `map(try fromjson catch empty)` the jq program opened
                    with. Arrays also report their LENGTH at `[#]`, which is
                    what keeps "zero notes" distinct from "no answer" (#2932).
  scribe_turn.awk   the turn-bounding program, replacing the thirty lines of
                    jq in the Stop hook.
  scribe_defs.sh    scribe_json_flat / _pick / _list / _len / _list_minus read,
                    scribe_json_out writes the envelope (five copies of one
                    shape, gone), scribe_urlenc replaces `jq -sRr '@uri'`.

Percent-encoding goes through `od -tu1` rather than an awk character loop on
purpose: awk's idea of a character follows the locale, so gawk reads an
accented letter as one and mawk as two, and an encoder built on substr() would
emit a different URL depending on which awk is installed. Encoding is defined
on bytes. Verified byte-identical to `jq -sRr '@uri'`.

Measured, not assumed. The per-event path costs 8ms against jq's 3ms. The
transcript path was 70x slower until two fixes: the Stop hook now finds where
the turn starts with a fixed-string grep before parsing (a needle carrying
unescaped quotes cannot occur inside a JSON string, so it matches only at a
record's top level — checked against a full JSON parse of a 27MB transcript:
152 prompt records, 152 matches, no misses, no extras), and the parser reads
each token out of a 1024-byte window instead of copying the rest of the buffer
per token, which was quadratic in line length on the 400KB tool results a
transcript carries.

Differential-tested against the jq program it replaces over 724 windows cut
from three real transcripts — 724 identical, 0 mismatched, 45 of them
exercising a real task close and a real reply. That sweep is what caught
`scribe_turn.awk` never setting FS, which truncated every multi-word reply at
its first space and was invisible to a test whose replies were all empty.

check_plugin.py's `jq -R` lint becomes a guard against either binary coming
back, and three smoke checks lose their `shutil.which("jq")` skip. jq is not
in `ci-python` either, so those three announced a skip on every CI run and had
never once run there: removing the dependency from the product also closed a
permanent hole in its verification. They pass now across all ten hooks.

tests/test_hook_json_reader.py is a differential against Python's `json` over
nested objects, arrays, unicode, escapes, control characters, empty cases and
a value longer than the token window, plus the envelope, the encoder and the
turn analyzer. 139 cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-20 12:20:45 -04:00
co-authored by Claude Opus 5
parent 97867d47ff
commit a49e7ed2af
22 changed files with 1185 additions and 210 deletions
+16 -15
View File
@@ -31,7 +31,6 @@
# 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
@@ -39,10 +38,11 @@ command -v git >/dev/null 2>&1 || exit 0
# 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
event_flat=$(printf '%s' "$event" | scribe_json_flat)
tool_name=$(scribe_json_pick "$event_flat" '.tool_name')
[ "$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=""
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.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
@@ -153,12 +153,12 @@ while IFS= read -r rel_path; do
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=""
path_enc=$(printf '%s' "$rel_path" | scribe_urlenc) || path_enc=""
code_enc=$(printf '%s' "$q" | scribe_urlenc) || 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=""
| scribe_urlenc) || enc=""
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
exclude_q=""; sync_exclude_q=""; derive_exclude_q=""
if [ -f "$idfile" ]; then
@@ -170,7 +170,7 @@ while IFS= read -r rel_path; do
[ -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=""
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
# The rules marker is gone with the resident set it aged (milestone 394).
@@ -197,12 +197,14 @@ while IFS= read -r rel_path; do
unreached_context=$(scribe_unreached "$state_dir" "$safe_sid" 8 "$rel_path")
fi
fi
body_flat=""
if [ -n "$body" ]; then
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
body_flat=$(printf '%s' "$body" | scribe_json_flat)
context=$(scribe_json_pick "$body_flat" '.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
scribe_json_list_minus "$body_flat" '.note_ids' '.sync_note_ids' >> "$idfile" || true
scribe_json_list "$body_flat" '.sync_note_ids' >> "$syncfile" || true
scribe_json_list "$body_flat" '.derive_keys' >> "$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
@@ -217,7 +219,7 @@ while IFS= read -r rel_path; do
# 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
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 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
@@ -238,6 +240,5 @@ while IFS= read -r rel_path; do
done <<< "$changed"
[ -n "$combined" ] || exit 0
jq -n --arg c "$combined" \
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $c}}'
scribe_json_out PostToolUse "$combined"
exit 0
+19 -13
View File
@@ -38,14 +38,17 @@ set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... }
# Parsed ONCE into flat lines and then queried three times (#4107): a prompt is
# the largest payload any hook reads, and re-parsing it per field is three
# passes over the same text.
event=$(cat 2>/dev/null || true)
prompt=$(printf '%s' "$event" | jq -r '.prompt // empty' 2>/dev/null) || prompt=""
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=""
event_flat=$(printf '%s' "$event" | scribe_json_flat)
prompt=$(scribe_json_pick "$event_flat" '.prompt')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
# Nothing to retrieve against.
[ -n "$prompt" ] || exit 0
@@ -59,11 +62,14 @@ scribe_config || exit 0
# prior-art hook's code cap; this copy was missed when that one was fixed, and
# scripts/check_plugin.py caught it.
q=$(printf '%s' "$prompt" | head -c 2000)
# `-sRr`, not `-rR`: jq -R reads LINE BY LINE, so a multi-line prompt encoded as
# several lines joined by raw newlines and the request died. Single-line prompts
# Encoded whole, never line by line. The predecessor here was `jq -rR`, which
# reads a line at a time: a multi-line prompt came back as several separately
# encoded lines joined by raw newlines and the request died. Single-line prompts
# worked, which is why this looked healthy — the long, substantial prompts most
# worth retrieving against were exactly the ones silently dropped. -s slurps.
q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0
# worth retrieving against were exactly the ones silently dropped. scribe_urlenc
# reads bytes and has no notion of a line.
q_enc=$(printf '%s' "$q" | scribe_urlenc) || exit 0
[ -n "$q_enc" ] || exit 0
# Scope to this directory's project — a `.scribe` marker, else the git remote.
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
@@ -109,22 +115,22 @@ body=$(curl -fsS --max-time 5 \
"${url%/}/api/plugin/retrieve?q=${q_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
[ -n "$body" ] || exit 0
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
body_flat=$(printf '%s' "$body" | scribe_json_flat)
context=$(scribe_json_pick "$body_flat" '.context')
[ -n "$context" ] || exit 0
# Remember the surfaced ids so they aren't injected again this session.
if [ -n "$idfile" ]; then
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
scribe_json_list "$body_flat" '.note_ids' >> "$idfile" || true
fi
# Rules onto the SHARED ledger, stamped so they can age out. Only FRESH ids
# come back in rule_ids (#3752) — a rule rendered as a repeat is already on
# the ledger, and re-appending it would keep pushing its stamp forward so it
# never aged at all.
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '.rule_ids[]? // empty' 2>/dev/null \
scribe_json_list "$body_flat" '.rule_ids' \
| scribe_rules_append "$rulefile"
fi
jq -n --arg c "$context" \
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
scribe_json_out UserPromptSubmit "$context"
exit 0
+224 -10
View File
@@ -19,6 +19,10 @@
# scribe_rules_live FILE live rule ids from the exclusion ledger,
# comma-joined; entries age out (#3751)
# scribe_rules_append FILE stdin ids -> the ledger, timestamped
# scribe_json_flat stdin JSON -> IDX<TAB>PATH<TAB>VALUE lines,
# queried with scribe_json_pick / _list / _len.
# scribe_json_out writes the hook envelope,
# scribe_urlenc percent-encodes. No jq (#4107).
# scribe_scope_query DIR `project_id=N` or `repo=<enc>` for DIR — the
# project-scope key EVERY hook sends (#4085).
# Helpers: scribe_marker_file, scribe_url_host,
@@ -39,6 +43,216 @@ scribe_skip_path() {
return 1
}
# ---------------------------------------------------------------------------
# JSON AND URL-ENCODING, WITHOUT jq (#4107).
#
# Until this section every hook opened `command -v jq >/dev/null 2>&1 || exit 0`
# and a machine without jq got no session context, no rules, no prior art and
# no process sync — silently, because `exit 0` is indistinguishable from "ran
# fine, nothing to say". That is the plugin pushing its own packaging problem
# onto whoever installs it, and it is the same silence #4085 existed to remove.
# jq is absent by default on macOS, on the Debian/Ubuntu slim images, on Alpine
# and in most CI containers. awk, sed, tr and od are POSIX; every one of them
# is already required by code above this line.
#
# scribe_json.awk does the parsing and carries the format. These are the four
# jobs the hooks actually had jq for:
#
# READ scribe_json_flat / _flat_lines turn stdin into `IDX<TAB>PATH<TAB>
# VALUE` lines ONCE, and scribe_json_pick / _list / _len query that
# text. Parse once, query many: a hook reads four fields off one
# event, and re-parsing per field is four passes over a payload that
# may be an entire source file.
# WRITE scribe_json_out emits the hookSpecificOutput envelope, which is the
# one piece of JSON these hooks produce.
# ENCODE scribe_urlenc replaces `jq -sRr '@uri'`, byte-exact for UTF-8.
# DECODE scribe_json_unescape turns a raw JSON string body into text.
_scribe_self=${BASH_SOURCE[0]:-$0}
# shellcheck disable=SC1007 # `CDPATH= cd` scopes one variable to one command
SCRIBE_HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$_scribe_self")" 2>/dev/null && pwd) \
|| SCRIBE_HOOK_DIR=$(dirname -- "$_scribe_self")
# One JSON value on stdin → flat lines. Empty output means it did not parse.
scribe_json_flat() {
awk -f "$SCRIBE_HOOK_DIR/scribe_json.awk" 2>/dev/null || true
}
# JSONL on stdin → flat lines, IDX counting the records that PARSED. A line
# that does not parse is dropped and the rest are still read.
scribe_json_flat_lines() {
awk -v mode=lines -f "$SCRIBE_HOOK_DIR/scribe_json.awk" 2>/dev/null || true
}
# $1 flat text, $2 exact path → the decoded scalar, or "" if absent.
#
# `null` READS AS ABSENT, matching the `// empty` every call site used to carry.
# A field the server left null and one it never sent mean the same thing to
# every caller here, and the shells downstream test `[ -n "$x" ]`.
scribe_json_pick() {
printf '%s\n' "${1:-}" \
| awk -F'\t' -v p="$2" '$2 == p { if ($3 != "null") print $3; exit }' 2>/dev/null \
| scribe_json_unescape
}
# $1 flat text, $2 array path → each element decoded, one per line. Elements
# are ids and keys here, so a value containing a newline is not a case that
# arises; one would split into two lines.
scribe_json_list() {
printf '%s\n' "${1:-}" \
| awk -F'\t' -v p="$2" '
BEGIN { plen = length(p) }
substr($2, 1, plen) == p {
rest = substr($2, plen + 1)
if (rest ~ /^\[[0-9]+\]$/ && $3 != "null") print $3
}' 2>/dev/null \
| scribe_json_unescape
}
# $1 flat text, $2 array path, $3 array path to SUBTRACT → the elements of $2
# that are not in $3, in order. The reuse and sync channels are two classes of
# one answer and each has its own session ledger, so the ids that go in the
# reuse file are precisely `note_ids` minus `sync_note_ids` — jq wrote that as
# `(.note_ids // []) - (.sync_note_ids // [])`.
scribe_json_list_minus() {
local keep
keep=$(scribe_json_list "$1" "$3")
scribe_json_list "$1" "$2" \
| awk -v drop="$keep" '
BEGIN { n = split(drop, a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") s[a[i]] = 1 }
NF && !($0 in s)' 2>/dev/null || true
}
# $1 flat text, $2 array path → its length. "" when the path is not an array,
# which is NOT the same as 0 — see the `[#]` note in scribe_json.awk.
scribe_json_len() {
printf '%s\n' "${1:-}" \
| awk -F'\t' -v p="$2" '$2 == p "[#]" { print $3; exit }' 2>/dev/null
}
# Raw JSON string bodies on stdin → text. One line in, one value out.
scribe_json_unescape() {
awk '
function hex4(h, i, c, d, v) {
v = 0
for (i = 1; i <= 4; i++) {
c = tolower(substr(h, i, 1))
d = index("0123456789abcdef", c) - 1
if (d < 0) return -1
v = v * 16 + d
}
return v
}
{
v = $0
if (index(v, "\\") == 0) { print v; next }
o = ""
i = 1
L = length(v)
while (i <= L) {
c = substr(v, i, 1)
if (c != "\\") { o = o c; i++; continue }
d = substr(v, i + 1, 1)
i += 2
if (d == "n") o = o "\n"
else if (d == "t") o = o "\t"
else if (d == "r") o = o "\r"
else if (d == "b") o = o sprintf("%c", 8)
else if (d == "f") o = o sprintf("%c", 12)
else if (d == "u") {
hi = hex4(substr(v, i, 4))
if (hi < 0) { o = o "\\u"; continue }
i += 4
# A surrogate PAIR is one character written as two escapes; decoding
# the halves separately yields two replacement characters instead.
if (hi >= 55296 && hi <= 56319 && substr(v, i, 2) == "\\u") {
lo = hex4(substr(v, i + 2, 4))
if (lo >= 56320 && lo <= 57343) {
hi = 65536 + (hi - 55296) * 1024 + (lo - 56320)
i += 6
}
}
o = o sprintf("%c", hi)
}
else o = o d
}
print o
}' 2>/dev/null || true
}
# Text on stdin → a JSON string BODY (escaped, no surrounding quotes, one line).
#
# split/join rather than gsub: gsub reads `\` and `&` in its replacement as
# metacharacters, so emitting a literal backslash through it takes a
# quadruple-escape whose meaning varies between awks. Plain concatenation has
# no such reading, and getting this wrong corrupts every injected context that
# happens to contain a Windows path or a regex.
scribe_json_escape() {
awk '
function rep(str, sep, with, parts, cnt, i, o) {
cnt = split(str, parts, sep)
o = parts[1]
for (i = 2; i <= cnt; i++) o = o with parts[i]
return o
}
{
line = $0
line = rep(line, "\\\\", "\\\\")
line = rep(line, "\"", "\\\"")
line = rep(line, "\t", "\\t")
line = rep(line, "\r", "\\r")
# EVERY OTHER CONTROL CHARACTER TOO, or the envelope is not JSON. A raw
# byte below 0x20 inside a string is invalid, and Claude Code discards
# the whole hook output rather than the one field — so a single stray
# character anywhere in an injected context costs the entire injection.
# jq escaped these; a writer that knew only about tab and carriage
# return would have regressed quietly, on the rare input nobody thinks
# to test by hand.
for (c = 1; c < 32; c++) {
if (c == 9 || c == 10 || c == 13) continue
ctl = sprintf("%c", c)
if (index(line, ctl)) line = rep(line, ctl, sprintf("\\u%04x", c))
}
if (NR > 1) printf "\\n"
printf "%s", line
}' 2>/dev/null || true
}
# The one JSON document these hooks WRITE: $1 hook event name, $2 the context.
# Every hook emitted this through `jq -n --arg c`, five copies of one shape.
scribe_json_out() {
local esc
esc=$(printf '%s' "$2" | scribe_json_escape) || return 0
printf '{"hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}\n' \
"$1" "$esc"
}
# stdin → percent-encoded, replacing `jq -sRr '@uri'`.
#
# THROUGH `od`, NOT A CHARACTER LOOP IN awk, and the reason is the bug this
# would otherwise reintroduce. awk's idea of a "character" follows the locale:
# in a UTF-8 locale gawk reads é as one character and mawk as two bytes, so any
# encoder built on substr() produces a different URL on the same input
# depending on which awk is installed. Percent-encoding is defined on BYTES. od
# -tu1 gives bytes, on every platform, and the only characters passed through
# unencoded are the unreserved ASCII set — which every awk agrees about.
#
# Slightly more aggressive than jq's `@uri`, which leaves `!~*'()` alone. Those
# are legal either way and the server decodes both identically.
scribe_urlenc() {
od -An -v -tu1 2>/dev/null \
| awk '{
for (i = 1; i <= NF; i++) {
b = $i + 0
if ((b >= 48 && b <= 57) || (b >= 65 && b <= 90) ||
(b >= 97 && b <= 122) || b == 45 || b == 46 || b == 95 || b == 126)
printf "%s", sprintf("%c", b)
else
printf "%%%02X", b
}
}' 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# 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
@@ -281,7 +495,7 @@ scribe_rules_live() {
}
# Append surfaced ids, stamped. Reads ids on stdin, one per line — the shape
# `jq -r '(.rule_ids // [])[]?'` already produces at both call sites.
# `scribe_json_list "$flat" '.rule_ids'` already produces at both call sites.
scribe_rules_append() {
local f="$1" now
[ -n "$f" ] || return 0
@@ -425,19 +639,20 @@ scribe_url_host() {
# `set -u` these hooks all run with aborts the hook and costs the whole
# session's context — a failure far larger than the message it was fetching.
scribe_marker_read() {
local f="$1" id inst want
local f="$1" flat id inst want
[ -n "$f" ] && [ -f "$f" ] || { printf '\t'; return 0; }
command -v jq >/dev/null 2>&1 || { printf '\t'; return 0; }
# A bare integer is valid JSON, so one filter reads both forms.
id=$(jq -r 'if type=="number" then (.|floor|tostring)
elif type=="object" then (.project_id // empty | tostring)
else empty end' "$f" 2>/dev/null) || id=""
flat=$(scribe_json_flat < "$f") || flat=""
# Both accepted forms, read off one parse: the object's `project_id`, or —
# for the bare integer someone writes by hand — the root scalar, whose path
# is the empty string because it sits under no key.
id=$(scribe_json_pick "$flat" '.project_id')
[ -n "$id" ] || id=$(scribe_json_pick "$flat" '')
case "$id" in ''|*[!0-9]*) id="" ;; esac
if [ -z "$id" ] || [ "$id" = "0" ]; then
printf '\tnames no project_id'
return 0
fi
inst=$(jq -r 'if type=="object" then (.instance // empty) else empty end' "$f" 2>/dev/null) || inst=""
inst=$(scribe_json_pick "$flat" '.instance')
if [ -n "$inst" ]; then
want=$(scribe_url_host "${url:-}")
inst=$(scribe_url_host "$inst")
@@ -468,7 +683,6 @@ scribe_scope_query() {
fi
repo=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
[ -n "$repo" ] || return 0
command -v jq >/dev/null 2>&1 || return 0
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
enc=$(printf '%s' "$repo" | scribe_urlenc) || enc=""
[ -n "$enc" ] && printf 'repo=%s' "$enc"
}
+217
View File
@@ -0,0 +1,217 @@
# Scribe plugin — JSON, read without jq (#4107).
#
# WHY THIS FILE EXISTS. Every hook here parsed JSON with `jq`, and every hook
# opened with `command -v jq >/dev/null 2>&1 || exit 0`. jq is not installed by
# default on macOS, on Debian/Ubuntu base images, on Alpine, or in most CI
# containers — so on a machine without it the operator got no session context,
# no rules, no prior art and no process sync, and not one word saying why. That
# is not a missing prerequisite to document; it is the plugin pushing its own
# packaging problem onto whoever installs it. awk is POSIX: it is present as
# gawk, mawk, nawk or busybox awk everywhere a shell is.
#
# WHAT IT DOES. Reads JSON on stdin, writes one line per SCALAR:
#
# IDX <TAB> PATH <TAB> VALUE
#
# 1 .prompt write the tests first
# 1 .tool_input.file_path /src/app.py
# 1 .note_ids[0] 4107
# 1 .note_ids[#] 3
#
# IDX 1-based index of the top-level JSON value (always 1 unless mode=lines).
# PATH dot/bracket path. Array indices are 0-based.
# VALUE for a string, the RAW JSON-escaped body with the quotes stripped — not
# the decoded text. Decoding here would let a newline or a tab inside a
# value break the line format that makes this readable from shell. The
# caller decodes what it actually uses, with `scribe_json_unescape`.
# Numbers, `true`, `false` and `null` are emitted literally.
#
# EVERY ARRAY ALSO EMITS `PATH[#]` with its LENGTH, including an empty one —
# which is what makes "the server answered with zero notes" distinguishable
# from "the server did not answer", a distinction #2932 built a whole outage
# marker around. An empty object emits `PATH{#}` 0 for the same reason.
#
# TWO MODES, because the two jobs have opposite failure behaviour:
#
# mode=whole (default) the entire input is ONE JSON value, possibly spread
# over many lines. A parse error produces no output at all.
# This is a hook event on stdin, or a server response body.
#
# mode=lines the input is JSONL — one JSON value per line — and a line that
# does not parse is DROPPED, the rest still read. This is the
# transcript in scribe_report_check.sh, where the window starts
# mid-record by construction: `tail -n 3000` cuts wherever it
# cuts, and the first line is routinely half a record. It is
# exactly the `map(try fromjson catch empty)` the jq program it
# replaces opened with. IDX counts lines that PARSED, so the
# ordering a turn is bounded by is unaffected by the dropped one.
#
# A KEY CONTAINING `.` OR `[` WOULD MAKE AN AMBIGUOUS PATH. Nothing in the two
# JSON dialects this reads — Claude Code hook events and Scribe's own API — has
# one, and inventing an escaping scheme for a case neither producer can emit
# would cost every caller a decode for nothing. Stated so the next person hits
# a comment rather than a mystery.
function skipws( c) {
while (pos <= n) {
c = substr(s, pos, 1)
if (c == " " || c == "\t" || c == "\n" || c == "\r") pos++
else return
}
}
# Values are emitted one per line, so a literal control character inside a
# string — invalid JSON, but producers emit it — must not become a line break
# that silently splits one value into two records.
function emit(path, val) {
ocount++
opath[ocount] = path
oval[ocount] = tame(val)
}
function tame(v) {
if (index(v, "\n")) v = rep(v, "\n", "\\n")
if (index(v, "\r")) v = rep(v, "\r", "\\r")
if (index(v, "\t")) v = rep(v, "\t", "\\t")
return v
}
# Replace every occurrence of a literal separator, via split/join rather than
# gsub: gsub's replacement string treats `\` and `&` as metacharacters, so
# emitting a literal backslash through it needs a quadruple-escape whose
# meaning varies by awk. Concatenation has no such reading.
function rep(str, sep, with, parts, cnt, i, o) {
cnt = split(str, parts, sep)
o = parts[1]
for (i = 2; i <= cnt; i++) o = o with parts[i]
return o
}
function parse_value(path, c) {
if (failed) return
skipws()
if (pos > n) { failed = 1; return }
c = substr(s, pos, 1)
if (c == "{") { parse_object(path); return }
if (c == "[") { parse_array(path); return }
if (c == "\"") { emit(path, parse_string()); return }
parse_literal(path)
}
# HOW FAR AHEAD A TOKEN IS READ, and why there is a limit at all.
#
# awk has no way to match a regex STARTING AT AN OFFSET, so reading a token
# means copying the rest of the buffer and anchoring with `^`. Do that per
# token and the cost is quadratic in the length of the line: a transcript
# record carrying a 400KB tool result has a hundred small tokens after it, and
# each one copied the whole 400KB again. Measured: ten such lines, 500KB in
# total, took 2.2s — while 291 ordinary lines totalling 684KB took 0.38s.
#
# So an ordinary token is read out of a WINDOW, and only a token that does not
# fit in one pays for the whole remainder — once, for itself, rather than once
# for every token that follows it. 1024 is where the curve flattens, measured
# over a real 1.19MB turn: 8192 → 800ms, 2048 → 425ms, 1024 → 351ms, 256 →
# 315ms, every one of them producing byte-identical output. Below 1024 the
# gain stops paying for the long values that then miss the window.
#
# A MATCH INSIDE THE WINDOW IS ALWAYS THE TRUE TOKEN, which is what makes this
# safe rather than merely fast. Every quote inside a JSON string is escaped, so
# the `\\.` branch consumes it; an unescaped `"` can only be the real closing
# quote. A window that cuts a string short therefore yields no match at all —
# it cannot yield a WRONG one — and the wide read below handles it.
#
# SET IN `BEGIN`, and it has to be there. A bare assignment at file scope
# is not a statement to awk, it is a PATTERN — a truthy expression with no
# action — so every input record took the default action and was ECHOED to
# stdout. Nothing downstream matched the echoed line, so it cost only noise
# until a test compared this parser's output against Python's and found one
# row too many.
function parse_string( rest, tok) {
rest = substr(s, pos, WINDOW)
if (match(rest, /^"([^"\\]|\\.)*"/) == 0) {
rest = substr(s, pos)
if (match(rest, /^"([^"\\]|\\.)*"/) == 0) { failed = 1; return "" }
}
tok = substr(rest, 2, RLENGTH - 2)
pos += RLENGTH
return tok
}
function parse_literal(path, rest) {
rest = substr(s, pos, WINDOW)
if (match(rest, /^(-?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?|true|false|null)/) == 0) {
failed = 1
return
}
emit(path, substr(rest, 1, RLENGTH))
pos += RLENGTH
}
function parse_object(path, key, c) {
pos++
skipws()
if (substr(s, pos, 1) == "}") { pos++; emit(path "{#}", 0); return }
while (1) {
skipws()
if (substr(s, pos, 1) != "\"") { failed = 1; return }
key = parse_string()
if (failed) return
skipws()
if (substr(s, pos, 1) != ":") { failed = 1; return }
pos++
parse_value(path "." key)
if (failed) return
skipws()
c = substr(s, pos, 1)
if (c == ",") { pos++; continue }
if (c == "}") { pos++; return }
failed = 1
return
}
}
function parse_array(path, i, c) {
pos++
i = 0
skipws()
if (substr(s, pos, 1) == "]") { pos++; emit(path "[#]", 0); return }
while (1) {
parse_value(path "[" i "]")
if (failed) return
i++
skipws()
c = substr(s, pos, 1)
if (c == ",") { pos++; continue }
if (c == "]") { pos++; emit(path "[#]", i); return }
failed = 1
return
}
}
# Parse the buffer in `s` as one value. Emits nothing unless it parses whole.
function run( i) {
n = length(s)
pos = 1
failed = 0
ocount = 0
parse_value("")
if (!failed) { skipws(); if (pos <= n) failed = 1 }
if (failed) { ocount = 0; return 0 }
idx++
for (i = 1; i <= ocount; i++) printf "%d\t%s\t%s\n", idx, opath[i], oval[i]
ocount = 0
return 1
}
BEGIN { idx = 0; WINDOW = 1024 }
mode == "lines" { s = $0; run(); next }
{ buf = buf $0 "\n" }
END {
if (mode == "lines") exit 0
s = buf
run()
}
+52 -37
View File
@@ -33,29 +33,34 @@
# 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
# 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. Sourced FIRST because the
# JSON reader is there too now (#4107).
# 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)
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=""
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=$(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=""
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')
# 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 —
@@ -102,15 +107,24 @@ fi
# 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, and taking it
# needs no `tac`. This read `head -n $ln | tac | scribe_defs | head -1` — reverse
# the lines, extract, take the first. `tac` is GNU-only: it is absent on macOS
# (whose equivalent is `tail -r`), so the guard below it meant this arm did
# nothing at all on every Mac, silently, since the day it shipped. scribe_defs
# judges each line independently, so reversing first and taking the head is the
# same answer as extracting forward and taking the tail — and `tail` also drops
# the `head -1` whose early exit is the SIGPIPE trap #4042 was filed for.
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:]]*$' || true)
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" | tac | scribe_defs | head -1 || true)
shapes=$(head -n "$ln" "$file_path" | scribe_defs | tail -1 || true)
fi
fi
fi
@@ -118,7 +132,7 @@ 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=""
| scribe_urlenc) || enc=""
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
fi
@@ -127,8 +141,7 @@ scribe_config || : # sets url/token; unconfigured is handled just below
# 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}}'
scribe_json_out PreToolUse "$local_context"
fi
exit 0
fi
@@ -142,13 +155,15 @@ fi
# 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=""
# Encoded whole, never line by line. The predecessor (`jq -rR`) read 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. Newlines are exactly what code contains, so this hook
# could never have worked that way (issue #2198 / #2082). 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")
@@ -199,7 +214,7 @@ if [ -n "$session_id" ]; then
[ -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=""
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 (#3751), and the ONLY ledger here that ages: the
@@ -227,24 +242,25 @@ else
fi
context=""
body_flat=""
if [ -n "$body" ]; then
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
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
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
scribe_json_list_minus "$body_flat" '.note_ids' '.sync_note_ids' >> "$idfile" || true
fi
if [ -n "$syncfile" ]; then
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
scribe_json_list "$body_flat" '.sync_note_ids' >> "$syncfile" || true
fi
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null \
| scribe_rules_append "$rulefile"
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
fi
if [ -n "$derivefile" ]; then
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
scribe_json_list "$body_flat" '.derive_keys' >> "$derivefile" || true
fi
fi
fi
@@ -259,7 +275,7 @@ fi
# 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
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 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
@@ -280,6 +296,5 @@ 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}}'
scribe_json_out PreToolUse "$combined"
exit 0
+6 -11
View File
@@ -39,23 +39,21 @@
# here is one extra line, in the direction that shows more rather than less.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
event=$(cat 2>/dev/null || true)
[ -n "$event" ] || exit 0
# No jq, no ledger — and no complaint. Every other hook degrades the same way
# rather than printing a tooling error in front of the operator's work (#4107
# tracks making that dependency honest; this is not the place to diverge).
command -v jq >/dev/null 2>&1 || exit 0
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
event_flat=$(printf '%s' "$event" | scribe_json_flat)
session_id=$(scribe_json_pick "$event_flat" '.session_id')
[ -n "$session_id" ] || exit 0
# The matcher in hooks.json already narrows to the get_rule tools, but the
# server segment of an MCP tool name varies with how the plugin was installed,
# so the id is read from whichever field is actually present rather than from
# an assumed tool name. An event that carries none simply records nothing.
rule_id=$(printf '%s' "$event" \
| jq -r '(.tool_input.rule_id // empty) | tostring' 2>/dev/null) || rule_id=""
rule_id=$(scribe_json_pick "$event_flat" '.tool_input.rule_id')
rule_id=$(printf '%s' "$rule_id" | tr -cd '0-9')
[ -n "$rule_id" ] || exit 0
@@ -67,9 +65,6 @@ state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# Stamped and append-only, exactly like the naming ledger — so the same reader
# (`scribe_rules_live`) ages both, and the last entry for an id wins.
printf '%s\n' "$rule_id" | scribe_rules_append "$state_dir/${safe_sid}.opened.ids"
+64 -45
View File
@@ -50,7 +50,6 @@
# 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
# shellcheck source=plugin/hooks/scribe_defs.sh
@@ -58,11 +57,12 @@ command -v curl >/dev/null 2>&1 || exit 0
# Stop delivers { session_id, transcript_path, cwd, hook_event_name, stop_hook_active }.
event=$(cat 2>/dev/null || true)
transcript=$(printf '%s' "$event" | jq -r '.transcript_path // empty' 2>/dev/null) || exit 0
event_flat=$(printf '%s' "$event" | scribe_json_flat)
transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
[ -n "$transcript" ] && [ -f "$transcript" ] || exit 0
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
active=$(printf '%s' "$event" | jq -r '.stop_hook_active // false' 2>/dev/null) || active="false"
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
session_id=$(scribe_json_pick "$event_flat" '.session_id')
active=$(scribe_json_pick "$event_flat" '.stop_hook_active')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
safe_sid=$(printf '%s' "${session_id:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
state_dir="${TMPDIR:-/tmp}/scribe-reportcheck"
@@ -78,48 +78,67 @@ grep -q -E '"name":[[:space:]]*"([^"]*__)?(update|create)_task"' < <(tail -c 200
exit 0
}
# The turn, parsed once. A window of recent lines, slurped raw and split inside
# jq (a line-by-line `-R` read is the #2198 trap). A first line cut mid-record
# fails to parse and is dropped. If the window holds no prompt, the turn cannot
# be bounded, so the hook reports nothing and stays out of the way.
facts=$(tail -n 3000 "$transcript" 2>/dev/null | jq -sRc '
split("\n") | map(try fromjson catch empty)
| map(select((.isSidechain // false) | not))
| . as $lines
| [range(0; length) | select(
$lines[.].type == "user" and ($lines[.].isMeta // false | not)
and ($lines[.].message.content | type) == "string")] as $prompts
| if ($prompts | length) == 0 then {bounded: false} else
$lines[($prompts | last) + 1:] as $turn
| [ $turn[] | select(.type == "assistant") | .message.content[]?
| select(.type == "tool_use"
and ((.name // "") | test("(^|__)(update|create)_task$"))
and (.input.status? == "done"))
| {id, task: (.input.task_id? // null)} ] as $closes
| [ $turn[] | select(.type == "user") | .message.content[]?
| select(type == "object" and .type == "tool_result" and .is_error == true)
| .tool_use_id ] as $errors
| [ $closes[] | select(.id as $i | ($errors | index($i)) | not) ] as $closed
| ([range(0; $turn | length) | select(
$turn[.].type == "user"
or ($turn[.].type == "assistant"
and ([$turn[.].message.content[]?.type] | index("tool_use"))))]
| last // -1) as $last_act
| {bounded: true,
closed: ($closed | length),
task_ids: [$closed[].task | select(. != null)],
reply: ([ $turn[$last_act + 1:][] | select(.type == "assistant")
| .message.content[]? | select(.type == "text") | .text ] | join("\n"))}
end' 2>/dev/null) || exit 0
# The turn, parsed once. A window of recent lines, flattened record by record
# and then read by scribe_turn.awk, which carries the turn-bounding rules. A
# line that does not parse is dropped and the rest are still read — the first
# line of a `tail -n 3000` window is routinely half a record. If the window
# holds no prompt, the turn cannot be bounded, so the hook reports nothing and
# stays out of the way.
window() { tail -n 3000 "$transcript" 2>/dev/null; }
turn_facts() { window | tail -n +"${1:-1}" | scribe_json_flat_lines \
| awk -f "$SCRIBE_HOOK_DIR/scribe_turn.awk" 2>/dev/null; }
[ "$(printf '%s' "$facts" | jq -r '.bounded // false')" = "true" ] || exit 0
closed=$(printf '%s' "$facts" | jq -r '.closed // 0')
# WHERE THE TURN STARTS, FOUND BEFORE PARSING RATHER THAN AFTER. The window is
# 3000 lines and routinely 7MB, of which a turn is the last few hundred lines
# and about a sixth of the bytes — the rest is tool results this check never
# looks at. The predecessor parsed all of it and threw most away, which jq
# could afford and a parser written in awk cannot: measured at 6.5s for a 7MB
# window against 94ms, on a hook that runs at the end of every turn.
#
# So grep — C, and reading a FIXED string — narrows first. A prompt record is
# `"type":"user"` whose `content` is a STRING; a tool result is the same type
# with an ARRAY, and the two are told apart by the character after `"content":`.
#
# WHY A FIXED STRING IS EXACT HERE, and not the usual regex-over-JSON guess.
# Every quote inside a JSON string is backslash-escaped, so a needle carrying
# UNESCAPED quotes cannot occur inside any string value — it can only match at
# a record's own top level. `"message":{"role":"user","content":"` therefore
# matches real prompt records and nothing else. Measured over a 27MB transcript
# against a full JSON parse: 152 prompt records, 152 matches, no misses and no
# extras. The looser `"content":"` matched 1101 lines, because a tool_result
# block has a `content` key of its own — which is the trap this avoids.
#
# THE LAST MATCH, not a few before it, because the margin is not free: the
# lines between two prompts are mostly tool results, and backing off three
# matches took the window from 53KB to 1.3MB and the parse from 21ms to 2.6s.
# The fallback below is the safety net instead — it is exact where a margin is
# only approximate, and it costs nothing in the case that actually happens.
#
# The needle assumes a key ORDER that a future Claude Code could change. If it
# does, grep matches nothing, `start` stays 1, and the whole window is read the
# slow way — correct, and slow, which is the right way round for a check that
# can block a stop.
start=$(window | grep -n -F '"message":{"role":"user","content":"' 2>/dev/null \
| cut -d: -f1 | awk 'END { if (NR) print $0 }')
case "$start" in ''|*[!0-9]*) start=1 ;; esac
facts=$(turn_facts "$start")
fact() { printf '%s\n' "$facts" | awk -F'\t' -v k="$1" '$1 == k { print substr($0, index($0, "\t") + 1); exit }'; }
if [ "$(fact bounded)" != "1" ] && [ "$start" != "1" ]; then
facts=$(turn_facts 1)
fi
[ "$(fact bounded)" = "1" ] || exit 0
closed=$(fact closed)
if [ "${closed:-0}" = "0" ]; then
rm -f "$marker" 2>/dev/null || true
exit 0
fi
reply=$(printf '%s' "$facts" | jq -r '.reply // ""')
task_ids=$(printf '%s' "$facts" | jq -r '.task_ids | map(tostring) | join(",")')
# Escaped on one line coming out of awk, so the format survives a multi-line
# reply; decoded here, once, where it is about to be read as text.
reply=$(fact reply | scribe_json_unescape)
task_ids=$(fact task_ids)
# The reply may not be written to the transcript yet when the hook fires. An
# empty reply is "cannot tell", not "missing everything" — stay out of the way.
@@ -146,7 +165,7 @@ report() {
q="outcome=$1&task_ids=${task_ids}"
m=$(IFS=,; printf '%s' "${missing[*]:-}")
if [ -n "$m" ]; then
enc=$(printf '%s' "$m" | jq -sRr '@uri' 2>/dev/null) || enc=""
enc=$(printf '%s' "$m" | scribe_urlenc) || enc=""
q="${q}&missing=${enc}"
fi
scope=$(scribe_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
@@ -176,8 +195,8 @@ fi
# a hook carries timing and transport). No reason back → nothing recorded →
# no block.
answer=$(report blocked) || exit 0
reason=$(printf '%s' "$answer" | jq -r '.reason // empty' 2>/dev/null) || reason=""
reason=$(scribe_json_pick "$(printf '%s' "$answer" | scribe_json_flat)" '.reason')
[ -n "$reason" ] || exit 0
: > "$marker" 2>/dev/null || true
jq -n --arg r "$reason" '{decision: "block", reason: $r}'
printf '{"decision":"block","reason":"%s"}\n' "$(printf '%s' "$reason" | scribe_json_escape)"
exit 0
+11 -9
View File
@@ -49,8 +49,6 @@ set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
# `CDPATH= cd` is deliberate, not a typo'd assignment: it runs this one `cd`
# with CDPATH empty, so an operator whose CDPATH happens to contain a matching
# directory name can't send us somewhere else — and `cd` won't echo the resolved
@@ -60,7 +58,8 @@ here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0
# SessionStart delivers a JSON event on stdin; `source` is startup|resume|compact|clear.
event=$(cat 2>/dev/null || true)
source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=""
event_flat=$(printf '%s' "$event" | scribe_json_flat)
source=$(scribe_json_pick "$event_flat" '.source')
# --- The rule ledger outlives the context it describes (#3749) ---
#
@@ -127,7 +126,7 @@ source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=
# missed exactly the arm that fires most.
case "$source" in
compact|clear)
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
sid=$(scribe_json_pick "$event_flat" '.session_id')
if [ -n "$sid" ]; then
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_')
scribe_clear_session_ledgers "$safe_sid"
@@ -163,7 +162,7 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"
# appears there.
manifest="$here/../.claude-plugin/plugin.json"
if [ -f "$manifest" ]; then
plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version=""
plugin_version=$(scribe_json_pick "$(scribe_json_flat < "$manifest")" '.version')
if [ -n "$plugin_version" ]; then
append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
fi
@@ -193,7 +192,11 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
body_flat=""
if [ -n "$body" ]; then
body_flat=$(printf '%s' "$body" | scribe_json_flat)
dyn=$(scribe_json_pick "$body_flat" '.context')
fi
# The rules marker is gone with the resident set it described
# (milestone 394). Nothing is preloaded, so there is no set whose
# drift a later write could be told about — a rule is retrieved at
@@ -227,7 +230,7 @@ fi
# server reports whether a project resolved, and this hook — which knows what
# it sent and why — turns that into the sentence the operator can act on. The
# repo case is left to the server's existing "bind this repo" hint.
if [ -n "$dyn" ] && [ -z "$(printf '%s' "$body" | jq -r '.project.id // empty' 2>/dev/null)" ]; then
if [ -n "$dyn" ] && [ -z "$(scribe_json_pick "$body_flat" '.project.id')" ]; then
host=$(scribe_url_host "$url")
if [ -n "$marker_why" ]; then
append "> ⚠️ Scribe: the marker file \`${marker}\` ${marker_why}, so no project context was loaded. Fix the file, or ignore it and bind this directory another way."
@@ -246,6 +249,5 @@ fi
# Nothing at all to inject → stay silent.
[ -n "$out" ] || exit 0
jq -n --arg c "$out" \
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
scribe_json_out SessionStart "$out"
exit 0
+9 -6
View File
@@ -26,7 +26,6 @@ set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
scribe_config || exit 0
@@ -36,8 +35,12 @@ body=$(curl -fsS --max-time 8 \
"${url%/}/api/plugin/processes" 2>/dev/null) || exit 0
[ -n "$body" ] || exit 0
count=$(printf '%s' "$body" | jq -r '.processes | length' 2>/dev/null) || exit 0
[ -n "$count" ] && [ "$count" != "null" ] || exit 0
body_flat=$(printf '%s' "$body" | scribe_json_flat)
# The array's LENGTH, which is "" when `.processes` is not an array at all —
# a different answer from 0, and the one that means the response was not what
# this hook expects.
count=$(scribe_json_len "$body_flat" '.processes')
case "$count" in ''|*[!0-9]*) exit 0 ;; esac
skills_dir="${HOME}/.claude/skills"
mkdir -p "$skills_dir" 2>/dev/null || exit 0
@@ -47,9 +50,9 @@ managed=" "
i=0
while [ "$i" -lt "$count" ]; do
name=$(printf '%s' "$body" | jq -r ".processes[$i].name // empty" 2>/dev/null)
slug=$(printf '%s' "$body" | jq -r ".processes[$i].slug // empty" 2>/dev/null)
desc=$(printf '%s' "$body" | jq -r ".processes[$i].description // empty" 2>/dev/null)
name=$(scribe_json_pick "$body_flat" ".processes[$i].name")
slug=$(scribe_json_pick "$body_flat" ".processes[$i].slug")
desc=$(scribe_json_pick "$body_flat" ".processes[$i].description")
i=$((i + 1))
[ -n "$slug" ] && [ -n "$name" ] || continue
+22 -25
View File
@@ -20,31 +20,31 @@
# Env:
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
# Sourced FIRST, because the JSON reader below lives there (#4107). It defines
# functions and clears `url`/`token`; nothing here runs before it is needed.
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
event=$(cat 2>/dev/null || true)
tool_name=$(printf '%s' "$event" | jq -r '.tool_name // 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=""
event_flat=$(printf '%s' "$event" | scribe_json_flat)
tool_name=$(scribe_json_pick "$event_flat" '.tool_name')
session_id=$(scribe_json_pick "$event_flat" '.session_id')
event_cwd=$(scribe_json_pick "$event_flat" '.cwd')
[ -n "$tool_name" ] || exit 0
# The action, as text. `.command` is Bash's field; the fallbacks let the matcher
# in hooks.json widen to other tools without this script changing — which is the
# whole reason the server side takes a name and a string rather than a schema.
command_text=$(printf '%s' "$event" | jq -r '
.tool_input.command //
.tool_input.url //
.tool_input.prompt //
empty' 2>/dev/null) || command_text=""
command_text=$(scribe_json_pick "$event_flat" '.tool_input.command')
[ -n "$command_text" ] || command_text=$(scribe_json_pick "$event_flat" '.tool_input.url')
[ -n "$command_text" ] || command_text=$(scribe_json_pick "$event_flat" '.tool_input.prompt')
[ -n "$command_text" ] || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# scribe_config, not a hand-rolled pair of parameter expansions: it also treats
# an UNEXPANDED `${...}` placeholder as unset, which would otherwise be sent as
# a garbage Bearer token and 401 on every call (#2198's class).
@@ -56,10 +56,12 @@ scribe_config || exit 0
# place. `head -c`, never `cut -c`: cut truncates each LINE and caps nothing.
command_text=$(printf '%s' "$command_text" | head -c 2000)
# -sRr, never -rR: jq -R without -s reads LINE BY LINE, so a multi-line command
# would encode per line and join with raw newlines — an invalid URL.
cmd_enc=$(printf '%s' "$command_text" | jq -sRr '@uri' 2>/dev/null) || exit 0
tool_enc=$(printf '%s' "$tool_name" | jq -sRr '@uri' 2>/dev/null) || exit 0
# Whole, never line by line: the predecessor (`jq -rR`) encoded a multi-line
# command one line at a time and joined them with raw newlines — an invalid URL.
# scribe_urlenc reads bytes and has no notion of a line.
cmd_enc=$(printf '%s' "$command_text" | scribe_urlenc)
tool_enc=$(printf '%s' "$tool_name" | scribe_urlenc)
[ -n "$cmd_enc" ] && [ -n "$tool_enc" ] || exit 0
repo_q=""
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
@@ -98,19 +100,14 @@ body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/tool-rules?tool=${tool_enc}&command=${cmd_enc}${repo_q}${rule_exclude_q}" 2>/dev/null) || exit 0
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
body_flat=$(printf '%s' "$body" | scribe_json_flat)
context=$(scribe_json_pick "$body_flat" '.context')
[ -n "$context" ] || exit 0
# Remember what was named so it is not repeated this session.
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null \
| scribe_rules_append "$rulefile"
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
fi
jq -cn --arg ctx "$context" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: $ctx
}
}' 2>/dev/null || true
scribe_json_out PreToolUse "$context"
exit 0
+108
View File
@@ -0,0 +1,108 @@
# Scribe plugin — what happened in the LAST TURN of a transcript (#4107).
#
# Reads the flat `IDX<TAB>PATH<TAB>VALUE` stream that scribe_json.awk produces
# in `mode=lines` from a Claude Code transcript, and answers the four questions
# scribe_report_check.sh asks. It replaces a thirty-line jq program; the shape
# of the answer is unchanged, so the hook around it reads the same.
#
# bounded 1 if a user prompt was found in the window, else empty. A window
# with no prompt cannot be cut into a turn, and the hook then says
# nothing rather than guessing — `tail -n 3000` cuts wherever it
# cuts, and a turn that began before the cut is not this hook's.
# closed how many task-closing tool calls SUCCEEDED in that turn.
# task_ids their task ids, comma-joined, for the server to record.
# reply the assistant text after the last action, STILL JSON-ESCAPED and
# on one line. The caller decodes it with scribe_json_unescape —
# decoding here would put newlines into a line-oriented format.
#
# A SIDECHAIN IS NOT THIS SESSION. Subagent records interleave into the same
# file, and a subagent closing a task is not the operator's session closing
# one — counting those made the check fire on turns that closed nothing.
#
# A CLOSE THAT ERRORED IS NOT A CLOSE, which is why the error pass runs first:
# the tool_result carrying `is_error` arrives in a LATER record than the
# tool_use it refutes, so a single forward pass would have already counted it.
# TAB-SEPARATED, and stated rather than assumed. Under awk's default splitting
# a value is cut at its first SPACE, so `$3` of a reply line was the reply's
# first word — the kind of defect that hides completely behind a test whose
# replies are all empty. Found by differential-testing this against the jq
# program it replaces, over real transcript windows (#4107).
BEGIN { FS = "\t" }
{
i = $1 + 0
if (i > maxidx) maxidx = i
p = $2
if (p == ".type") { f[i, "type"] = $3; next }
if (p == ".isSidechain") { f[i, "side"] = $3; next }
if (p == ".isMeta") { f[i, "meta"] = $3; next }
# `.message.content` as a SCALAR is what marks a real user prompt; a tool
# result carries an array at the same path, and counting one as a prompt
# would cut the turn at the wrong place.
if (p == ".message.content") { if ($3 != "null") f[i, "str"] = 1; next }
if (substr(p, 1, 17) != ".message.content[") next
rest = substr(p, 18)
if (rest == "#]") { nb[i] = $3 + 0; next }
c = index(rest, "]")
if (c < 2) next
b[i, substr(rest, 1, c - 1) + 0, substr(rest, c + 1)] = $3
}
function is_mine(i) {
return (f[i, "side"] != "true")
}
function has_tool_use(i, j) {
for (j = 0; j < nb[i]; j++) if (b[i, j, ".type"] == "tool_use") return 1
return 0
}
END {
for (i = 1; i <= maxidx; i++)
if (is_mine(i) && f[i, "type"] == "user" && f[i, "meta"] != "true" && f[i, "str"] == 1)
prompt = i
if (!prompt) { print "bounded\t"; exit 0 }
for (i = prompt + 1; i <= maxidx; i++) {
if (!is_mine(i) || f[i, "type"] != "user") continue
for (j = 0; j < nb[i]; j++)
if (b[i, j, ".type"] == "tool_result" && b[i, j, ".is_error"] == "true")
errored[b[i, j, ".tool_use_id"]] = 1
}
for (i = prompt + 1; i <= maxidx; i++) {
if (!is_mine(i) || f[i, "type"] != "assistant") continue
for (j = 0; j < nb[i]; j++) {
if (b[i, j, ".type"] != "tool_use") continue
if (b[i, j, ".name"] !~ /(^|__)(update|create)_task$/) continue
if (b[i, j, ".input.status"] != "done") continue
if (b[i, j, ".id"] in errored) continue
closed++
t = b[i, j, ".input.task_id"]
if (t != "") ids = ids (ids == "" ? "" : ",") t
}
}
# Where the WORK stopped and the report began. Everything after the last
# action is the reply being checked; text emitted between two tool calls is
# narration mid-work, not a report, and holding it to the report shape would
# block turns that did report properly at the end.
act = prompt
for (i = prompt + 1; i <= maxidx; i++) {
if (!is_mine(i)) continue
if (f[i, "type"] == "user" || (f[i, "type"] == "assistant" && has_tool_use(i))) act = i
}
for (i = act + 1; i <= maxidx; i++) {
if (!is_mine(i) || f[i, "type"] != "assistant") continue
for (j = 0; j < nb[i]; j++)
if (b[i, j, ".type"] == "text")
reply = reply (reply == "" ? "" : "\\n") b[i, j, ".text"]
}
printf "bounded\t1\n"
printf "closed\t%d\n", closed + 0
printf "task_ids\t%s\n", ids
printf "reply\t%s\n", reply
}