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