← back to Ticket Guard

ticket-guard.sh

155 lines

#!/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.
#
# IDENTITY = pid AND start time (2026-09-13, confirmed by a second-model review):
# the status record is keyed by TTY, and a tty is a reusable SLOT (memory:
# tty-is-a-reusable-slot-not-a-session-identity), so 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.
# pid ALONE is not enough — macOS recycles pids, so a dead writer's pid can be
# re-issued to a live session and re-validate a stale record. pid + start time is the
# unique process identity. `ps -o lstart=` is byte-identical to the record's
# owner.started, verified on ttys003.
# Real stale-slot instance caught: ttys073's record named pid 21464 @11:29 while the
# live session was pid 84660 @16:08.
#
# THROTTLE: a genuinely ticketless session would otherwise get this text on EVERY
# prompt forever, burning context to repeat itself. It nudges at most once per
# TICKET_GUARD_THROTTLE_MIN (default 15) per session identity. The FIRST prompt always
# nudges. This is a deliberate, bounded trade of some silence for not nagging — the
# stamp is keyed to pid+start, so a NEW session is never silenced by an old one's stamp.
#
# KNOWN LIMIT (accepted, documented rather than hidden): step 1 proves a ticket was
# DECLARED by the launcher, not that the ticket is still OPEN. A session whose ticket
# was later closed still passes. Verifying liveness would need a `tk` lookup on every
# prompt, which is exactly the cost this hook exists to avoid.
#
# Testability seam (TK-11431 amendment 3) — used ONLY by test-ticket-guard.sh.
# Hook wiring must NEVER pass these:
#   TICKET_GUARD_STATE_DIR, TICKET_GUARD_STAMP_DIR, TICKET_GUARD_THROTTLE_MIN,
#   TICKET_GUARD_FAKE_PID, TICKET_GUARD_FAKE_ARGV, TICKET_GUARD_FAKE_TTY,
#   TICKET_GUARD_FAKE_STARTED
set -u

STATE_DIR="${TICKET_GUARD_STATE_DIR:-$HOME/.local/state/abrams-terminal-status}"
STAMP_DIR="${TICKET_GUARD_STAMP_DIR:-$HOME/.local/state/ticket-guard}"
THROTTLE_MIN="${TICKET_GUARD_THROTTLE_MIN:-15}"

silent(){ exit 0; }

nudge(){
  local why="$1"
  # Throttle: at most one nudge per session identity per THROTTLE_MIN minutes.
  if [ "$THROTTLE_MIN" -gt 0 ] 2>/dev/null; then
    local key stamp
    key="$(printf '%s' "${SELF_TTY:-notty}-${SELF_PID:-0}-${SELF_STARTED:-x}" | tr -c 'A-Za-z0-9._-' '_')"
    stamp="$STAMP_DIR/$key"
    mkdir -p "$STAMP_DIR" 2>/dev/null
    if [ -f "$stamp" ] && [ -z "$(find "$stamp" -maxdepth 0 -mmin "+$THROTTLE_MIN" 2>/dev/null)" ]; then
      silent   # nudged recently for THIS session; stay quiet rather than nag.
    fi
    : > "$stamp" 2>/dev/null
  fi
  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 + start time ----
SELF_PID=""; SELF_TTY=""; SELF_ARGV=""; SELF_STARTED=""
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:-}"; SELF_STARTED="${TICKET_GUARD_FAKE_STARTED:-}"
else
  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>` as literal text INSIDE the
# prompt argument, so it is genuinely in argv (verified against live ps output), not
# merely in the environment. IDNUM is the DRIVING ticket's number alone, which makes
# it unambiguous by construction (same signal as ticket_binding.py's 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 status record ----
# No controlling tty (headless `claude -p`, a detached session): the record is keyed
# by tty, so without one there is nothing to safely read. Never probe a degenerate
# path — several ttyless sessions would collide on one filename and inherit each
# other's binding. Fail loud instead.
case "$SELF_TTY" in
  ''|'??'|'?') nudge "no launcher ticket and this session has no controlling tty" ;;
esac

# LAZY: start time is needed only from here on. 45 of 77 live sessions are bound by
# the launcher and returned at step 2 above without paying for this extra ps call.
if [ -z "$SELF_STARTED" ] && [ -z "${TICKET_GUARD_FAKE_PID:-}" ]; then
  SELF_STARTED="$(ps -o lstart= -p "$SELF_PID" 2>/dev/null | sed 's/^ *//; s/ *$//')"
fi

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" SELF_STARTED="$SELF_STARTED" python3 - <<'PY' 2>/dev/null
import json, os, sys
try:
    d = json.load(open(os.environ["REC"]))
except Exception:
    print("UNMEASURED status record is unreadable"); sys.exit(0)
owner = d.get("owner") or {}
live_pid, live_started = os.environ["SELF_PID"], os.environ["SELF_STARTED"].strip()
if str(owner.get("pid", "")) != str(live_pid):
    print("UNMEASURED status record belongs to a previous session on this tty")
    sys.exit(0)
rec_started = str(owner.get("started", "")).strip()
# pid matched — but pids are recycled, so the start time must match too, else this
# record was written by a DEAD process that merely happened to hold this pid.
if live_started and rec_started and rec_started != live_started:
    print("UNMEASURED status record pid was recycled from a previous process")
    sys.exit(0)
if not live_started or not rec_started:
    print("UNMEASURED could not confirm process identity (missing start time)")
    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