← back to Ticket Guard
ticket-guard: UserPromptSubmit hook that catches ticketless sessions at first action (TK-11621)
153f41045eba6428d2ecf87b21dd8382c0eb48fe · 2026-09-13 16:27:43 -0700 · Steve
Enforces CLAUDE.md's every-action-rides-a-ticket rule mechanically instead of
relying on model memory. Fires on UserPromptSubmit (not SessionStart) so an idle
restored session is never made to invent a ticket for work it is not doing.
Fail-loud: an unmeasured binding is never treated as bound (TK-11431 amd 1).
Identity-checks the tty status record against the live pid to defeat the
reusable-tty-slot false green. 60-70ms vs the 18-27s full scan.
Ships with a negative test (8 cases, all injected faults go red).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkVx1x2ppxm6tdEXngBj2P
Files touched
A .gitignoreA test-ticket-guard.shA ticket-guard.sh
Diff
commit 153f41045eba6428d2ecf87b21dd8382c0eb48fe
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Sep 13 16:27:43 2026 -0700
ticket-guard: UserPromptSubmit hook that catches ticketless sessions at first action (TK-11621)
Enforces CLAUDE.md's every-action-rides-a-ticket rule mechanically instead of
relying on model memory. Fires on UserPromptSubmit (not SessionStart) so an idle
restored session is never made to invent a ticket for work it is not doing.
Fail-loud: an unmeasured binding is never treated as bound (TK-11431 amd 1).
Identity-checks the tty status record against the live pid to defeat the
reusable-tty-slot false green. 60-70ms vs the 18-27s full scan.
Ships with a negative test (8 cases, all injected faults go red).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DkVx1x2ppxm6tdEXngBj2P
---
.gitignore | 8 +++++
test-ticket-guard.sh | 72 ++++++++++++++++++++++++++++++++++++++
ticket-guard.sh | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 177 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/test-ticket-guard.sh b/test-ticket-guard.sh
new file mode 100644
index 0000000..83c2dbc
--- /dev/null
+++ b/test-ticket-guard.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# test-ticket-guard.sh — NEGATIVE TEST for ticket-guard.sh (CLAUDE.md TK-11431 amendment 3).
+# A positive-only test on a detector proves nothing: it confirms the happy path and
+# leaves the entire purpose of the component unverified. Each case below INJECTS a
+# fault and asserts the guard goes RED (nudges), alongside the must-stay-silent cases.
+#
+# Run: bash test-ticket-guard.sh (tests the repo copy)
+# HOOK=~/.claude/hooks/ticket-guard.sh bash test-ticket-guard.sh (tests the installed copy)
+set -u
+HOOK="${HOOK:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ticket-guard.sh}"
+TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+pass=0; fail=0
+ck(){ # ck <name> <expect: NUDGE|SILENT> <output>
+ local n="$1" want="$2" out="$3" got
+ if printf '%s' "$out" | grep -q 'ticket-guard'; then got=NUDGE; else got=SILENT; fi
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); printf ' ok %-52s -> %s\n' "$n" "$got"
+ else fail=$((fail+1)); printf ' FAIL %-52s -> %s (wanted %s)\n' "$n" "$got" "$want"; fi
+}
+mk(){ printf '%s' "$2" > "$TMP/$1.json"; }
+
+# 1. BOUND by launcher argv (a run-ticket.sh session) -> must stay SILENT
+out="$(TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=111 TICKET_GUARD_FAKE_TTY=ttysA \
+ TICKET_GUARD_FAKE_ARGV='claude --model opus export TK_AGENT=claude-run-11621. You are driving ticket TK-11621' bash "$HOOK")"
+ck "bound via launcher argv" SILENT "$out"
+
+# 2. INJECTED FAULT: unbound session, no record at all -> must go RED
+out="$(TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=222 TICKET_GUARD_FAKE_TTY=ttysMISSING \
+ TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
+ck "FAULT: bare claude, no status record" NUDGE "$out"
+
+# 3. BOUND by an identity-MATCHED record -> SILENT
+mk ttysB '{"owner":{"pid":333,"tty":"ttysB"},"ticket":"TK-9999"}'
+out="$(TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=333 TICKET_GUARD_FAKE_TTY=ttysB \
+ TICKET_GUARD_FAKE_ARGV='claude --continue' bash "$HOOK")"
+ck "bound via own identity-matched record" SILENT "$out"
+
+# 4. THE FALSE-GREEN CASE: stale slot. Record carries a real TK-#### but belongs to a
+# DEAD session that previously held this tty. A naive read would go SILENT and
+# silence the guard precisely when it is needed. Must go RED.
+# (Real instance observed on ttys073: record named pid 21464 @11:29 while the live
+# session was pid 84660 @16:08.)
+mk ttysC '{"owner":{"pid":21464,"tty":"ttysC"},"ticket":"TK-11155"}'
+out="$(TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=84660 TICKET_GUARD_FAKE_TTY=ttysC \
+ TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
+ck "FAULT: stale-slot record from a previous session" NUDGE "$out"
+
+# 5. INJECTED FAULT: record present, identity matches, but no ticket -> RED
+mk ttysD '{"owner":{"pid":444,"tty":"ttysD"},"ticket":"","ticket_source":"unbound"}'
+out="$(TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=444 TICKET_GUARD_FAKE_TTY=ttysD \
+ TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
+ck "FAULT: matched record but ticket empty" NUDGE "$out"
+
+# 6. INJECTED FAULT: corrupt/unreadable record -> UNMEASURED -> RED, never silent
+printf '%s' '{not json' > "$TMP/ttysE.json"
+out="$(TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=555 TICKET_GUARD_FAKE_TTY=ttysE \
+ TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
+ck "FAULT: corrupt record (unmeasured must not pass)" NUDGE "$out"
+
+# 7. A near-miss argv must NOT be accepted as a binding (guards the regex itself):
+# a session that merely MENTIONS a ticket in prose is not bound by the launcher.
+out="$(TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=777 TICKET_GUARD_FAKE_TTY=ttysG \
+ TICKET_GUARD_FAKE_ARGV='claude --continue please look at TK-11621 and TK_AGENT=someone' bash "$HOOK")"
+ck "FAULT: prose mention of a ticket is not a binding" NUDGE "$out"
+
+# 8. Emitted JSON must be valid and carry the right hook event (a malformed hook is inert)
+out="$(TICKET_GUARD_STATE_DIR=$TMP TICKET_GUARD_FAKE_PID=666 TICKET_GUARD_FAKE_TTY=ttysF \
+ TICKET_GUARD_FAKE_ARGV='claude' bash "$HOOK")"
+if printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["hookSpecificOutput"]["hookEventName"]=="UserPromptSubmit"; assert "ticket" in d["hookSpecificOutput"]["additionalContext"]' 2>/dev/null; then
+ pass=$((pass+1)); echo " ok emitted JSON is valid UserPromptSubmit context"
+else fail=$((fail+1)); echo " FAIL emitted JSON invalid"; fi
+
+echo; echo "pass=$pass fail=$fail"; [ "$fail" -eq 0 ]
diff --git a/ticket-guard.sh b/ticket-guard.sh
new file mode 100755
index 0000000..5877fab
--- /dev/null
+++ b/ticket-guard.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+# ticket-guard.sh — UserPromptSubmit hook. TK-11621.
+#
+# WHY: CLAUDE.md's hard rule is "every agent ACTION rides a ticket", but the only
+# thing enforcing it was model memory. run-ticket.sh bakes a ticket into sessions
+# it launches; every OTHER spawn (a hand-opened `claude`, an iterm-restore
+# `claude --continue`) starts unbound and stays unbound silently. TK-11621 measured
+# 32 live unbound sessions against 45 bound.
+#
+# WHY UserPromptSubmit AND NOT SessionStart: a restored session sitting idle at its
+# prompt has taken NO action and must NOT be made to invent a ticket — that is ticket
+# spam for work that does not exist. This fires at the moment an action becomes
+# imminent (a prompt arrives), which is exactly where the rule actually binds.
+#
+# FAIL-LOUD (CLAUDE.md TK-11431 amendment 1): an UNMEASURED binding is never treated
+# as bound. Every uncertain path nudges. The nudge is self-guarded and costs an
+# already-ticketed session nothing, so a false nudge is cheap; a false SILENCE is the
+# harmful direction and is what this refuses to produce.
+#
+# STALE-SLOT TRAP (memory: tty-is-a-reusable-slot-not-a-session-identity): the store
+# record is keyed by TTY, and a tty is a reusable SLOT. A fresh unbound session can
+# inherit a DEAD session's record still carrying that session's TK-#### — reading it
+# naively yields a false "bound" and silences the guard exactly when it is needed.
+# So a store record is trusted ONLY when its owner pid AND start time match the live
+# session. Verified on ttys073 (record named pid 21464 @11:29 while live pid was
+# 84660 @16:08 — a naive read would have passed a different session's ticket).
+#
+# Testability seam (TK-11431 amendment 3) — used ONLY by test-ticket-guard.sh:
+# TICKET_GUARD_STATE_DIR, TICKET_GUARD_FAKE_PID, TICKET_GUARD_FAKE_ARGV,
+# TICKET_GUARD_FAKE_TTY. Hook wiring must NEVER pass these.
+set -u
+
+STATE_DIR="${TICKET_GUARD_STATE_DIR:-$HOME/.local/state/abrams-terminal-status}"
+
+silent(){ exit 0; }
+
+nudge(){
+ local why="$1"
+ cat <<JSON
+{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"[ticket-guard] This session has NO ticket bound (${why}). CLAUDE.md hard rule: every agent action rides a ticket. BEFORE acting, bind one: export TK_AGENT=<short-role-name>; tk inbox; then EITHER tk take TK-<n> if this continues known work, OR tk new \"<short title of what you are actually about to do>\" -p <project>. Then tk log every action and tk comment your reasoning. If a ticket is genuinely already bound, do nothing and proceed. Never invent a ticket for work you are not actually doing. Gated actions (customer-facing / destructive / spend / DNS / publish / send-to-list / remote-push / canonical dw_unified or Shopify writes) still draft to ~/.claude/yolo-queue/pending-approval/ and STOP."}}
+JSON
+ exit 0
+}
+
+# ---- 1. Resolve THIS session's own runtime pid + tty (walk the ppid chain) ----
+if [ -n "${TICKET_GUARD_FAKE_PID:-}" ]; then
+ SELF_PID="$TICKET_GUARD_FAKE_PID"; SELF_TTY="${TICKET_GUARD_FAKE_TTY:-ttysTEST}"
+ SELF_ARGV="${TICKET_GUARD_FAKE_ARGV:-}"
+else
+ SELF_PID=""; SELF_TTY=""; SELF_ARGV=""
+ p=$$
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
+ line="$(ps -o ppid=,tty=,comm= -p "$p" 2>/dev/null)" || break
+ set -- $line; pp="${1:-}"; tt="${2:-}"; cm="${3:-}"
+ case "$cm" in
+ *claude*|*codex*) SELF_PID="$p"; SELF_TTY="$tt"
+ SELF_ARGV="$(ps -o command= -p "$p" 2>/dev/null)"; break ;;
+ esac
+ [ -z "$pp" ] || [ "$pp" = "0" ] || [ "$pp" = "1" ] && break
+ p="$pp"
+ done
+fi
+
+# Could not identify our own process => UNMEASURED => nudge, never silent.
+[ -n "$SELF_PID" ] || nudge "could not resolve this session's own process"
+
+# ---- 2. PRIMARY: launcher-declared ticket in our own argv ----
+# run-ticket.sh bakes `export TK_AGENT=<prefix>-<IDNUM>` where IDNUM is the DRIVING
+# ticket's number alone. Unambiguous by construction (ticket_binding.py AGENT regex).
+if printf '%s' "$SELF_ARGV" | grep -Eqi 'TK_AGENT=(claude-run|codex-run|local-qwen-27b-run|local-qwen-14b-run|local-qwen-14b-mac1-run)-[0-9]+'; then
+ silent
+fi
+
+# ---- 3. SECONDARY: identity-checked store record ----
+REC="$STATE_DIR/${SELF_TTY}.json"
+[ -f "$REC" ] || nudge "no launcher ticket and no status record for ${SELF_TTY}"
+
+VERDICT="$(REC="$REC" SELF_PID="$SELF_PID" python3 - <<'PY' 2>/dev/null
+import json, os, sys
+try:
+ d = json.load(open(os.environ["REC"]))
+except Exception:
+ print("UNMEASURED record unreadable"); sys.exit(0)
+owner = d.get("owner") or {}
+if str(owner.get("pid", "")) != str(os.environ["SELF_PID"]):
+ # Stale-slot: this record belongs to a DIFFERENT session that held this tty.
+ print("UNMEASURED status record belongs to a previous session on this tty")
+ sys.exit(0)
+t = (d.get("ticket") or "").strip()
+print("BOUND" if t.upper().startswith("TK-") else "UNBOUND no ticket on this session's record")
+PY
+)"
+[ -n "$VERDICT" ] || nudge "binding check produced no result"
+case "$VERDICT" in
+ BOUND*) silent ;;
+ *) nudge "${VERDICT#* }" ;;
+esac
(oldest)
·
back to Ticket Guard
·
install.sh: gated, idempotent, self-verifying settings.json ffe9706 →