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>
108 lines
4.4 KiB
Awk
108 lines
4.4 KiB
Awk
# Grimoire plugin — what happened in the LAST TURN of a transcript.
|
|
#
|
|
# 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
|
|
# the Stop hooks ask (scribe_reply_check.sh). 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 would be the
|
|
# reply's first word — a defect that hides completely behind a test whose
|
|
# replies are all empty.
|
|
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
|
|
}
|