CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.
Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.
The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1415 lines
65 KiB
Bash
1415 lines
65 KiB
Bash
#!/usr/bin/env bash
|
|
# shellcheck shell=bash
|
|
# Grimoire plugin — the pieces the hooks share.
|
|
#
|
|
# scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh
|
|
# fires AFTER a Bash tool call and diffs the working tree, so code written by
|
|
# sed/heredocs/scripts gets the same prior-art and ledger checks. Both need the
|
|
# same three things, kept here so they cannot drift apart:
|
|
#
|
|
# scribe_skip_path PATH formats that hold prose or data, not shapes
|
|
# scribe_skip_prompt TEXT the turn was written by the CLIENT, not the
|
|
# operator — skip it entirely
|
|
# scribe_defs stdin code → "kind<TAB>name" per definition
|
|
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
|
|
# local-duplicate lines (ARM 1)
|
|
# scribe_unreached STATE SID SECS REL the "Grimoire didn't answer" line, once
|
|
# per outage — or nothing, if said lately
|
|
# scribe_reached STATE SID the server answered: the next outage speaks again
|
|
# scribe_config sets `url` + `token` from the env, returns 0
|
|
# only if BOTH are usable
|
|
# scribe_rules_live FILE live rule ids from the exclusion ledger,
|
|
# comma-joined; entries age out
|
|
# 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.
|
|
# scribe_scope_query DIR `project_id=N` or `repo=<enc>` for DIR — the
|
|
# project-scope key EVERY hook sends.
|
|
# Helpers: scribe_marker_file, scribe_url_host,
|
|
# scribe_marker_read (id<TAB>why-not),
|
|
# scribe_marker_project (the id alone)
|
|
#
|
|
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
|
|
|
|
# Skip formats that hold prose or data rather than reusable code. Purely to
|
|
# avoid a pointless round-trip — the server would return nothing for these
|
|
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
|
|
# often exactly the thing worth reusing.
|
|
scribe_skip_path() {
|
|
case "$1" in
|
|
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
|
|
return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
# Skip a turn the CLIENT wrote rather than the operator. Claude Code
|
|
# submits several kinds of machine-written text through UserPromptSubmit — a
|
|
# task notification, the echo of a slash command, the caveat banner a local
|
|
# command prints — and they reach `.prompt` indistinguishable from typed words
|
|
# unless something looks.
|
|
#
|
|
# THIS PROTECTS TELEMETRY FIRST AND CUTS NOISE SECOND. Retrieving against a
|
|
# notification usually returns nothing, so the visible cost looks like one
|
|
# wasted embedding. The real cost is that the call is LOGGED. It inflates the
|
|
# denominator of every prompt-boundary surface, so delivery rate reads low for
|
|
# a reason that has nothing to do with retrieval; and the refusal lands in
|
|
# `near_misses`, where a tuning decision reads it as unmet demand. Notification
|
|
# blocks resemble each other, so they pile up just under a floor, and a floor
|
|
# lowered to serve that apparent demand would inject the same record into
|
|
# every notification.
|
|
#
|
|
# A PREFIX TEST, NOT A SUBSTRING ONE, and that distinction is the whole safety
|
|
# argument. A real prompt may well CONTAIN one of these tags — an operator
|
|
# pasting a transcript, or a `<system-reminder>` appended after typed words —
|
|
# and must still be retrieved against. Nothing an operator types BEGINS with a
|
|
# client envelope.
|
|
#
|
|
# Leading whitespace goes first: the tag is what identifies the turn, and one
|
|
# stray newline ahead of it would otherwise walk past the entire filter.
|
|
#
|
|
# NOT LISTED, ON PURPOSE: the compaction-resume injection. It is machine
|
|
# written too, but it is a summary of real work rather than plumbing, and a
|
|
# resumed session is exactly where recalling a rule earns its keep.
|
|
scribe_skip_prompt() {
|
|
_scribe_prompt=${1#"${1%%[![:space:]]*}"}
|
|
case "$_scribe_prompt" in
|
|
"<task-notification>"*|\
|
|
"<agent-message>"*|"<agent-message "*|\
|
|
"<command-name>"*|"<command-message>"*|"<command-args>"*|\
|
|
"<local-command-stdout>"*|"<local-command-stderr>"*|"<local-command-caveat>"*)
|
|
return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON AND URL-ENCODING, WITHOUT jq.
|
|
#
|
|
# jq is absent by default on macOS, on the Debian/Ubuntu slim images, on Alpine
|
|
# and in most CI containers, and a hook that exits quietly without it looks
|
|
# exactly like one that ran and had nothing to say. 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 transcript path → the tail of the last assistant REPLY text, decoded, on
|
|
# one line; "" when there is none. The auto-inject query pairs it with
|
|
# a short prompt, so "yes do that" still says what it is about.
|
|
#
|
|
# Cheap by construction, because it runs on every prompt: the last 512 KB only,
|
|
# and grep narrows to assistant records carrying a text block BEFORE anything is
|
|
# parsed — a transcript is mostly tool results, and parsing those in awk costs
|
|
# seconds. Fixed strings
|
|
# with UNESCAPED quotes can only match at a record's own top level.
|
|
# A sidechain is a subagent talking, not this session, so it is skipped.
|
|
scribe_recent_context() {
|
|
[ -n "${1:-}" ] && [ -f "$1" ] || return 0
|
|
tail -c 524288 "$1" 2>/dev/null \
|
|
| grep -F '"role":"assistant"' 2>/dev/null \
|
|
| grep -F '"type":"text"' 2>/dev/null \
|
|
| tail -n 4 \
|
|
| scribe_json_flat_lines \
|
|
| awk -F'\t' '
|
|
$2 == ".isSidechain" { side[$1] = $3; next }
|
|
$2 ~ /^\.message\.content\[[0-9]+\]\.text$/ { txt[$1] = txt[$1] " " $3; if ($1 > last) last = $1 }
|
|
END { for (i = last; i >= 0; i--) if ((i in txt) && side[i] != "true") { print txt[i]; exit } }
|
|
' 2>/dev/null \
|
|
| scribe_json_unescape \
|
|
| tr '\n\t' ' ' \
|
|
| awk '{ sub(/[ ]+$/, ""); n = length($0); if (n) print (n > 600 ? substr($0, n - 599) : $0) }'
|
|
# A transcript with no reply yet is an answer ("nothing"), not a failure —
|
|
# under pipefail the grep that matched nothing would otherwise be the status.
|
|
return 0
|
|
}
|
|
|
|
# $1 flat text, $2 exact path → the decoded scalar, or "" if absent.
|
|
#
|
|
# `null` READS AS ABSENT. 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`.
|
|
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.
|
|
#
|
|
# A `\uXXXX` ESCAPE IS ENCODED AS UTF-8 HERE, BYTE BY BYTE, UNDER LC_ALL=C.
|
|
# The server escapes every non-ASCII character this way (the JSON default),
|
|
# and `sprintf("%c", n)` for n > 127 means a different thing in every awk and
|
|
# locale: gawk in a UTF-8 locale writes the character, gawk in the C locale
|
|
# and mawk write the single byte n % 256, so "·" (U+00B7) would arrive as a
|
|
# lone 0xB7 wherever the hook runs without a UTF-8 locale. Under LC_ALL=C every awk writes exactly the byte asked for, so the encoding
|
|
# below is the only one that happens.
|
|
scribe_json_unescape() {
|
|
LC_ALL=C awk '
|
|
function utf8(n) {
|
|
if (n < 128) return sprintf("%c", n)
|
|
if (n < 2048) return sprintf("%c%c", 192 + int(n / 64), 128 + n % 64)
|
|
if (n < 65536) return sprintf("%c%c%c", 224 + int(n / 4096),
|
|
128 + int(n / 64) % 64, 128 + n % 64)
|
|
return sprintf("%c%c%c%c", 240 + int(n / 262144), 128 + int(n / 4096) % 64,
|
|
128 + int(n / 64) % 64, 128 + n % 64)
|
|
}
|
|
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 utf8(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
|
|
# payload) and the ledger feed (below: the definitions being written,
|
|
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
|
|
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
|
|
# sees, so the two must agree on what counts as a definition.
|
|
#
|
|
# THE WHOLE INPUT IS BUFFERED so the span scan below can look ahead.
|
|
# The matchers are line-oriented and know nothing about what a line is INSIDE:
|
|
# a wrapped docstring beginning "class AND the …" announces a shape called
|
|
# `AND`, which reaches the session mid-edit as a divergence prompt about a
|
|
# symbol that does not exist. blank_spans() replaces every comment and string
|
|
# span with its own newlines before a matcher sees a line — the same scan, in
|
|
# the same order, as coverage.py::_blank_spans. Change one, change both.
|
|
scribe_defs() {
|
|
awk '
|
|
BEGIN {
|
|
SQ = sprintf("%c", 39)
|
|
SQ3 = SQ SQ SQ
|
|
DQ = "\""
|
|
DQ3 = DQ DQ DQ
|
|
# The only characters that can begin a span, a line comment or a
|
|
# string. Everything between two of them is copied in one go rather
|
|
# than a character at a time.
|
|
MARKERS = "[" DQ SQ "/#]"
|
|
}
|
|
|
|
# Offset just past the one-line string opening at c, or c+1 when it does
|
|
# not close before the end of the line — so an apostrophe in prose costs
|
|
# one character rather than everything up to the next quote.
|
|
function string_end(L, c, q, i, n, ch) {
|
|
n = length(L); i = c + 1
|
|
while (i <= n) {
|
|
ch = substr(L, i, 1)
|
|
if (ch == "\\") { i = i + 2; continue }
|
|
if (ch == q) return i + 1
|
|
i++
|
|
}
|
|
return c + 1
|
|
}
|
|
|
|
# Does the "#" at c open a comment, or is it a CSS colour or id? An
|
|
# alphanumeric straight after it is #fff or #app; anything else is a
|
|
# comment in every language that has one.
|
|
function hash_comment(L, c) {
|
|
return substr(L, c + 1, 1) !~ /^[A-Za-z0-9]$/
|
|
}
|
|
|
|
# raw[1..n] -> msk[1..n] with comment and string spans emptied. Line
|
|
# COUNT is preserved and column positions are not; the matchers lstrip.
|
|
# ONLY CLOSED SPANS ARE BLANKED: an opener with no closer is rewound past
|
|
# and scanning resumes, so a stray marker costs one span rather than
|
|
# every definition below it.
|
|
function blank_spans(raw, n, msk,
|
|
i, c, L, len, state, closer, oplen, sl, sc, sprefix,
|
|
t3, t2, ch, e, k, rest, m) {
|
|
for (i = 1; i <= n; i++) msk[i] = ""
|
|
i = 1; c = 1; state = 0; closer = ""
|
|
while (1) {
|
|
while (i <= n) {
|
|
L = raw[i]; len = length(L)
|
|
if (c > len) { i++; c = 1; continue }
|
|
if (state) {
|
|
e = index(substr(L, c), closer)
|
|
if (e == 0) { i++; c = 1; continue }
|
|
c = c + e - 1 + length(closer)
|
|
state = 0; closer = ""
|
|
continue
|
|
}
|
|
rest = substr(L, c)
|
|
m = match(rest, MARKERS)
|
|
if (m == 0) { msk[i] = msk[i] rest; i++; c = 1; continue }
|
|
if (m > 1) {
|
|
msk[i] = msk[i] substr(rest, 1, m - 1)
|
|
c = c + m - 1
|
|
continue
|
|
}
|
|
t3 = substr(L, c, 3); t2 = substr(L, c, 2); ch = substr(L, c, 1)
|
|
if (t3 == DQ3 || t3 == SQ3) {
|
|
sl = i; sc = c; sprefix = msk[i]
|
|
state = 1; closer = t3; oplen = 3; c = c + 3
|
|
continue
|
|
}
|
|
if (t2 == "/*") {
|
|
sl = i; sc = c; sprefix = msk[i]
|
|
state = 1; closer = "*/"; oplen = 2; c = c + 2
|
|
continue
|
|
}
|
|
if (t2 == "//" || (ch == "#" && hash_comment(L, c))) {
|
|
# A line comment is COPIED, not blanked: its continuation lines
|
|
# carry their own marker, so none can read as a definition alone.
|
|
msk[i] = msk[i] substr(L, c)
|
|
i++; c = 1
|
|
continue
|
|
}
|
|
if (ch == DQ || ch == SQ) {
|
|
k = string_end(L, c, ch)
|
|
msk[i] = msk[i] substr(L, c, k - c)
|
|
c = k
|
|
continue
|
|
}
|
|
msk[i] = msk[i] ch
|
|
c++
|
|
}
|
|
if (!state) return
|
|
for (k = sl; k <= n; k++) msk[k] = ""
|
|
msk[sl] = sprefix
|
|
i = sl; c = sc + oplen; state = 0; closer = ""
|
|
}
|
|
}
|
|
|
|
function emit(line, t, rest) {
|
|
# CSS class definition: .name { or .name,
|
|
if (match(line, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
|
|
t = line; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
|
|
if (t != "") print "css\t" t; return
|
|
}
|
|
sub(/^[[:space:]]+/, "", line)
|
|
# Strip leading declaration modifiers so the definition keyword is the
|
|
# first word regardless of language (export/pub/private/suspend/...).
|
|
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
|
|
# Go method with receiver: func (r *T) Name(
|
|
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
|
|
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
|
|
sub(/[^A-Za-z0-9_].*$/, "", t)
|
|
if (t != "") print "sym\t" t; return
|
|
}
|
|
# Keyword-announced definitions, functions and named types alike.
|
|
# Dunders are skipped: every class defines __init__, so "already defined
|
|
# in N other files" is guaranteed noise for them — and noise is what
|
|
# teaches sessions to skip the hint.
|
|
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
|
|
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
|
|
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
|
# `type` defines only when something follows the name (= or {); an
|
|
# import specifier `type Foo,` is the same two words and defines
|
|
# nothing (mirror of coverage.py).
|
|
if (line ~ /^type[[:space:]]/) {
|
|
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
|
|
if (rest !~ /[={]/) return
|
|
}
|
|
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; return
|
|
}
|
|
# Arrow/expression assignment: const name = (…) / let name = async (
|
|
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
|
|
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
|
|
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
|
if (t != "") print "sym\t" t; return
|
|
}
|
|
}
|
|
|
|
{ raw[NR] = $0 }
|
|
|
|
END {
|
|
blank_spans(raw, NR, msk)
|
|
for (r = 1; r <= NR; r++) emit(msk[r])
|
|
}
|
|
' 2>/dev/null
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ARM 1 — BY NAME, LOCALLY. Does a definition of this already exist?
|
|
#
|
|
# The recorded arms ask Grimoire what was RECORDED; the ledger arm asks what a
|
|
# BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody bound,
|
|
# is invisible to both, so it gets defined again in the next file. This arm asks the one question only the
|
|
# developer's machine can answer, inside the repo, holding the code about to
|
|
# be written: no index, no storage, no server — it runs even on an install
|
|
# that has never configured Grimoire.
|
|
#
|
|
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
|
# every CALL site and drown the real finding — and a hint that is mostly noise
|
|
# is one people learn to skip, which is worse than none. ALL code, not a
|
|
# language shortlist: the same keyword family scribe_defs announces.
|
|
#
|
|
# $1 repo root, $2 repo-relative path of the file being written (excluded from
|
|
# the grep — it would always match itself on an Edit). Definitions on stdin.
|
|
# Prints one "> - `name` is already defined in N other file(s): …" per hit.
|
|
# ── The contract around a change ───────────────────────────────────────
|
|
#
|
|
# WHAT THIS ANSWERS. "You altered the arity, name or shape of something — here
|
|
# is everything that reads it." An interface-contract check between a
|
|
# definition and its callers.
|
|
#
|
|
# WHY IT IS A CHECK AND NOT ADVICE. "Widening a tuple is an interface change to
|
|
# every unpack site, and the compiler will not tell you" is easy to know and
|
|
# easy to forget mid-edit. Text delivered at the moment of acting is too weak a
|
|
# carrier for a reflex that has to change what the act IS, so this looks the
|
|
# callers up instead.
|
|
#
|
|
# `scribe_exposed` — the names a CALLER can depend on, from a blob of code.
|
|
# Three kinds, because a contract breaks three ways and they look nothing
|
|
# alike in the source:
|
|
#
|
|
# sym what is defined rename / removal
|
|
# arg its parameter names arity and order
|
|
# key quoted keys of dict literals the shape of what it RETURNS
|
|
#
|
|
# The third matters because a returned dict can change shape with no
|
|
# signature changing: callers that read it inside a fail-open `except` go
|
|
# silently inert, with nothing pointing at the cause.
|
|
scribe_exposed() {
|
|
# The `sym` half DELEGATES to scribe_defs rather than repeating its patterns.
|
|
# Those patterns cover nine languages and carry their own edge cases (the Go
|
|
# receiver form, the `type` import specifier, the dunder skip); a second copy
|
|
# here would drift from them. One reader, called twice.
|
|
local blob
|
|
blob=$(cat)
|
|
{
|
|
printf '%s' "$blob" | scribe_defs
|
|
printf '%s' "$blob" | awk '
|
|
function emit(kind, name) {
|
|
if (name != "" && name !~ /^__.*__$/) print kind "\t" name
|
|
}
|
|
{
|
|
line = $0; sub(/^[[:space:]]+/, "", line)
|
|
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
|
|
|
|
# Parameter names, from whatever announces a definition. Taken from the
|
|
# FIRST parenthesis only: a default value can itself contain parens and
|
|
# a greedy match would swallow the body of a one-liner.
|
|
if (match(line, /^(function|def|func|fun|fn|sub)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*\(/) \
|
|
|| match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?\(/)) {
|
|
args = line
|
|
sub(/^[^(]*\(/, "", args)
|
|
sub(/\).*$/, "", args)
|
|
n = split(args, parts, ",")
|
|
for (i = 1; i <= n; i++) {
|
|
a = parts[i]
|
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", a)
|
|
# Strip a type annotation, a default, and the * / ** / & markers.
|
|
sub(/[:=].*$/, "", a)
|
|
gsub(/^[*&]+/, "", a)
|
|
gsub(/[[:space:]]/, "", a)
|
|
# `self` and `cls` are not part of anything a caller passes.
|
|
if (a != "" && a != "self" && a != "cls" && a ~ /^[A-Za-z_$][A-Za-z0-9_$]*$/)
|
|
emit("arg", a)
|
|
}
|
|
}
|
|
|
|
# Quoted keys of a dict / object literal. Anchored on the quote so a
|
|
# dictionary ACCESS (`cfg["k"]`) does not read as a definition of one —
|
|
# only `"k":` counts, which is the writing position.
|
|
rest = $0
|
|
while (match(rest, /["'\''][A-Za-z_][A-Za-z0-9_]*["'\''][[:space:]]*:/)) {
|
|
tok = substr(rest, RSTART, RLENGTH)
|
|
rest = substr(rest, RSTART + RLENGTH)
|
|
gsub(/["'\'']/, "", tok); sub(/[[:space:]]*:$/, "", tok)
|
|
emit("key", tok)
|
|
}
|
|
}
|
|
' 2>/dev/null
|
|
} | sort -u
|
|
}
|
|
|
|
# Who references `name` anywhere else in the repo. Word-bounded, so `cfg` does
|
|
# not match `cfg_path`, and the defining file is excluded — a definition is
|
|
# always its own first mention and listing it says nothing.
|
|
#
|
|
# `|| true` INSIDE the substitution, for the reason given at
|
|
# scribe_local_dups: under `pipefail` a `head` that exits early kills the
|
|
# still-writing git grep, and an outer fallback then wipes the hits head had
|
|
# already printed.
|
|
scribe_contract_readers() {
|
|
local root="$1" rel="$2" name="$3"
|
|
[ -n "$root" ] && [ -n "$name" ] || return 0
|
|
git -C "$root" grep -I -l -w -e "$name" -- . ":(exclude)${rel}" 2>/dev/null \
|
|
| head -6 || true
|
|
}
|
|
|
|
# The whole check, rendered. Kept here rather than inline in the hook so it
|
|
# can be exercised against a pair of blobs with no event, no server and no
|
|
# session — which is how every case in test_contract_around_the_change.py is
|
|
# written.
|
|
#
|
|
# Arguments: root, repo-relative path, the subject definition, the old text,
|
|
# the new text, and the session ledger (may be empty).
|
|
#
|
|
# TWO GATES, AND THE SECOND IS WHAT KEEPS THIS QUIET. A change to the exposed
|
|
# set is necessary but not sufficient: a definition NOTHING else references
|
|
# has no contract to break, so the readers lookup runs second and an empty
|
|
# result ends it silently. On a repo of any size most edits touch something
|
|
# local, so most edits say nothing here — and a hint that fires on everything
|
|
# is one that gets skipped.
|
|
scribe_contract_block() {
|
|
local root="$1" rel="$2" subject="$3" old_text="$4" new_text="$5" ledger="$6"
|
|
local changed gained lost readers count
|
|
[ -n "$root" ] && [ -n "$subject" ] || return 0
|
|
[ -n "$old_text" ] && [ -n "$new_text" ] || return 0
|
|
|
|
# Named once per session per subject. A second edit to the same definition
|
|
# is the SAME contract question, and answering it again would punish the
|
|
# ordinary rhythm of getting a change right over several passes.
|
|
if [ -n "$ledger" ] && [ -f "$ledger" ]; then
|
|
grep -qxF "$subject" "$ledger" 2>/dev/null && return 0
|
|
fi
|
|
|
|
# One pass, no process substitution: `comm` would need /dev/fd, and this
|
|
# also keeps the two sides' extraction visibly identical.
|
|
changed=$(
|
|
{
|
|
printf '%s' "$old_text" | scribe_exposed | sed 's/^/O\t/'
|
|
printf '%s' "$new_text" | scribe_exposed | sed 's/^/N\t/'
|
|
} | awk -F'\t' '
|
|
NF >= 3 { k = $2 "\t" $3; side[k] = side[k] $1 }
|
|
END {
|
|
for (k in side)
|
|
if (side[k] == "O") print "lost\t" k
|
|
else if (side[k] == "N") print "gained\t" k
|
|
}
|
|
' 2>/dev/null
|
|
)
|
|
[ -n "$changed" ] || return 0
|
|
|
|
readers=$(scribe_contract_readers "$root" "$rel" "$subject")
|
|
[ -n "$readers" ] || return 0
|
|
count=$(printf '%s\n' "$readers" | grep -c . 2>/dev/null || printf '0')
|
|
|
|
gained=$(printf '%s\n' "$changed" | awk -F'\t' '$1=="gained" {printf "%s%s %s", (n++?", ":""), $2, $3}')
|
|
lost=$(printf '%s\n' "$changed" | awk -F'\t' '$1=="lost" {printf "%s%s %s", (n++?", ":""), $2, $3}')
|
|
|
|
printf '> The contract around `%s` changed, and %s other file(s) reference it (`git grep -w`; a nudge, not a gate):\n' \
|
|
"$subject" "$count"
|
|
[ -n "$gained" ] && printf '> gained: %s\n' "$gained"
|
|
[ -n "$lost" ] && printf '> lost: %s\n' "$lost"
|
|
printf '> read by: %s\n' "$(printf '%s' "$readers" | tr '\n' ' ' | sed 's/ $//')"
|
|
printf '> A caller that passes or reads the old shape keeps compiling and fails only when that line runs. Read them before moving on.\n'
|
|
[ -n "$ledger" ] && printf '%s\n' "$subject" >> "$ledger" 2>/dev/null
|
|
return 0
|
|
}
|
|
|
|
|
|
scribe_slippage_lines() {
|
|
# $1 state dir, $2 sanitised session id.
|
|
#
|
|
# SILENT ONLY WHEN NO RULE TOUCHED THE SESSION AT ALL. Traffic that did
|
|
# happen is always reported, because "which rules governed this work" is
|
|
# what the static instructions above already ask the summariser to preserve
|
|
# in prose — these lines are the measured version of that, and they are
|
|
# three short lines.
|
|
#
|
|
# What is conditional is the ACCUSATION. Each subtraction prints only when
|
|
# it has members, so a session that opened everything it was shown and
|
|
# resolved everything it opened gets the traffic and no more. "0 rules
|
|
# unresolved" on every compaction is how a readout teaches its reader to
|
|
# skip it.
|
|
local dir="$1" sid="$2" named opened acted held
|
|
[ -n "$dir" ] && [ -n "$sid" ] || return 0
|
|
# THE TWINS, not the exclusion ledgers. This runs at the compaction, which
|
|
# is the moment the exclusion ledgers are about to be cleared and the moment
|
|
# their TTL has usually already eaten the early part of a long session. A
|
|
# readout built on them would report only the last stretch of the session
|
|
# and read as though it had reported all of it.
|
|
named=$(scribe_ledger_kept "$dir/${sid}.rules.keep.ids")
|
|
opened=$(scribe_ledger_kept "$dir/${sid}.opened.keep.ids")
|
|
acted=$(scribe_ledger_kept "$dir/${sid}.acted.keep.ids")
|
|
held=$(scribe_ledger_kept "$dir/${sid}.checkpoint.keep.ids")
|
|
[ -n "$named$opened" ] || return 0
|
|
|
|
local unread unresolved
|
|
unread=$(scribe_ids_minus "$named" "$opened")
|
|
unresolved=$(scribe_ids_minus "$opened" "$acted")
|
|
|
|
printf -- '- This session'"'"'s rule traffic, from what actually happened rather than from recollection:\n'
|
|
[ -n "$opened" ] && printf -- ' read: %s\n' "$opened"
|
|
[ -n "$held" ] && printf -- ' held an act before it ran: %s\n' "$held"
|
|
[ -n "$unread" ] && printf -- ' named by an arm and never opened: %s\n' "$unread"
|
|
if [ -n "$unresolved" ]; then
|
|
printf -- ' READ WITH NO OUTCOME RECORDED: %s. Carry these over as outstanding. A rule read and left unresolved looks exactly like one that worked, and the summary is where that difference is lost for good — say `rule_outcome(id, "applied")`, or `rule_outcome(id, "departed", why=...)` where you deliberately went another way.\n' "$unresolved"
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
scribe_ids_minus() {
|
|
# Set difference over two space-separated id lists, order preserved. Written
|
|
# as one awk pass rather than a nested shell loop because the ledgers can
|
|
# hold a few dozen ids by the end of a long session and this runs inside the
|
|
# compaction path, where a slow hook delays the thing it is decorating.
|
|
local a="$1" b="$2"
|
|
[ -n "$a" ] || return 0
|
|
awk -v a="$a" -v b="$b" '
|
|
BEGIN {
|
|
n = split(b, drop, " ")
|
|
for (i = 1; i <= n; i++) if (drop[i] != "") skip[drop[i]] = 1
|
|
m = split(a, keep, " ")
|
|
out = ""
|
|
for (i = 1; i <= m; i++) {
|
|
id = keep[i]
|
|
if (id == "" || (id in skip) || (id in done)) continue
|
|
done[id] = 1
|
|
out = out (out == "" ? "" : " ") id
|
|
}
|
|
if (out != "") print out
|
|
}
|
|
' </dev/null 2>/dev/null
|
|
}
|
|
|
|
# ARM 1, BY NAME — and the confirmation pass that makes it mean something.
|
|
#
|
|
# WHY A GREP IS NOT ENOUGH. The pattern below looks for a definition keyword
|
|
# followed by the name. A grep sees LINES, not spans, so a docstring sentence
|
|
# like
|
|
#
|
|
# class with only modifier rules is a deletion that went half-way.
|
|
#
|
|
# matches it for `name=with`.
|
|
#
|
|
# SO EVERY HIT IS CONFIRMED by running the real extractor over the candidate
|
|
# file and keeping only names it actually reports. That is the honest check and
|
|
# the only one that cannot disagree with the other end of the pipe.
|
|
#
|
|
# TIGHTENING THE PATTERN WOULD BE CHEAPER AND WRONG. Requiring `(` or
|
|
# `{` or `:` after the name rejects `class with only…` — and also rejects
|
|
# `class Foo extends Bar {`, `class Foo : Base()` and `type Foo struct {`. This
|
|
# arm's whole justification is that it works with no server, no
|
|
# index and no binding, which makes a miss here invisible. Trading a visible
|
|
# false positive for an invisible false negative is a bad trade.
|
|
#
|
|
# ONCE PER DISTINCT FILE, not once per (name, file) pair: the same file is
|
|
# usually a candidate for several names at once, and the extractor reads the
|
|
# whole file either way. `scribe_defs` runs at roughly 33ms per 250KB, so
|
|
# even a pathological payload of ordinary-word names costs a few hundred ms to
|
|
# confirm; the per-(name, file) shape would pay that several times over for
|
|
# the same answer.
|
|
_SCRIBE_DUP_CANDIDATES=12
|
|
_SCRIBE_DUP_SHOWN=4
|
|
_SCRIBE_DUP_BUDGET=48
|
|
|
|
scribe_local_dups() {
|
|
local root="$1" rel="$2" kind name pat hits
|
|
local records="" cands="" idx=$'\n' seen=0
|
|
local f defs line record rest files shown
|
|
|
|
# PASS 1 — candidates. One grep per name, unchanged except for the cap.
|
|
#
|
|
# THE CANDIDATE CAP IS WIDER THAN THE DISPLAY CAP. Confirmation REMOVES
|
|
# hits, so capping before it runs would let phantom matches crowd out a real
|
|
# definition. The display cap (_SCRIBE_DUP_SHOWN) applies to CONFIRMED hits,
|
|
# which is where a cap belongs.
|
|
while IFS=$'\t' read -r kind name; do
|
|
[ -n "${name:-}" ] || continue
|
|
case "$kind" in
|
|
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
|
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
|
esac
|
|
# -I skips binaries; :(exclude) drops the file being written.
|
|
# `|| true` INSIDE the substitution, not `|| hits=""` outside it: under
|
|
# the hooks' `pipefail`, `head` exiting early kills a git grep that is
|
|
# still writing, the pipeline reports SIGPIPE, and an outer fallback would
|
|
# wipe the hits head had already printed — dropping exactly the most
|
|
# duplicated name.
|
|
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null \
|
|
| head -"$_SCRIBE_DUP_CANDIDATES" || true)
|
|
[ -n "$hits" ] || continue
|
|
records+="${kind}"$'\t'"${name}"$'\t'"${hits//$'\n'/$'\t'}"$'\n'
|
|
cands+="${hits}"$'\n'
|
|
done
|
|
|
|
[ -n "$records" ] || return 0
|
|
|
|
# PASS 2 — what each candidate file actually defines, one extractor run per
|
|
# file. The budget is a floor under the worst case rather than a tuning knob:
|
|
# the upstream caps (twelve names, twelve candidates each) bound this at 144
|
|
# files, and a repo that reached that would be paying a second of hook time
|
|
# for a nudge. A file past the budget is DROPPED rather than passed through
|
|
# unconfirmed — this arm is a nudge and not a gate, so an unproven claim is
|
|
# worth less here than no claim.
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
[ "$seen" -ge "$_SCRIBE_DUP_BUDGET" ] && break
|
|
seen=$((seen + 1))
|
|
defs=$(scribe_defs < "$root/$f" 2>/dev/null | sort -u) || defs=""
|
|
[ -n "$defs" ] || continue
|
|
while IFS= read -r line; do
|
|
[ -n "$line" ] || continue
|
|
idx+="${f}"$'\t'"${line}"$'\n'
|
|
done <<< "$defs"
|
|
done <<< "$(printf '%s' "$cands" | sort -u)"
|
|
|
|
# PASS 3 — emit the confirmed hits, in the order the names arrived.
|
|
while IFS= read -r record; do
|
|
[ -n "$record" ] || continue
|
|
kind=${record%%$'\t'*}
|
|
rest=${record#*$'\t'}
|
|
name=${rest%%$'\t'*}
|
|
files=""
|
|
shown=0
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
# Delimited on both sides so `usage.ts` cannot satisfy a lookup for
|
|
# `.ts`, and `handler` cannot satisfy one for `handle`.
|
|
case "$idx" in
|
|
*$'\n'"${f}"$'\t'"${kind}"$'\t'"${name}"$'\n'*) ;;
|
|
*) continue ;;
|
|
esac
|
|
files+="${f} "
|
|
shown=$((shown + 1))
|
|
[ "$shown" -ge "$_SCRIBE_DUP_SHOWN" ] && break
|
|
done <<< "$(printf '%s' "${rest#*$'\t'}" | tr '\t' '\n')"
|
|
[ -n "$files" ] || continue
|
|
if [ "$kind" = css ]; then label=".$name"; else label="$name"; fi
|
|
printf '> - `%s` is already defined in %s other file(s): %s\n' \
|
|
"$label" "$shown" "${files% }"
|
|
done <<< "$records"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Where every hook gets its endpoint and credential. One copy, because the
|
|
# `${...}` guard below is a correctness detail a hook with its own copy would
|
|
# forget, and nothing would fail loudly.
|
|
#
|
|
# Sets `url` and `token` as globals rather than echoing them: a token must not
|
|
# pass through a subshell's output, where it could land in a log or an `xtrace`
|
|
# line. Returns 0 only when both are usable, so a caller can either bail
|
|
# (`scribe_config || exit 0`) or carry on degraded — the session-context hook
|
|
# still owes its static floor when Grimoire is unconfigured.
|
|
# Declared here, not just assigned inside the function: `scribe_defs.sh` owns
|
|
# these two names, and a sourcing hook should have them defined the moment it
|
|
# sources — before any code path that might reference them. It also lets
|
|
# the linter see the assignment, which it cannot follow into a function in
|
|
# another file without -x (SC2154).
|
|
url=""
|
|
token=""
|
|
|
|
scribe_config() {
|
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
|
# An unexpanded `${...}` placeholder arriving as a literal would be sent as a
|
|
# garbage Bearer token and 401. Treat it as unset.
|
|
case "$url" in *'${'*) url="" ;; esac
|
|
case "$token" in *'${'*) token="" ;; esac
|
|
[ -n "$url" ] && [ -n "$token" ]
|
|
}
|
|
|
|
# The blind spot made visible. Both write-path hooks fail OPEN when the
|
|
# instance is slow or down — right for noise, wrong for silence: a session
|
|
# cannot tell "the ledger checked and found nothing" from "the ledger never
|
|
# answered" (a cold start after a redeploy is enough to miss the budget). So a
|
|
# failed call says so — ONCE per outage: the marker holds the time it last
|
|
# spoke; within ten minutes of that it stays quiet, and a successful call
|
|
# clears it so the next outage announces itself afresh. Unconfigured installs
|
|
# never reach this: no URL/token means no call was owed.
|
|
_SCRIBE_UNREACHED_QUIET=600
|
|
|
|
scribe_unreached() {
|
|
local marker="$1/$2.unreached" now last
|
|
now=$(date +%s 2>/dev/null) || now=0
|
|
if [ -f "$marker" ]; then
|
|
last=$(cat "$marker" 2>/dev/null) || last=0
|
|
case "$last" in ''|*[!0-9]*) last=0 ;; esac
|
|
[ $((now - last)) -lt "$_SCRIBE_UNREACHED_QUIET" ] && return 0
|
|
fi
|
|
printf '%s' "$now" > "$marker" 2>/dev/null || true
|
|
printf '> Grimoire did not answer the prior-art check for `%s` within %ss — this write went UNCHECKED against the record and the shape ledger (the local by-name arm, if it spoke above, needed no server). If the name matters, check it yourself: `search` for the concept, `list_shapes(project_id, path=…)` for the ledger. Said once per outage; if it keeps happening the instance is slow or down.' "$4" "$3"
|
|
}
|
|
|
|
scribe_reached() {
|
|
rm -f "$1/$2.unreached" 2>/dev/null || true
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# THE RULE EXCLUSION LEDGER, and how entries in it AGE.
|
|
#
|
|
# Two hooks write this file and two read it, which is the whole reason the
|
|
# parsing lives here: `scribe_prior_art.sh` and `scribe_tool_rules.sh` share
|
|
# one ledger so a rule named by one arm is not re-offered by the other, and a
|
|
# format only one of them understood would break that on the first read.
|
|
#
|
|
# WHAT PROBLEM AGEING SOLVES. The ledger is cleared when an EVENT destroys
|
|
# context — a compaction or a /clear. This is the case with no event at all: a
|
|
# long session where the rule was named two hundred turns ago and has simply
|
|
# fallen out of attention. Present in context and salient at the moment are
|
|
# different properties.
|
|
#
|
|
# PER-ENTRY TIMESTAMPS, NOT A FILE MTIME. Clearing the whole ledger when the
|
|
# file is old is one line of shell and wrong in exactly the session that needs
|
|
# it: a single recent write keeps every stale id alive, and the ids that go
|
|
# stale first are the ones from the rules that fire most.
|
|
#
|
|
# WALL TIME, NOT A TURN COUNT, and the trade is real rather than dismissed. A
|
|
# turn count is a truer model of salience — an idle session does not forget —
|
|
# but a hook has no turn number without keeping its own counter, which is a
|
|
# second piece of session state to write, read, clear on compaction and get
|
|
# wrong. Wall time is available from `date` and costs nothing. The failure mode
|
|
# it accepts is a session left idle over lunch treating its rules as forgotten,
|
|
# which produces one extra full line per rule and no other harm.
|
|
_SCRIBE_RULE_TTL=2700
|
|
|
|
# 45 MINUTES, and the reasoning rather than the number.
|
|
#
|
|
# It is a judgement made to be revised: how often an aged-out rule gets PULLED
|
|
# after it returns is the reading that should move it.
|
|
#
|
|
# Too short and the exclusion stops existing and the repetition it prevents
|
|
# comes back. Too long and it never fires at all in a session short enough to
|
|
# matter. 45 minutes is about one working stretch on a single task: long enough
|
|
# that a rule does not re-announce itself while you are still doing the thing
|
|
# it governs, short enough that a multi-hour session gets a genuine refresh
|
|
# rather than one 9am mention.
|
|
#
|
|
# Being wrong on the short side is the cheaper error, which is why this leans
|
|
# short. An excluded rule is REFERENCED rather than withheld, so an expired
|
|
# entry costs one full line instead of one short one, and the exclusion
|
|
# re-arms the moment it is spent.
|
|
|
|
# Live ids from a ledger, comma-joined for `exclude_rule_ids`. Empty output for
|
|
# a missing, empty or fully-aged file — the callers already treat "" as "send
|
|
# no exclusions".
|
|
#
|
|
# THE LAST ENTRY FOR AN ID WINS, and this is what stops a rule ping-ponging.
|
|
# The file is append-only, so a rule that ages out, gets surfaced fresh and is
|
|
# appended again has TWO lines. Reading the first would leave it permanently
|
|
# expired and it would re-announce itself on every single call from then on —
|
|
# the loudest possible failure, from the mechanism meant to quieten things.
|
|
# Appends are chronological, so the last line for an id is its most recent.
|
|
#
|
|
# A BARE ID — no tab, no timestamp — IS LIVE. Unknown means "not measured" and
|
|
# never "old": treating it as expired would re-announce every such rule at
|
|
# once, which is precisely the noise this exists to prevent. Those entries
|
|
# simply never age, which is bounded: the session ends.
|
|
scribe_rules_live() {
|
|
local f="$1" now
|
|
[ -n "$f" ] && [ -f "$f" ] || return 0
|
|
now=$(date +%s 2>/dev/null) || now=0
|
|
awk -F'\t' -v now="$now" -v ttl="$_SCRIBE_RULE_TTL" '
|
|
{
|
|
id = $1
|
|
gsub(/[^0-9]/, "", id)
|
|
if (id == "") next
|
|
if (!(id in seen)) { seen[id] = 1; seq[++n] = id }
|
|
stamp[id] = ($2 ~ /^[0-9]+$/) ? $2 : ""
|
|
}
|
|
END {
|
|
out = ""
|
|
for (i = 1; i <= n; i++) {
|
|
id = seq[i]
|
|
if (stamp[id] != "" && now > 0 && (now - stamp[id]) > ttl) continue
|
|
out = out (out == "" ? "" : ",") id
|
|
}
|
|
print out
|
|
}
|
|
' "$f" 2>/dev/null || true
|
|
}
|
|
|
|
# Append surfaced ids, stamped. Reads ids on stdin, one per line — the shape
|
|
# `scribe_json_list "$flat" '.rule_ids'` already produces at both call sites.
|
|
scribe_rules_append() {
|
|
# Writes TWO files, and the second one is the point.
|
|
#
|
|
# `$f` is an EXCLUSION ledger: it answers "does this context already hold
|
|
# this?", so it is aged by TTL and swept at every compaction — after a
|
|
# compaction the agent genuinely does not hold what it was shown, and
|
|
# forgetting is correct. Three hooks depend on exactly that.
|
|
#
|
|
# The twin is an EVIDENCE ledger: it answers "did this happen?", which no
|
|
# compaction can make untrue. It is never aged and never swept.
|
|
#
|
|
# These are opposite lifetimes, so one file cannot serve both: a compaction
|
|
# would clear the ledger that is also the record.
|
|
#
|
|
# DERIVED HERE rather than listed at the call sites, because a list goes
|
|
# stale (see `scribe_clear_session_ledgers`). Every ledger
|
|
# written through this function gets its twin, including the next one
|
|
# somebody adds. The cost is a second small file per ledger per session.
|
|
local f="$1" now
|
|
[ -n "$f" ] || return 0
|
|
now=$(date +%s 2>/dev/null) || now=0
|
|
awk -v ts="$now" -v keep="${f%.ids}.keep.ids" '
|
|
NF { line = $1 "\t" ts; print line; print line >> keep }
|
|
' >> "$f" 2>/dev/null || true
|
|
}
|
|
|
|
scribe_ledger_kept() {
|
|
# Ids from an EVIDENCE twin: deduped, order preserved, NOT aged. A rule
|
|
# opened two hours ago was still opened, so the TTL that keeps an exclusion
|
|
# ledger honest would here delete the finding.
|
|
local f="$1"
|
|
[ -n "$f" ] && [ -f "$f" ] || return 0
|
|
awk -F'\t' '$1 != "" && !seen[$1]++ {
|
|
out = out (out == "" ? "" : " ") $1
|
|
} END { if (out != "") print out }' "$f" 2>/dev/null || true
|
|
}
|
|
|
|
# ── The pre-act checkpoint's session ledger ──────────────────────────────
|
|
#
|
|
# A checkpoint STOPS an act rather than annotating it, so unlike every other
|
|
# ledger here its job is to make sure the same stop cannot happen twice. Two
|
|
# guards, and they fail in different directions:
|
|
#
|
|
# per rule — a rule may hold at most ONE act per session. Once it has, the
|
|
# remedy has been offered; repeating it on the next call would
|
|
# turn a reader who decided the rule does not apply into a
|
|
# reader who cannot proceed.
|
|
# per session — at most `_SCRIBE_CHECKPOINT_CAP` stops in total, whatever the
|
|
# corpus scores. A mis-set floor or a corpus that suddenly
|
|
# resembles everything must degrade to a noisy session, never
|
|
# to one that cannot make progress. This is the guard on the
|
|
# worst case, not a tuning value.
|
|
#
|
|
# NOT AGED, unlike the naming ledgers. Those age because they describe what a
|
|
# session is still HOLDING, and a context stops holding things. This one
|
|
# describes what already HAPPENED — a stop was raised and its remedy offered —
|
|
# and that does not become untrue an hour later. Ageing it would let a long
|
|
# session be stopped by the same rule repeatedly, which is the one outcome
|
|
# both guards exist to prevent.
|
|
_SCRIBE_CHECKPOINT_CAP=5
|
|
|
|
scribe_checkpoint_allowed() {
|
|
# $1 ledger file, $2 rule id. Returns 0 (and RECORDS the stop) when this act
|
|
# may be held; non-zero otherwise. Records on the way out rather than asking
|
|
# the caller to, because a caller that forgets is a session that can be
|
|
# stopped forever and the failure is invisible until it happens.
|
|
local f="$1" id="$2" n
|
|
[ -n "$f" ] || return 1
|
|
id=$(printf '%s' "$id" | tr -cd '0-9')
|
|
[ -n "$id" ] || return 1
|
|
if [ -f "$f" ]; then
|
|
# Already held an act for this rule — the remedy has been offered once.
|
|
grep -qx "$id" "$f" 2>/dev/null && return 1
|
|
n=$(grep -c '^[0-9][0-9]*$' "$f" 2>/dev/null || printf '0')
|
|
# `grep -c` over a missing file can print nothing; a bare arithmetic test
|
|
# on an empty string is a syntax error in some shells and silently true in
|
|
# others, which is how a cap comes to cap nothing.
|
|
n=$(printf '%s' "$n" | tr -cd '0-9')
|
|
[ -n "$n" ] || n=0
|
|
[ "$n" -ge "$_SCRIBE_CHECKPOINT_CAP" ] && return 1
|
|
fi
|
|
printf '%s\n' "$id" >> "$f" 2>/dev/null || return 1
|
|
# The evidence twin, beside the cap rather than instead of it. `$f` is swept
|
|
# at a compaction and SHOULD be: after one, this context has not read the
|
|
# rule, so the budget to stop an act on it is honestly fresh. That a stop
|
|
# already happened is a different claim, and it stays true.
|
|
printf '%s\n' "$id" >> "${f%.ids}.keep.ids" 2>/dev/null || true
|
|
return 0
|
|
}
|
|
|
|
scribe_json_deny() {
|
|
# $1 hook event name, $2 the reason the agent reads INSTEAD of the result.
|
|
#
|
|
# The one place this plugin emits a decision on a tool call. `deny` returns
|
|
# the reason to the MODEL and the call does not run — it is not a prompt to
|
|
# the operator, costs them nothing, and is undone by the model simply
|
|
# submitting the call again. That is the whole difference from `ask`, which
|
|
# would hand a judgement that is the agent's to make to the person who asked
|
|
# for the work.
|
|
local esc
|
|
esc=$(printf '%s' "$2" | scribe_json_escape) || return 0
|
|
printf '{"hookSpecificOutput":{"hookEventName":"%s","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' \
|
|
"$1" "$esc"
|
|
}
|
|
|
|
# The OPENED ledger's contribution to a rule arm's query string.
|
|
#
|
|
# TWO LEDGERS, BECAUSE THEY RECORD TWO DIFFERENT FACTS. `.rules.ids` holds
|
|
# every id an arm has NAMED; `.opened.ids` holds the ids the session actually
|
|
# read, written by scribe_record_opened.sh from the `get_rule` call itself.
|
|
# Named is not read: the injected line is a teaser, and one skimmed past
|
|
# leaves nothing behind — least of all across a compaction. Sending both lets
|
|
# the server tell a reader who opened a rule from one who was only shown it,
|
|
# instead of telling them both the same untrue thing.
|
|
#
|
|
# Same reader as the naming ledger on purpose, so ageing, the last-entry-wins
|
|
# rule and the bare-id format are defined once and cannot drift apart.
|
|
scribe_held_query() {
|
|
local ids
|
|
ids=$(scribe_rules_live "$1")
|
|
[ -n "$ids" ] && printf '&held_rule_ids=%s' "$ids"
|
|
return 0
|
|
}
|
|
|
|
# The Systems whose rulings this session was already shown in full, as the
|
|
# `seen_ruling_systems` query both PreToolUse hooks send. A flat
|
|
# read, not aged like the rule file: a ruling binds for the whole session, and
|
|
# the server already answers a repeat with one short reference line. The file
|
|
# follows the ledger name convention, so a compaction clears it and the
|
|
# rulings are shown in full again to the context that lost them.
|
|
scribe_rulings_query() {
|
|
local ids
|
|
[ -f "$1" ] || return 0
|
|
ids=$(grep -E '^[0-9]+$' "$1" 2>/dev/null | sort -un | tr '\n' ',' | sed 's/,$//')
|
|
[ -n "$ids" ] && printf '&seen_ruling_systems=%s' "$ids"
|
|
return 0
|
|
}
|
|
|
|
# Drop EVERY per-session ledger, matched by convention rather than listed.
|
|
#
|
|
# A compaction destroys the context, so a ledger that survives it makes the
|
|
# records it lists unreachable for the rest of the session. A hand-kept list
|
|
# of files to clear misses whichever ledger was added after it was written.
|
|
#
|
|
# So the rule is the NAME: a per-session ledger is `<sid>[.<kind>].ids`, and
|
|
# everything matching that goes. A new ledger following the convention is
|
|
# covered the day it is written, by nobody. One that does not follow it is a
|
|
# deliberate exception and has to say so.
|
|
#
|
|
# Scoped to `.ids` rather than `<sid>.*` so a marker that is REWRITTEN on
|
|
# compact rather than discarded can still live in these directories without
|
|
# being swept away by a glob that was never told about it. `<sid>.unreached`
|
|
# is the live example: it records that the instance could not be reached, not
|
|
# what the session holds, and the once-per-outage line needs it to outlive a
|
|
# compaction.
|
|
#
|
|
# TWO DIRECTORIES: `scribe_autoinject.sh` keeps its note ledger in
|
|
# `scribe-autoinject`, apart from the prior-art hooks'. The roster lives here
|
|
# rather than as a path at each call site: one place to add to, and the tests
|
|
# read THIS string rather than a copy of it.
|
|
SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject"
|
|
|
|
# THE WRITTEN-SHAPES LEDGER. Every definition a write names,
|
|
# appended as `path<TAB>kind<TAB>name`, for the Stop hook to ask about at the
|
|
# end of the turn (scribe_shape_check.sh). Its own file — one file, one
|
|
# question — under the prior-art directory, so the session
|
|
# clear above already sweeps it. Read `kind<TAB>name` lines on stdin (the
|
|
# scribe_defs format).
|
|
#
|
|
# A NEW file that is a unit in its own right adds one `file` line named by its
|
|
# stem — the same test the server's sync applies (coverage.is_file_unit): it
|
|
# RENDERS (markup names classes, or it opens a <script>/<template>/<style>
|
|
# block) and nothing inside it is named after it. That is a component in any
|
|
# framework, without a list of frameworks.
|
|
# $1 session id (already made filename-safe) $2 repo-relative path
|
|
# $3 "new" when the file did not exist before this write
|
|
# $4 the code written (to test whether the file renders)
|
|
scribe_written_append() {
|
|
local sid="$1" rel="$2" fresh="${3:-}" code="${4:-}" dir stem names
|
|
if [ -z "$sid" ] || [ -z "$rel" ]; then cat >/dev/null; return 0; fi
|
|
dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|
mkdir -p "$dir" 2>/dev/null || true
|
|
names=$(cat)
|
|
{
|
|
if [ "$fresh" = "new" ]; then
|
|
stem=${rel##*/}; stem=${stem%.*}
|
|
if [ -n "$stem" ] \
|
|
&& ! printf '%s\n' "$names" | awk -F'\t' -v s="$stem" '$1 == "sym" && $2 == s { f = 1 } END { exit !f }' \
|
|
&& printf '%s\n' "$code" | grep -qE '^[[:space:]]*<(script|template|style)([[:space:]>]|$)|(class|className)[[:space:]]*='; then
|
|
printf '%s\tfile\t%s\n' "$rel" "$stem"
|
|
fi
|
|
fi
|
|
printf '%s\n' "$names" | awk -F'\t' -v p="$rel" 'NF >= 2 && ($1 == "sym" || $1 == "css") && $2 != "" { printf "%s\t%s\t%s\n", p, $1, $2 }'
|
|
} >> "$dir/${sid}.written.ids" 2>/dev/null || true
|
|
return 0
|
|
}
|
|
|
|
# THE REPLY-SHAPE LEDGER. Which default reply shapes this
|
|
# session has been shown IN FULL — `core`, `completion`, `asks`, `plan` — one
|
|
# key per line in <sid>.shapes.ids under the prior-art directory. The server
|
|
# sends a shape in full when its key is absent and as a one-line reminder when
|
|
# present, so the core arrives whole once and is a pointer on later turns.
|
|
#
|
|
# Swept with every other `.ids` ledger on compact and clear, and that is the
|
|
# point rather than a side effect: after a compaction the session no longer
|
|
# holds the shape, so the next turn gets it in full again. Not aged — the
|
|
# per-turn reminder is what keeps it in view between compactions.
|
|
# scribe_shapes_file <sid> the ledger's path
|
|
# scribe_shapes_seen <file> its keys, comma-joined, for `shapes_seen=`
|
|
# scribe_shapes_append <file> keys on stdin, appended
|
|
scribe_shapes_file() {
|
|
printf '%s/scribe-priorart/%s.shapes.ids' "${TMPDIR:-/tmp}" "$1"
|
|
}
|
|
|
|
scribe_shapes_seen() {
|
|
[ -n "$1" ] && [ -f "$1" ] || return 0
|
|
awk '/^[a-z]+$/ && !seen[$0]++ { out = out (out == "" ? "" : ",") $0 } END { print out }' \
|
|
"$1" 2>/dev/null || true
|
|
}
|
|
|
|
scribe_shapes_append() {
|
|
[ -n "$1" ] || { cat >/dev/null; return 0; }
|
|
mkdir -p "$(dirname "$1")" 2>/dev/null || true
|
|
awk '/^[a-z]+$/' >> "$1" 2>/dev/null || true
|
|
}
|
|
|
|
scribe_clear_session_ledgers() {
|
|
# SPARES `*.keep.ids`, which are evidence rather than exclusions — see
|
|
# `scribe_rules_append`. Everything else still goes: the convention is
|
|
# unchanged and still covers a ledger added tomorrow, which is what the
|
|
# block above insists on. The twin opts OUT by its name, so a new ledger is
|
|
# born on the swept side unless somebody says otherwise.
|
|
local sid="$1" dir f
|
|
[ -n "$sid" ] || return 0
|
|
for dir in $SCRIBE_LEDGER_DIRS; do
|
|
for f in "${TMPDIR:-/tmp}/$dir/$sid"*.ids; do
|
|
[ -f "$f" ] || continue
|
|
case "$f" in *.keep.ids) continue ;; esac
|
|
rm -f "$f" 2>/dev/null || true
|
|
done
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WHAT HAPPENED IN THE LAST TURN of a transcript: scribe_turn.awk's
|
|
# facts — bounded, closed, task_ids, reply — one `KEY<TAB>VALUE` per line.
|
|
# Shared by the two Stop hooks that read a turn: the report check and the
|
|
# reply check. Empty output when the turn cannot be bounded.
|
|
#
|
|
# The turn is 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, and the caller says
|
|
# nothing rather than guessing.
|
|
#
|
|
# 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 these checks never
|
|
# look at. Parsing all of it in awk takes seconds (6.5s for a 7MB window,
|
|
# against 94ms narrowed first), 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.
|
|
_scribe_turn_window() { tail -n 3000 "$1" 2>/dev/null; }
|
|
_scribe_turn_parse() {
|
|
_scribe_turn_window "$1" | tail -n +"${2:-1}" | scribe_json_flat_lines \
|
|
| awk -f "$SCRIBE_HOOK_DIR/scribe_turn.awk" 2>/dev/null
|
|
}
|
|
|
|
scribe_turn_facts() {
|
|
local transcript="$1" start facts
|
|
[ -n "$transcript" ] && [ -f "$transcript" ] || return 0
|
|
start=$(_scribe_turn_window "$transcript" \
|
|
| grep -n -F '"message":{"role":"user","content":"' 2>/dev/null \
|
|
| cut -d: -f1 | awk '{ last = $0 } END { if (NR) print last }')
|
|
case "$start" in ''|*[!0-9]*) start=1 ;; esac
|
|
facts=$(_scribe_turn_parse "$transcript" "$start")
|
|
if [ "$(scribe_turn_fact "$facts" bounded)" != "1" ] && [ "$start" != "1" ]; then
|
|
facts=$(_scribe_turn_parse "$transcript" 1)
|
|
fi
|
|
printf '%s\n' "$facts"
|
|
}
|
|
|
|
# $1 facts from scribe_turn_facts, $2 key → that fact's value, or "".
|
|
scribe_turn_fact() {
|
|
printf '%s\n' "$1" | awk -F'\t' -v k="$2" '$1 == k { print substr($0, index($0, "\t") + 1); exit }'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WHICH PROJECT IS THIS DIRECTORY'S?
|
|
#
|
|
# Every hook here scopes its request to a project. The first key is `git
|
|
# remote get-url origin`, resolved server-side through the repo bindings. That
|
|
# works inside a bound repo and not at all outside one, where a session would
|
|
# get no project scope from any hook.
|
|
#
|
|
# The second key is a `.scribe` file in the directory (or above it, the way
|
|
# git finds its root), naming the project the work belongs to. It is a
|
|
# POINTER, not a copy: an id and enough to check the id means what it says.
|
|
# Nothing Grimoire should be holding goes in it.
|
|
#
|
|
# {"instance": "https://scribe.example.com", "project_id": 2,
|
|
# "project": "FabledScribe"}
|
|
#
|
|
# instance WHICH Grimoire the id belongs to, and the reason this file is
|
|
# not just a number. A project id means nothing on its own: id 2
|
|
# is a different project on every instance, so a marker that
|
|
# travels — a copied directory, a shared machine, a repo someone
|
|
# else clones — would silently scope a session to the wrong
|
|
# project. Compared HOST-ONLY against the configured endpoint, so
|
|
# http/https and a trailing slash don't cause a false mismatch.
|
|
# A mismatch drops the id: no project beats the wrong project.
|
|
# project_id the pointer itself. Required.
|
|
# project a human label. NOTHING READS IT. It is there so the file
|
|
# answers "what is this?" when opened, and so a stale one is
|
|
# visible rather than inert.
|
|
#
|
|
# A bare integer is also accepted (`echo 2 > .scribe`), because it is what a
|
|
# person writes by hand and it parses as JSON already. It skips the instance
|
|
# check by having nothing to check — deliberate, and the reason the written
|
|
# form carries `instance`.
|
|
#
|
|
# THE MARKER WINS over a git remote. Someone put the file there on purpose;
|
|
# a remote is just where the code happens to be pushed. That also makes the
|
|
# marker the way to override a binding for one directory.
|
|
|
|
# Nearest `.scribe` at or above DIR. Walks up to the filesystem root, capped so
|
|
# a pathological path cannot spin.
|
|
scribe_marker_file() {
|
|
local dir="$1" depth=0
|
|
[ -n "$dir" ] || return 0
|
|
while [ "$depth" -lt 40 ]; do
|
|
[ -f "$dir/.scribe" ] && { printf '%s' "$dir/.scribe"; return 0; }
|
|
case "$dir" in ""|"/") return 0 ;; esac
|
|
dir=$(dirname -- "$dir" 2>/dev/null) || return 0
|
|
depth=$((depth + 1))
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Host of a URL, lowercased, port kept. "" for empty input.
|
|
scribe_url_host() {
|
|
printf '%s' "${1:-}" \
|
|
| sed -e 's#^[A-Za-z][A-Za-z0-9+.-]*://##' -e 's#^[^/@]*@##' -e 's#[/?].*$##' \
|
|
| tr 'A-Z' 'a-z'
|
|
}
|
|
|
|
# Read a marker file: prints "ID<TAB>REASON", at most one of them non-empty.
|
|
#
|
|
# "7\t" use project 7
|
|
# "\tnames no …" a file is there and deliberately NOT used; say why
|
|
# "\t" no marker file at all — the ordinary case, say nothing
|
|
#
|
|
# One line rather than an id plus a global, because every caller reads this
|
|
# through `$( )` and a global set inside a command substitution dies with the
|
|
# subshell. The caller would then read an unset variable, which under the
|
|
# `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" flat id inst want
|
|
[ -n "$f" ] && [ -f "$f" ] || { printf '\t'; return 0; }
|
|
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=$(scribe_json_pick "$flat" '.instance')
|
|
if [ -n "$inst" ]; then
|
|
want=$(scribe_url_host "${url:-}")
|
|
inst=$(scribe_url_host "$inst")
|
|
if [ -n "$want" ] && [ "$inst" != "$want" ]; then
|
|
printf '\tpoints at %s, but this session is configured for %s' "$inst" "$want"
|
|
return 0
|
|
fi
|
|
fi
|
|
printf '%s\t' "$id"
|
|
}
|
|
|
|
# Just the id a marker names, or "" — the half scribe_scope_query needs.
|
|
scribe_marker_project() {
|
|
scribe_marker_read "$1" | cut -f1
|
|
}
|
|
|
|
# The query args identifying DIR's project — `project_id=N` or `repo=<enc>` —
|
|
# with NO leading `?` or `&`, so each caller keeps its own separator. Empty
|
|
# when neither key is available. Requires `url` to be set (scribe_config) for
|
|
# the marker's instance check; without it a marker is still honoured, since an
|
|
# unconfigured hook is not going to send the request anyway.
|
|
scribe_scope_query() {
|
|
local dir="$1" id repo enc
|
|
id=$(scribe_marker_project "$(scribe_marker_file "$dir")")
|
|
if [ -n "$id" ]; then
|
|
printf 'project_id=%s' "$id"
|
|
return 0
|
|
fi
|
|
repo=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
|
|
[ -n "$repo" ] || return 0
|
|
enc=$(printf '%s' "$repo" | scribe_urlenc) || enc=""
|
|
[ -n "$enc" ] && printf 'repo=%s' "$enc"
|
|
}
|