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:
@@ -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)
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user