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
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user