feat(moments): the reply moment holds a finished reply for one read (milestone 458 step 4b, #4922)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped

The reply is the one act no tool call marks, and it is where "let me
know if it works" gets said. A new Stop hook (scribe_reply_check.sh)
sends the finished reply to POST /api/plugin/reply-rules, which checks
it twice:

- mounted: every unopened RULE on reply.report, plus reply.ask when the
  reply asks a question. Deterministic.
- semantic: the reply's head and tail against every rule's trigger, on a
  new ranked surface, reply_rule. It is the backstop for whatever the
  earlier arms missed. Its floor is its stop bar (default 0.80, budget
  1), with its own Settings dials. The new stop_only stage records
  surfacing for the rule that holds and nothing else, because nothing
  else reached anyone.

Following the operator's ruling from 456 step 8, a rule that holds blocks
once, in the server's words. The hook blocks only on a reason it was
given, so an unreachable instance never stops a session, and it never
holds the rewrite. The ledger is the act checkpoint's own, so a rule
holds a session once across both doors and the per-session cap counts
both.

The turn reader moved from the report check into scribe_defs.sh
(scribe_turn_facts / scribe_turn_fact), so the two Stop hooks read a
turn the same way. The output was checked identical on a real transcript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 12:30:07 -04:00
co-authored by Claude Opus 5.5
parent b3b616b20a
commit c6cdfc2172
17 changed files with 740 additions and 59 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.05.1624",
"version": "2026.10.05.1630",
"author": {
"name": "Bryan Van Deusen"
},
+1
View File
@@ -41,6 +41,7 @@ another one means adding files, not moving or rewriting any.
| `hooks/scribe_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. |
| `hooks/scribe_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Scribe's own (their responses carry `moment_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_reply_check.sh` | Stop: sends the finished reply to `POST /api/plugin/reply-rules` — the rules mounted on the reply moments, and the reply against every rule's trigger; holds once per rule, with the reason the server returns, and never holds the rewrite. |
| `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. |
+4
View File
@@ -108,6 +108,10 @@
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_report_check.sh\""
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_reply_check.sh\""
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_shape_check.sh\""
+68
View File
@@ -1226,6 +1226,74 @@ scribe_clear_session_ledgers() {
return 0
}
# ---------------------------------------------------------------------------
# WHAT HAPPENED IN THE LAST TURN of a transcript (#4107): 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 (milestone 458). 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. The predecessor parsed all of it and threw most away, which jq
# could afford and a parser written in awk cannot: measured at 6.5s for a 7MB
# window against 94ms, 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? (#4085)
#
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Scribe — Stop hook: the reply moment (milestone 458 step 4, folded in from
# milestone 456 step 8).
#
# 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.
#
# THE SAME CONTRACT AS scribe_report_check.sh, and for its reasons:
# - 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')
# The rewrite after a hold goes out as written.
[ "$active" = "true" ] && exit 0
[ -n "$transcript" ] && [ -f "$transcript" ] && [ -n "$session_id" ] || exit 0
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)
[ -n "$(printf '%s' "$reply" | tr -d '[:space:]')" ] || 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
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"
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"}' "$reply_esc" | 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
answer_flat=$(printf '%s' "$answer" | scribe_json_flat)
reason=$(scribe_json_pick "$answer_flat" '.reason')
[ -n "$reason" ] || exit 0
# 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
+3 -50
View File
@@ -78,56 +78,9 @@ grep -q -E '"name":[[:space:]]*"([^"]*__)?(update|create)_task"' < <(tail -c 200
exit 0
}
# The turn, 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, so the hook reports nothing and
# stays out of the way.
window() { tail -n 3000 "$transcript" 2>/dev/null; }
turn_facts() { window | tail -n +"${1:-1}" | scribe_json_flat_lines \
| awk -f "$SCRIBE_HOOK_DIR/scribe_turn.awk" 2>/dev/null; }
# 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 this check never
# looks at. The predecessor parsed all of it and threw most away, which jq
# could afford and a parser written in awk cannot: measured at 6.5s for a 7MB
# window against 94ms, 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.
start=$(window | 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=$(turn_facts "$start")
fact() { printf '%s\n' "$facts" | awk -F'\t' -v k="$1" '$1 == k { print substr($0, index($0, "\t") + 1); exit }'; }
if [ "$(fact bounded)" != "1" ] && [ "$start" != "1" ]; then
facts=$(turn_facts 1)
fi
# The turn, parsed once — scribe_turn_facts, shared with the reply check.
facts=$(scribe_turn_facts "$transcript")
fact() { scribe_turn_fact "$facts" "$1"; }
[ "$(fact bounded)" = "1" ] || exit 0
closed=$(fact closed)