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
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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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\""
|
||||
|
||||
@@ -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)
|
||||
#
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user