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>
127 lines
5.6 KiB
Bash
127 lines
5.6 KiB
Bash
#!/usr/bin/env bash
|
|
# Grimoire — Stop hook: the reply moment.
|
|
#
|
|
# A reply is the one act no tool call marks, and it is where "please check
|
|
# this on your end" or "it's done" gets said — so every arm that fires on a
|
|
# tool call misses it. This hook sends the finished reply to the server, which
|
|
# checks it twice over: the rules MOUNTED on the reply moments, and the reply
|
|
# text against every rule's trigger, as the backstop for whatever the earlier
|
|
# arms missed. An unopened rule from either half holds the reply for one read.
|
|
#
|
|
# ONE END-OF-TURN REQUEST. When the turn closed a task, the same request
|
|
# carries the task ids and the server also checks the reply for the completion
|
|
# sections. The section check's outcome is recorded (`report_check`, the
|
|
# adherence number), and when it
|
|
# held the reply, the rewrite is sent back once with `rewrite: true` so the
|
|
# server can record how it came out. A rewrite is never held.
|
|
#
|
|
# THE CONTRACT, and the reasons for it:
|
|
# - the server decides and supplies the words; this hook blocks only on a
|
|
# reason it was given, so an unconfigured or unreachable instance never
|
|
# stops a session;
|
|
# - the rewrite is never held (`stop_hook_active`), so a hold costs one turn
|
|
# at most;
|
|
# - a rule holds a session once. The ledger is the act checkpoint's own
|
|
# (`<sid>.checkpoint.ids`), so a rule that already stopped a command does
|
|
# not stop the reply about it, and the per-session cap counts both doors.
|
|
#
|
|
# Env:
|
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
|
|
|
command -v curl >/dev/null 2>&1 || exit 0
|
|
|
|
# shellcheck source=plugin/hooks/scribe_defs.sh
|
|
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
|
|
|
event=$(cat 2>/dev/null || true)
|
|
event_flat=$(printf '%s' "$event" | scribe_json_flat)
|
|
transcript=$(scribe_json_pick "$event_flat" '.transcript_path')
|
|
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')
|
|
|
|
[ -n "$transcript" ] && [ -f "$transcript" ] && [ -n "$session_id" ] || exit 0
|
|
|
|
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._-' '_')
|
|
rulefile="$state_dir/${safe_sid}.rules.ids"
|
|
stopfile="$state_dir/${safe_sid}.checkpoint.ids"
|
|
# Set when the section check held the reply: the next stop is its rewrite.
|
|
# A ledger by name (`.ids`), so a compaction sweeps it with the rest.
|
|
reportfile="$state_dir/${safe_sid}.reportcheck.ids"
|
|
|
|
# The rewrite after a hold goes out as written. Only a rewrite of a SECTION
|
|
# hold is sent at all — to record how it came out; one after a rule hold, or
|
|
# another plugin's block, has nothing to report.
|
|
if [ "$active" = "true" ]; then
|
|
[ -f "$reportfile" ] || exit 0
|
|
rm -f "$reportfile" 2>/dev/null || true
|
|
rewrite=true
|
|
else
|
|
rm -f "$reportfile" 2>/dev/null || true
|
|
rewrite=false
|
|
fi
|
|
scribe_config || exit 0
|
|
|
|
facts=$(scribe_turn_facts "$transcript")
|
|
[ "$(scribe_turn_fact "$facts" bounded)" = "1" ] || exit 0
|
|
reply=$(scribe_turn_fact "$facts" reply | scribe_json_unescape)
|
|
# The reply may not be in the transcript yet when the hook fires: an empty
|
|
# reply is "cannot tell", never "missing everything".
|
|
[ -n "$(printf '%s' "$reply" | tr -d '[:space:]')" ] || exit 0
|
|
# How many tasks this turn closed, and which — successful closes only, this
|
|
# session only (scribe_turn.awk). A task created already done closes with no
|
|
# id, so the count is what says a check is due. Digits and commas only, so
|
|
# both drop straight into the JSON.
|
|
closed=$(scribe_turn_fact "$facts" closed | tr -cd '0-9')
|
|
closed=${closed:-0}
|
|
task_ids=$(scribe_turn_fact "$facts" task_ids | tr -cd '0-9,' | sed 's/,,*/,/g; s/^,//; s/,$//')
|
|
[ "$rewrite" = "true" ] && [ "$closed" = "0" ] && exit 0
|
|
|
|
# Bounded before encoding. The server reads the head and the tail — the part
|
|
# of a report that asks something of the reader is at its end — so a very
|
|
# long reply keeps both.
|
|
if [ "${#reply}" -gt 12000 ]; then
|
|
reply="${reply:0:4000} … ${reply: -8000}"
|
|
fi
|
|
reply_esc=$(printf '%s' "$reply" | scribe_json_escape) || exit 0
|
|
closing=""
|
|
if [ "$closed" != "0" ]; then
|
|
closing=$(printf ',"closed":%d,"closed_task_ids":[%s],"rewrite":%s' "$closed" "$task_ids" "$rewrite")
|
|
fi
|
|
|
|
query=""
|
|
scope=$(scribe_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
|
|
[ -n "$scope" ] && query="&${scope}"
|
|
rule_seen=$(scribe_rules_live "$rulefile")
|
|
[ -n "$rule_seen" ] && query="${query}&exclude_rule_ids=${rule_seen}"
|
|
query="${query}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
|
|
if [ -f "$stopfile" ]; then
|
|
stopped=$(grep -E '^[0-9]+$' "$stopfile" 2>/dev/null | paste -sd, -)
|
|
[ -n "$stopped" ] && query="${query}&stopped_rule_ids=${stopped}"
|
|
fi
|
|
query=${query#&}
|
|
|
|
answer=$(printf '{"reply":"%s"%s}' "$reply_esc" "$closing" | curl -fsS --max-time 6 \
|
|
-H "Authorization: Bearer ${token}" \
|
|
-H "Content-Type: application/json" \
|
|
--data-binary @- \
|
|
"${url%/}/api/plugin/reply-rules${query:+?$query}" 2>/dev/null) || exit 0
|
|
|
|
[ "$rewrite" = "true" ] && exit 0
|
|
answer_flat=$(printf '%s' "$answer" | scribe_json_flat)
|
|
reason=$(scribe_json_pick "$answer_flat" '.reason')
|
|
[ -n "$reason" ] || exit 0
|
|
[ "$(scribe_json_pick "$answer_flat" '.report_check')" = "blocked" ] && : > "$reportfile" 2>/dev/null
|
|
|
|
# Recorded BEFORE the block is emitted, for the act checkpoint's reason: a
|
|
# hold that is shown and not recorded is one that can be shown again.
|
|
for id in $(scribe_json_list "$answer_flat" '.rule_ids'); do
|
|
scribe_checkpoint_allowed "$stopfile" "$id" || true
|
|
done
|
|
scribe_json_list "$answer_flat" '.rule_ids' | scribe_rules_append "$rulefile"
|
|
|
|
printf '{"decision":"block","reason":"%s"}\n' "$(printf '%s' "$reason" | scribe_json_escape)"
|
|
exit 0
|