diff --git a/plugins/boxlite-agent-tooling/.agents/hooks/README.md b/plugins/boxlite-agent-tooling/.agents/hooks/README.md index 0971884..fac56c0 100644 --- a/plugins/boxlite-agent-tooling/.agents/hooks/README.md +++ b/plugins/boxlite-agent-tooling/.agents/hooks/README.md @@ -115,6 +115,11 @@ flowchart TD | **Incomplete snapshot** | Gate | Evidence could not be captured completely | | **Dossier** | Auditor | Its assessment: PASS, FAIL, or IN_PROGRESS, with findings | +The gate reads the transcript back from its end to the last real user message +([reader](../lib/verdict-audit-state.sh)). It decodes at most 64 MiB of that final +turn and only hashes earlier history, so a long session never makes evidence +incomplete ([#262](https://github.com/boxlite-ai/agent-tooling/issues/262)). + The runner gives the auditor a [private copy of the snapshot](audit/run-verdict-audit.sh). Re-entry means running the gate again to validate the returned dossier against the current session, audit generation, and repository state. @@ -123,7 +128,7 @@ current session, audit generation, and repository state. The regression test follows this sequence: -1. The **65 MiB original transcript** exceeds the reader's limit. +1. The **65 MiB original transcript** holds only NUL bytes, which the reader refuses. 2. The gate saves an **incomplete snapshot** containing `truncated: true` and `records: []`. 3. The test deletes the **original transcript**. 4. The fix preserves the snapshot through retries and reloads it during **gate re-entry**. diff --git a/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.sh b/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.sh index f8b4b61..88978ca 100755 --- a/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.sh +++ b/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.sh @@ -240,7 +240,7 @@ classifier_timeout_seconds=20 classifier_max_bytes=12000 classifier_output_max_bytes=4096 verdict_output_max_bytes=8192 -transcript_source_max_bytes=67108864 +extractor_transcript_max_bytes=67108864 transcript_turn_max_bytes=262144 transcript_snapshot_truncated=false @@ -1623,7 +1623,7 @@ prepare_audit_transcript() { mkdir -p "$(dirname "$payload_transcript_file")" 2>/dev/null || return 1 if [[ -n "${VERDICT_EXTRACTOR_CMD:-}" ]] \ && raw_snapshot="$(verdict_audit_read_regular_state \ - "$source_path" "$transcript_source_max_bytes" json 2>/dev/null)" \ + "$source_path" "$extractor_transcript_max_bytes" json 2>/dev/null)" \ && [[ "$raw_snapshot" == *$'\n'* ]]; then raw_body="${raw_snapshot#*$'\n'}" if ! staged_identity="$(printf '%s' "$raw_body" \ @@ -1666,7 +1666,7 @@ prepare_audit_transcript() { fi elif staged_identity="$(verdict_audit_snapshot_final_turn_identity \ "$source_path" "$staged" \ - "$transcript_source_max_bytes" "$transcript_turn_max_bytes")"; then + "$verdict_audit_final_turn_max_bytes" "$transcript_turn_max_bytes")"; then snapshot_state="$(verdict_audit_read_regular_state \ "$staged" "$transcript_turn_max_bytes" json \ "$staged_identity" 2>/dev/null)" || snapshot_state="" diff --git a/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.test.sh b/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.test.sh index 0e4f766..8e30ea4 100755 --- a/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.test.sh +++ b/plugins/boxlite-agent-tooling/.agents/hooks/audit/preflight-verdict-check.test.sh @@ -3069,6 +3069,111 @@ else fail=$((fail+1)); printf ' FAIL %s (out=%s)\n' "codex turn-level" "$out" fi; rm -rf "$R" +echo +echo "## Final-turn reader: parsing tracks the turn, not the session" +# The reader scans back from the end of the transcript to the last real user message, +# so earlier history is hashed, never parsed. A whole-file cap made every Stop after a +# session passed 64 MiB fail as unreadable evidence (#262). +R="$(setup)" +perl -e 'my $pad = "x" x 1048000; print qq({"type":"progress","data":"$pad"}\n) for 1 .. 66' \ + > "$R/transcript.jsonl" +append_user "$R" "How is the session going?" +append_assistant "$R" "Nothing here is a verdict; just chatting." +long_decision="$(decide "$R")" +long_rung="none" +grep -q 'extract truncated-block' "$R"/.agents/state/verdict-decisions.log* 2>/dev/null \ + && long_rung="extract truncated-block" +if [[ "$long_decision" == allow && "$long_rung" == none ]]; then + pass=$((pass + 1)) + printf ' PASS a 66 MiB session with a small final turn is judged, not truncated\n' +else + fail=$((fail + 1)) + printf ' FAIL a 66 MiB session with a small final turn is judged, not truncated (decision=%s rung=%s)\n' \ + "$long_decision" "$long_rung" +fi +rm -rf "$R" + +snapshot_of() { # transcript turn-max -> snapshot JSON; non-zero when the reader refuses + local dir rc=0 + dir="$(mktemp -d)" + ( source "$REPO_ROOT/.agents/lib/verdict-audit-state.sh" + verdict_audit_snapshot_final_turn "$1" "$dir/snapshot.json" "$2" 262144 ) 2>/dev/null \ + && cat "$dir/snapshot.json" || rc=1 + rm -rf "$dir" + return "$rc" +} +sha256_of() { perl -MDigest::SHA -e 'print Digest::SHA->new(256)->addfile($ARGV[0])->hexdigest' "$1"; } + +# Lines come back exactly as a forward read gives them: one spanning several 64 KiB +# blocks, a blank line, CRLF, and a last line without its LF. +R="$(mktemp -d)"; t="$R/transcript.jsonl" +long_claim="$(perl -e 'print "y" x 153600')" +jq -nc '{type:"assistant", message:{content:[{type:"text",text:"OLD_TURN_CLAIM"}]}}' > "$t" +jq -nc '{type:"user", message:{content:[{type:"text",text:"go on"}]}}' >> "$t" +jq -nc --arg c "$long_claim" '{type:"assistant", message:{content:[{type:"text",text:$c}]}}' >> "$t" +printf '\n' >> "$t" +printf '%s\r\n' "$(jq -nc '{type:"assistant", message:{content:[{type:"text",text:"crlf"}]}}')" >> "$t" +printf '%s' "$(jq -nc '{type:"assistant", message:{content:[{type:"text",text:"no final LF"}]}}')" >> "$t" +boundary_snapshot="$(snapshot_of "$t" 67108864)" || boundary_snapshot="" +boundary_texts="$(jq -c '[.records[] | select(.kind == "assistant") | .texts[]]' \ + <<<"$boundary_snapshot" 2>/dev/null)" +if [[ "$boundary_texts" == "$(jq -nc --arg c "$long_claim" '[$c, "crlf", "no final LF"]')" \ + && "$(jq -r '.source_sha256' <<<"$boundary_snapshot")" == "$(sha256_of "$t")" \ + && "$(jq -r '.source_bytes' <<<"$boundary_snapshot")" == "$(wc -c < "$t" | tr -d ' ')" ]]; then + pass=$((pass + 1)) + printf ' PASS block-spanning, blank, CRLF and unterminated lines read as a forward read does\n' +else + fail=$((fail + 1)) + printf ' FAIL block-spanning, blank, CRLF and unterminated lines read as a forward read does (texts=%.80s)\n' \ + "$boundary_texts" +fi +rm -rf "$R" + +# The decode bound covers the final turn, not the file. +R="$(mktemp -d)"; t="$R/transcript.jsonl" +perl -e 'print qq({"type":"progress","data":"), "z" x 100000, qq("}\n)' > "$t" +jq -nc '{type:"user", message:{content:[{type:"text",text:"go on"}]}}' >> "$t" +jq -nc '{type:"assistant", message:{content:[{type:"text",text:"small turn"}]}}' >> "$t" +small_turn_rc=0; snapshot_of "$t" 4096 >/dev/null || small_turn_rc=$? +jq -nc --arg c "$(perl -e 'print "w" x 8192')" \ + '{type:"assistant", message:{content:[{type:"text",text:$c}]}}' >> "$t" +large_turn_rc=0; snapshot_of "$t" 4096 >/dev/null || large_turn_rc=$? +if [[ "$small_turn_rc" == 0 && "$large_turn_rc" != 0 ]]; then + pass=$((pass + 1)); printf ' PASS the decode bound covers the final turn, not the file\n' +else + fail=$((fail + 1)) + printf ' FAIL the decode bound covers the final turn, not the file (small=%s large=%s)\n' \ + "$small_turn_rc" "$large_turn_rc" +fi +rm -rf "$R" + +# History is hashed, not parsed: NUL anywhere still fails, as does a malformed final-turn +# line, but a malformed line before the last real user message no longer does. +R="$(mktemp -d)" +for history_case in nul malformed-turn malformed-history; do + t="$R/$history_case.jsonl" + case "$history_case" in + nul) printf '{"type":"progress","data":"a\0b"}\n' > "$t" ;; + malformed-turn) printf '{"type":"progress","data":"ok"}\n' > "$t" ;; + malformed-history) printf '{"type":"progress","data":\n' > "$t" ;; + esac + jq -nc '{type:"user", message:{content:[{type:"text",text:"go on"}]}}' >> "$t" + jq -nc '{type:"assistant", message:{content:[{type:"text",text:"turn"}]}}' >> "$t" + [[ "$history_case" == malformed-turn ]] && printf '{"type":"assistant","message":\n' >> "$t" +done +nul_rc=0; snapshot_of "$R/nul.jsonl" 67108864 >/dev/null || nul_rc=$? +turn_rc=0; snapshot_of "$R/malformed-turn.jsonl" 67108864 >/dev/null || turn_rc=$? +history_rc=0; snapshot_of "$R/malformed-history.jsonl" 67108864 >/dev/null || history_rc=$? +if [[ "$nul_rc" != 0 && "$turn_rc" != 0 && "$history_rc" == 0 ]]; then + pass=$((pass + 1)) + printf ' PASS NUL and malformed final-turn lines fail; malformed history is only hashed\n' +else + fail=$((fail + 1)) + printf ' FAIL NUL and malformed final-turn lines fail; malformed history is only hashed (nul=%s turn=%s history=%s)\n' \ + "$nul_rc" "$turn_rc" "$history_rc" +fi +rm -rf "$R" + echo echo "## Harness-agnostic extraction: conventions, not schema lists" R="$(setup)"; write_codex_transcript "$R" "Root cause is the missing bind mount; all tests pass now." @@ -4216,8 +4321,8 @@ echo "## a transcript unreadable at one moment is re-read, not judged incomplete # The live failure: the gate reads the transcript while the harness is still appending # to it, the extractor's two passes disagree on the bytes, and the hollow snapshot it # publishes reads downstream as "evidence incomplete" — forcing a FAIL dossier about a -# turn nobody actually looked at. A regular file the extractor refuses on sight (over -# the source ceiling) reproduces that moment deterministically, and a helper swaps in +# turn nobody actually looked at. A regular file the extractor refuses on sight (only +# NUL bytes) reproduces that moment deterministically, and a helper swaps in # the settled transcript inside the retry window. R="$(setup)" write_transcript "$R" "Nothing here is a verdict; just chatting." diff --git a/plugins/boxlite-agent-tooling/.agents/hooks/audit/run-verdict-audit.sh b/plugins/boxlite-agent-tooling/.agents/hooks/audit/run-verdict-audit.sh index 0fdd3cb..ce74d54 100755 --- a/plugins/boxlite-agent-tooling/.agents/hooks/audit/run-verdict-audit.sh +++ b/plugins/boxlite-agent-tooling/.agents/hooks/audit/run-verdict-audit.sh @@ -65,7 +65,6 @@ audit_verified_identity="" audit_success_pending=false transcript_snapshot_dir="" transcript_snapshot_path="" -transcript_source_max_bytes=67108864 transcript_turn_max_bytes=262144 previous_dossier_max_bytes=65536 previous_dossier_input_path="" @@ -754,9 +753,9 @@ snapshot_transcript() { fi elif ! verdict_audit_snapshot_final_turn \ "$transcript_path" "$transcript_snapshot_path" \ - "$transcript_source_max_bytes" "$transcript_turn_max_bytes"; then - printf 'run-verdict-audit.sh: transcript must be stable, regular JSONL no larger than %s bytes; auditor was not started.\n' \ - "$transcript_source_max_bytes" >&2 + "$verdict_audit_final_turn_max_bytes" "$transcript_turn_max_bytes"; then + printf 'run-verdict-audit.sh: transcript must be stable, regular JSONL whose final turn is at most %s bytes; auditor was not started.\n' \ + "$verdict_audit_final_turn_max_bytes" >&2 return 1 fi transcript_path="$transcript_snapshot_path" diff --git a/plugins/boxlite-agent-tooling/.agents/lib/reply-summary.sh b/plugins/boxlite-agent-tooling/.agents/lib/reply-summary.sh index 57c5759..d1ec1e4 100644 --- a/plugins/boxlite-agent-tooling/.agents/lib/reply-summary.sh +++ b/plugins/boxlite-agent-tooling/.agents/lib/reply-summary.sh @@ -8,8 +8,7 @@ # Readability thresholds are independent of the shared prompt's prose budget. reply_summary_paragraph_max_words=80 reply_summary_item_max_words=40 -# The same bounds the verdict check puts on the transcript it reads. -reply_summary_transcript_max_bytes=67108864 +# The same snapshot bound the verdict check puts on the transcript it reads. reply_summary_snapshot_max_bytes=262144 # A word is a whitespace-separated token holding a letter or digit, so table pipes, @@ -127,8 +126,9 @@ reply_summary_is_dense() { # text _reply_summary_snapshot() { # transcript-path scratch-dir -> bounded JSON local snapshot="$2/final-turn.json" identity state [[ -n "${1-}" && -f "$1" && ! -L "$1" && -d "${2-}" ]] || return 1 + # shellcheck disable=SC2154 # Assigned by verdict-audit-state.sh, which callers source. identity="$(verdict_audit_snapshot_final_turn_identity "$1" "$snapshot" \ - "$reply_summary_transcript_max_bytes" "$reply_summary_snapshot_max_bytes" \ + "$verdict_audit_final_turn_max_bytes" "$reply_summary_snapshot_max_bytes" \ 2>/dev/null)" || return 1 state="$(verdict_audit_read_regular_state "$snapshot" \ "$reply_summary_snapshot_max_bytes" json "$identity" 2>/dev/null)" || return 1 diff --git a/plugins/boxlite-agent-tooling/.agents/lib/verdict-audit-state.sh b/plugins/boxlite-agent-tooling/.agents/lib/verdict-audit-state.sh index c41d383..012dbad 100644 --- a/plugins/boxlite-agent-tooling/.agents/lib/verdict-audit-state.sh +++ b/plugins/boxlite-agent-tooling/.agents/lib/verdict-audit-state.sh @@ -9,6 +9,11 @@ verdict_audit_lock_max_seconds=3600 # shellcheck disable=SC2034 # Public constants for scripts that source this library. verdict_audit_deadline_slack_seconds=60 +# Most transcript bytes the final-turn reader decodes, from the end of the file back to +# the last real user message. JSON::PP decodes about 5 MiB/s, so this fits the reader's +# 15 s alarm; earlier history is only hashed, so session length alone never trips it. +# shellcheck disable=SC2034 # Public constants for scripts that source this library. +verdict_audit_final_turn_max_bytes=67108864 # Persisted decimal fields are untrusted. Keep them below 10^18 so Bash 3's signed # integer arithmetic cannot wrap a huge value into the live deadline window. The caller @@ -240,19 +245,21 @@ verdict_audit_copy_regular_state() { # source destination max-bytes ' "$1" "$2" "$3" } -# Stream one selected JSONL transcript inode and retain only records after the last real -# user message. Historical turns and harness envelopes never enter the model-visible -# snapshot. Assistant claims stay whole; large tool events become bounded previews plus -# size/hash metadata. Claim truncation is fail-closed. Summarized evidence remains usable -# only when the auditor can independently reproduce what a claim depends on. -_verdict_audit_snapshot_final_turn() { # source destination source-max snapshot-max emit-identity - local source="$1" destination="$2" max_source_bytes="$3" max_snapshot_bytes="$4" +# Select one JSONL transcript inode and retain only records after the last real user +# message. Parsing scans back from the end of the file, so its cost tracks the final turn, +# not the session; earlier history is only hashed. Historical turns and harness envelopes +# never enter the model-visible snapshot. Assistant claims stay whole; large tool events +# become bounded previews plus size/hash metadata. Claim truncation is fail-closed. +# Summarized evidence remains usable only when the auditor can independently reproduce +# what a claim depends on. +_verdict_audit_snapshot_final_turn() { # source destination turn-max snapshot-max emit-identity + local source="$1" destination="$2" max_turn_bytes="$3" max_snapshot_bytes="$4" local emit_identity="$5" source_identity snapshot_identity="" snapshot_bytes - [[ "$max_source_bytes" =~ ^[1-9][0-9]*$ \ + [[ "$max_turn_bytes" =~ ^[1-9][0-9]*$ \ && "$max_snapshot_bytes" =~ ^[1-9][0-9]*$ \ && ( "$emit_identity" == 0 || "$emit_identity" == 1 ) ]] || return 1 source_identity="$(perl -MFcntl=:DEFAULT -e ' - my ($path, $max) = @ARGV; + my ($path) = @ARGV; exit 1 if lstat($path) && -l _; my $flags = O_RDONLY | O_NONBLOCK; $flags |= Fcntl::O_NOFOLLOW() if defined &Fcntl::O_NOFOLLOW; @@ -260,16 +267,15 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot my @opened = stat($fh); my @named = lstat($path); exit 1 unless @opened && @named && -f $fh - && $opened[0] == $named[0] && $opened[1] == $named[1] - && $opened[7] <= $max; + && $opened[0] == $named[0] && $opened[1] == $named[1]; print join(":", @opened[0,1,7,9,10]); - ' "$source" "$max_source_bytes" 2>/dev/null)" || return 1 + ' "$source" 2>/dev/null)" || return 1 if ! snapshot_identity="$( ( set -o pipefail perl -MFcntl=:DEFAULT -MJSON::PP -MDigest::SHA -MEncode=encode_utf8 -MScalar::Util=refaddr -e ' - my ($path, $max_source, $max_snapshot, $expected) = @ARGV; + my ($path, $max_turn, $max_snapshot, $expected) = @ARGV; $SIG{ALRM} = sub { exit 1 }; alarm 15; exit 1 if lstat($path) && -l _; @@ -280,8 +286,7 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot my @named = lstat($path); exit 1 unless @before && @named && -f $fh && join(":", @before[0,1,7,9,10]) eq $expected - && $before[0] == $named[0] && $before[1] == $named[1] - && $before[7] <= $max_source; + && $before[0] == $named[0] && $before[1] == $named[1]; binmode($fh); my $decoder = JSON::PP->new->utf8->allow_nonref; my $encoder = JSON::PP->new->canonical->allow_nonref; @@ -482,32 +487,55 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot return "metadata" if $kind =~ /metadata|snapshot|queue|history/i; return "other"; } - sub reset_turn { - @assistant_records = (); - @tool_entries = (); - $claim_bytes = 0; - $final_turn_bytes = 0; - %omitted = (system => 0, progress => 0, metadata => 0, other => 0); - %omitted_bytes = (system => 0, progress => 0, metadata => 0, other => 0); - $sequence = 0; - } - my $total = 0; - while (defined(my $line = <$fh>)) { - $total += length($line); - exit 1 if $total > $max_source || index($line, "\0") >= 0; - $source_digest->add($line); - next if $line =~ /^\s*$/; + # Scan back from the opened size to the last real user message; only those lines + # are decoded. JSON escapes control characters and UTF-8 never reuses ASCII bytes, + # so every LF ends a record wherever a block boundary falls. + my ($position, $pending, $scanned) = ($before[7], "", 0); + my (@turn_lines, @turn_records); + while (1) { + my $last = length($pending) - 1; + my $newline = $last > 0 ? rindex($pending, "\n", $last - 1) : -1; + my $line; + if ($newline >= 0) { + $line = substr($pending, $newline + 1, length($pending), ""); + } elsif ($position > 0) { + # Stop reading at the turn bound, and read as much again as is pending, so one + # long line costs O(n), not O(n^2). + my $room = $max_turn - $scanned - length($pending); + exit 1 if $room < 0; + my $count = length($pending) > 65536 ? length($pending) : 65536; + $count = $room + 1 if $count > $room + 1; + $count = $position if $count > $position; + $position -= $count; + my $block = ""; + sysseek($fh, $position, 0) or exit 1; + my $read = sysread($fh, $block, $count); + exit 1 unless defined($read) && $read == $count && index($block, "\0") < 0; + $pending = $block . $pending; + next; + } elsif (length($pending)) { + ($line, $pending) = ($pending, ""); + } else { + last; + } + $scanned += length($line); + exit 1 if $scanned > $max_turn; + if ($line =~ /^\s*$/) { + push @turn_lines, $line; + next; + } my $record = eval { $decoder->decode($line) }; exit 1 if $@; # A pre-normalized snapshot is accepted only by the bounded schema path. - # path. Never reinterpret an oversized or malformed snapshot as raw history. + # Never reinterpret an oversized or malformed snapshot as raw history. exit 1 if ref($record) eq "HASH" && ($record->{type} // "") eq "verdict_final_turn_snapshot"; - if (is_real_user($record)) { - reset_turn(); - next; - } + last if is_real_user($record); + push @turn_lines, $line; + push @turn_records, $record; + } + for my $record (reverse @turn_records) { $sequence++; my @tools = tool_objects($record); my @texts = assistant_texts($record); @@ -528,6 +556,28 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot $omitted_bytes{$kind} += $record_bytes; } } + # Hash every byte, so source_sha256 still covers the whole file, and bind the turn + # lines decoded above to the bytes this pass reads. + my ($turn_bytes, $decoded_digest) = (0, Digest::SHA->new(256)); + for my $line (reverse @turn_lines) { + $turn_bytes += length($line); + $decoded_digest->add($line); + } + my $turn_start = $before[7] - $turn_bytes; + my $turn_digest = Digest::SHA->new(256); + my $hashed = 0; + sysseek($fh, 0, 0) or exit 1; + while ($hashed < $before[7]) { + my $chunk = ""; + my $count = sysread($fh, $chunk, 65536); + exit 1 unless $count && index($chunk, "\0") < 0; + $source_digest->add($chunk); + $turn_digest->add(substr($chunk, $turn_start > $hashed ? $turn_start - $hashed : 0)) + if $hashed + $count > $turn_start; + $hashed += $count; + } + exit 1 unless $hashed == $before[7] + && $turn_digest->hexdigest eq $decoded_digest->hexdigest; my $first_digest = $source_digest->hexdigest; my @after = stat($fh); my @named_after = lstat($path); @@ -543,7 +593,7 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot exit 1 unless defined $count; last if $count == 0; $verified_bytes += $count; - exit 1 if $verified_bytes > $max_source; + exit 1 if $verified_bytes > $before[7]; $verify_digest->add($chunk); } my @verified = stat($fh); @@ -633,7 +683,7 @@ _verdict_audit_snapshot_final_turn() { # source destination source-max snapshot exit 1 if length($output) + 1 > $max_snapshot; alarm 0; print STDOUT $output, "\n" or exit 1; - ' "$source" "$max_source_bytes" "$max_snapshot_bytes" "$source_identity" \ + ' "$source" "$max_turn_bytes" "$max_snapshot_bytes" "$source_identity" \ | verdict_audit_write_exclusive_regular_identity "$destination" ) )"; then diff --git a/plugins/boxlite-agent-tooling/ARCHITECTURE.md b/plugins/boxlite-agent-tooling/ARCHITECTURE.md index c6c6816..238c397 100644 --- a/plugins/boxlite-agent-tooling/ARCHITECTURE.md +++ b/plugins/boxlite-agent-tooling/ARCHITECTURE.md @@ -628,6 +628,7 @@ both attempts in history. See the [design](https://github.com/boxlite-ai/agent-t - Agent to auditor: the auditor spec is the only writer of a dossier. Gates read verdicts and never write them. - Gate to state: each artifact is bound to what it judged. Turn dossier: branch, HEAD, tree hash, generation, session scope, prompt epoch; a mismatch is discarded and the Stop gate falls through to fresh detection (`.agents/hooks/audit/preflight-verdict-check.sh:28`). Commit and push dossier: branch, HEAD, the staged or pushed diff, the command, and for a commit the subject, for 5 hours after it is written; a mismatch or an older dossier denies until a fresh audit (`.agents/hooks/preflight-commit-push.sh:677`). Receipt: parent, tree, subject. Research verdict: root, branch, design URL, and the SHA-256 of the content the auditor reviewed (`.agents/lib/design-doc.sh`). - Incomplete evidence: a missing or empty source during refresh retains the published failed snapshot. Audit re-entry reads and validates that bounded snapshot before consuming the dossier; loss of the original source cannot become `empty-allow`. An initially absent or empty transcript still has nothing to judge. +- Final-turn reader: `.agents/lib/verdict-audit-state.sh` reads a transcript back from its end to the last real user message and decodes at most `verdict_audit_final_turn_max_bytes` (64 MiB) of it. Earlier history is only hashed into `source_sha256`, so session length alone never makes evidence incomplete; the reader's 15 s alarm bounds the rest ([#262](https://github.com/boxlite-ai/agent-tooling/issues/262)). - Stop gate to verdict check: after the TL;DR check and timed confirmations, `.agents/hooks/stop-gate.sh` `exec`s `.agents/hooks/audit/preflight-verdict-check.sh` with the payload unchanged, so the check runs as the hook process: its output, stderr, exit status, and signal handling are the hook's ([#255](https://github.com/boxlite-ai/agent-tooling/issues/255)). Missing dependencies fail closed. - Mandatory summary: `.agents/lib/concise-writing.sh` checks for an ATX TL;DR heading outside fences, quotes, and comments, whose first block is a paragraph opening with a word, emphasis, a link, or inline code. A list item, thematic break, link or footnote definition, setext heading, or table cannot stand in for that paragraph ([#257](https://github.com/boxlite-ai/agent-tooling/issues/257)). Human-facing replies must start there; the entire section has at most 39 words using the shared Unicode-aware counter. Each denial is one line: the failed condition, then a literal fix; length failures report the count and limit and ask for a same-level heading after one short sentence. Missing, misplaced, malformed, and overlong TL;DR denials end with `apply boxlite-writing` unless the caller passes `no-hint`. Only the GitHub writing check does, because the PR gate appends its own reminder. Stop checks the TL;DR only when `stop_hook_active` is false: one correction can continue a turn, then the check is skipped, including after unrelated Stop continuations. GitHub and design-document checks remain mandatory. Missing Stop text falls back to a bounded transcript snapshot; unreadable or truncated snapshots fail closed even on continuations. Explicitly empty reply text remains empty without replaying earlier text; verdict auditing still runs. Internal instructions and tool payloads need no summary. Sentence simplicity remains a writing instruction. - Enforcement scope: Stop checks the final reply after generation; it cannot retract streamed commentary. GitHub checks cover recognized CLI calls, not browser or connector writes. Design checks cover fetched GitHub, Linear, and Notion content; native Notion headings retain their structure. Shared guidance applies to every human-facing output across these surfaces.