Files
FabledGrimoire/plugin/hooks/scribe_shape_check.sh
T
bvandeusenandClaude Opus 5.5 17777e9bb1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 39s
docs(6204): shipped instruction text describes the product, not this instance's records
MCP tool docstrings and comments, the server's orientation notes, the plugin
skills, every hook script and the plugin README/PACKAGING drop record ids,
milestone numbers, dates and incident stories, and state each non-obvious why
as a present-tense fact. History stays in the task logs.

Corrections found on the way:
- plugin README/PACKAGING said every hook is a GET; moment, rule-opened and
  reply-rules POST, so the plugin's key needs write scope. Both now say so,
  name Settings -> MCP Access, and list every wired hook and endpoint.
- the contract-change hint printed "(lesson #4207)", this instance's record,
  to every install. It no longer cites a record.
- tasks.py claimed there is no delete_task; there is.
- rulebooks.py cited delete_event and a design spec that no longer exist.
- scribe_after_write.sh carried an always-empty etag_q; removed.
- test_write_path_trigger pinned "| tac | scribe_defs | head -1", text that
  existed only in a comment about the old code; it now pins the real tail -1
  pipeline.
- docs/development.md: CI runs on Gitea Actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:45:09 -04:00

106 lines
4.3 KiB
Bash

#!/usr/bin/env bash
# Scribe plugin — Stop hook: the turn's new shapes are judged by the agent
# that wrote them.
#
# The write hooks (scribe_prior_art.sh, scribe_after_write.sh) append every
# definition a write names to a session ledger, `<sid>.written.ids`
# (`path<TAB>kind<TAB>name`, see scribe_written_append). At the end of the
# turn this hook sends that ledger to the instance, which answers with what
# nobody has judged yet. If anything is, the hook blocks ONCE, in the server's
# words: the agent that built the code is the one participant who knows what
# it is, and the end of the turn is the last moment that is still true.
#
# THE SAME DISCIPLINE AS the completion-section check in scribe_reply_check.sh:
# - it blocks only on a `reason` the instance returned, which it returns
# only for a block it RECORDED — an unconfigured or unreachable instance
# never stops a session, and every intervention is one the numbers see;
# - never twice for one turn: the stop that follows its own block
# (`stop_hook_active` with this hook's marker present) is recorded as
# judged_after_block / left_after_block and let through;
# - another plugin's block loop is left alone (active, no marker).
#
# THE LEDGER IS CONSUMED, NOT TURN-STAMPED. Everything written since the last
# stop this hook answered is "this turn". It is emptied once the instance has
# answered — after a pass, or after the post-block stop — and KEPT when the
# instance could not be reached, so the question is asked at the next stop
# that can be answered rather than silently dropped.
#
# Config (same as the other hooks):
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
set -uo pipefail
command -v curl >/dev/null 2>&1 || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# Stop delivers { session_id, transcript_path, cwd, hook_event_name, stop_hook_active }.
event=$(cat 2>/dev/null || true)
event_flat=$(printf '%s' "$event" | scribe_json_flat)
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 "$session_id" ] || exit 0
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
ledger="${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.written.ids"
state_dir="${TMPDIR:-/tmp}/scribe-shapecheck"
mkdir -p "$state_dir" 2>/dev/null || true
marker="$state_dir/${safe_sid}.blocked"
if [ "$active" = "true" ]; then
# A Stop hook already blocked this stop. Another plugin's → nothing to add.
[ -f "$marker" ] || exit 0
phase=after
else
rm -f "$marker" 2>/dev/null || true
phase=check
fi
if [ ! -s "$ledger" ]; then
rm -f "$marker" 2>/dev/null || true
exit 0
fi
scribe_config || exit 0
# Unique lines, oldest first, capped: the request stays a GET (a read-scoped
# key runs the plugin), and a turn that wrote more than this generated code.
written=$(awk '!seen[$0]++' "$ledger" 2>/dev/null | head -n 80)
written_enc=$(printf '%s' "$written" | scribe_urlenc) || exit 0
dir="${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}"
q="phase=${phase}&written=${written_enc}"
scope=$(scribe_scope_query "$dir")
[ -n "$scope" ] && q="${q}&${scope}"
# The repo rides along even when a marker names the project: the instance
# needs it to tell the agent which repo a just-written shape belongs to.
case "$scope" in
repo=*) ;;
*)
remote=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
if [ -n "$remote" ]; then
remote_enc=$(printf '%s' "$remote" | scribe_urlenc) || remote_enc=""
[ -n "$remote_enc" ] && q="${q}&repo=${remote_enc}"
fi
;;
esac
answer=$(curl -fsS --max-time 4 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/shape-check?${q}" 2>/dev/null) || exit 0 # unreachable: keep the ledger
if [ "$phase" = "after" ]; then
rm -f "$marker" "$ledger" 2>/dev/null || true
exit 0
fi
reason=$(scribe_json_pick "$(printf '%s' "$answer" | scribe_json_flat)" '.reason')
if [ -z "$reason" ]; then
rm -f "$ledger" 2>/dev/null || true
exit 0
fi
: > "$marker" 2>/dev/null || true
printf '{"decision":"block","reason":"%s"}\n' "$(printf '%s' "$reason" | scribe_json_escape)"
exit 0