feat(shapes): the agent judges what it wrote, at the end of the turn (milestone 439 steps 1-3)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 33s

Recording used to be decided by machinery — the only "record it" prompt
fired when a same-named copy already existed (#2664), so a first instance of
a reusable piece was never asked about, and judgment arrived only through
audits. Now the question is asked where the knowledge is: the end of the
turn that wrote the code, of the agent that wrote it.

- Write hooks keep `<sid>.written.ids` (path, kind, name) for every
  definition a write names; a new file adds a `file` line for its stem — a
  candidate in any language without a framework rule (scribe_written_append).
- Stop hook scribe_shape_check.sh sends the ledger to GET
  /api/plugin/shape-check and blocks once, in the server's words, when
  anything is unjudged. Same discipline as the report check: never twice,
  never without a recorded check, another hook's loop left alone; the ledger
  is kept when the instance cannot be reached.
- shape_ledger.unjudged_shapes: no row, unclassified, scoped and hook stamps
  are unjudged; an agent/audit/import verdict is not. A snippet recorded at
  the shape answers for it until the refresh stamps it canonical.
- services/shape_check owns the reason text and records every outcome in
  app_logs (passed / blocked / judged_after_block / left_after_block).
- classify_shapes(repo=…) judges a shape the ledger has not synced yet via a
  provisional row under a bound repo; the sync confirms it, or vanishes and
  revives it with the verdict intact. An unbound repo is refused.

Plugin version minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:39:36 -04:00
co-authored by Claude Opus 5.5
parent eb5cc6d3a7
commit f1fbdf746a
17 changed files with 854 additions and 9 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.10.01.0326",
"version": "2026.10.01.1239",
"author": {
"name": "Bryan Van Deusen"
},
+1
View File
@@ -40,6 +40,7 @@ another one means adding files, not moving or rewriting any.
| `hooks/scribe_after_write.sh` | PostToolUse on shell commands: the same check for code written through the shell. |
| `hooks/scribe_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. |
| `hooks/scribe_report_check.sh` | Stop: when the turn closed a task, checks the reply for the completion sections and reports to `GET /api/plugin/report-check`; blocks once, with the reason the server returns. |
| `hooks/scribe_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `<sid>.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. |
| `hooks/scribe_sync_processes.sh` + `commands/sync.md` | `GET /api/plugin/processes` → `~/.claude/skills/scribe-proc-*` stubs; `/scribe:sync` on demand. |
| `hooks/scribe_defs.sh` | Shared shell helpers: config, dedup ledgers, outage line. |
| `hooks/scribe_static_context.md` | The adapter static text. |
+9
View File
@@ -91,6 +91,15 @@ On install you'll be asked for:
never blocks twice, and never blocks when the instance did not record the
check (unconfigured or unreachable). Outcomes land in the admin logs under
category `plugin`, action `report_check`.
- `hooks/hooks.json` → a second Stop hook (`hooks/scribe_shape_check.sh`): the
write hooks note every definition a write names (and every new file) in a
session ledger; at the end of the turn this sends them to
`GET /api/plugin/shape-check`, which answers with the ones nobody has judged.
If any are, it blocks once with the server's reason, asking the agent that
wrote them to say what each is — reusable (record a snippet), an instance or
variant of one, or a one-off — in one `classify_shapes` call. Same discipline
as the report check: never twice, never without a recorded check. Outcomes
land under category `plugin`, action `shape_check`.
- `skills/` → the universal process-skills, surfaced by description match.
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
+4
View File
@@ -98,6 +98,10 @@
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_report_check.sh\""
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_shape_check.sh\""
}
]
}
+5
View File
@@ -129,14 +129,19 @@ while IFS= read -r rel_path; do
# The code just written: the ADDED lines of the uncommitted diff for a
# tracked file (sed, not cut: this strips one marker char per line, it is
# not a payload cap), the whole file when untracked.
fresh=""
if git -C "$repo_root" ls-files --error-unmatch -- "$rel_path" >/dev/null 2>&1; then
code=$(git -C "$repo_root" diff -U0 -- "$rel_path" 2>/dev/null | grep '^+' | grep -v '^+++' | sed 's/^+//') || code=""
else
code=$(cat "$file_path" 2>/dev/null) || code=""
fresh="new"
fi
[ -n "$code" ] || continue
# `|| true` inside: an early-exiting `head` must not void its own output (#4042).
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true)
# What this write defined, for the end-of-turn question (milestone 439) —
# recorded before the arms below, which skip a write that defines nothing.
printf '%s\n' "$names" | scribe_written_append "$safe_sid" "$rel_path" "$fresh"
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
# nothing to say; the arms are about shapes.
[ -n "$names" ] || continue
+25
View File
@@ -1158,6 +1158,31 @@ scribe_held_query() {
# the tests read THIS string rather than a copy of it.
SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject"
# THE WRITTEN-SHAPES LEDGER (milestone 439). 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 (lesson #4226) — 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 adds one `file` line named by its stem: a
# component, module or package file is a candidate in its own right, in any
# language, without a framework rule saying so.
# $1 session id (already made filename-safe) $2 repo-relative path
# $3 "new" when the file did not exist before this write
scribe_written_append() {
local sid="$1" rel="$2" fresh="${3:-}" dir stem
if [ -z "$sid" ] || [ -z "$rel" ]; then cat >/dev/null; return 0; fi
dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$dir" 2>/dev/null || true
{
if [ "$fresh" = "new" ]; then
stem=${rel##*/}; stem=${stem%.*}
[ -n "$stem" ] && printf '%s\tfile\t%s\n' "$rel" "$stem"
fi
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
}
scribe_clear_session_ledgers() {
# SPARES `*.keep.ids`, which are evidence rather than exclusions — see
# `scribe_rules_append`. Everything else still goes: the convention is
+7
View File
@@ -201,6 +201,13 @@ derive_exclude_q=""
rule_exclude_q=""
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
# What this write defined, for the end-of-turn question (milestone 439). A
# Write onto a path that does not exist yet creates a file — a candidate in
# its own right. Written before any server call: the ask at the end of the
# turn must not depend on this write's hint having been answered.
fresh=""
[ -e "$file_path" ] || fresh="new"
printf '%s\n' "$shapes" | scribe_written_append "$safe_sid" "$rel_path" "$fresh"
idfile="$state_dir/${safe_sid}.ids"
syncfile="$state_dir/${safe_sid}.sync.ids"
derivefile="$state_dir/${safe_sid}.derive.ids"
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Scribe plugin — Stop hook: the turn's new shapes are judged by the agent
# that wrote them (milestone 439).
#
# 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 scribe_report_check.sh, deliberately:
# - 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