[object Object]

← back to Terminal Status

Auto-bind a ticket for unbound sessions from the UserPromptSubmit hook (TK-12168)

6d687763a62a1f3a7ba45e7f80f0436c38863cab · 2026-09-24 14:44:43 -0700 · Steve Abrams

ticket_binding.discover() is deliberately read-only, so a plain top-level
interactive session that never ran `tk` and carries no TK- in its argv sits
unbound ("TK REQUIRED") forever. Adds a new `auto-bind` subcommand
(cmd_auto_bind) that runs from working-state.sh on every prompt: if the
session is still unbound after a substantive prompt, bind the TK- it names,
or mint one via `tk new` and bind that. Trivial prompts (ok/yes/bare slash
commands/<3 words) never mint a ticket; a per-tty flock lock + state file
prevent duplicate creation; every `tk` failure fails open and rate-limits
the retry so a broken `tk` binary is never hammered.

Adds explicit_ticket_in_prompt / is_trivial_prompt / short_topic as pure,
unit-tested helpers in ticket_binding.py.

14 new tests (TicketPromptHelperTests + AutoBindTests). Confirmed the
negative-test rule: on the pre-change tree these all ERROR
(AttributeError: no cmd_auto_bind) -- exactly the "TK REQUIRED forever" gap
this closes. Full suite 128/128 after the change (was 114/114 baseline).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V29xaoBwcFwJyR4KC43ptn

Files touched

Diff

commit 6d687763a62a1f3a7ba45e7f80f0436c38863cab
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 14:44:43 2026 -0700

    Auto-bind a ticket for unbound sessions from the UserPromptSubmit hook (TK-12168)
    
    ticket_binding.discover() is deliberately read-only, so a plain top-level
    interactive session that never ran `tk` and carries no TK- in its argv sits
    unbound ("TK REQUIRED") forever. Adds a new `auto-bind` subcommand
    (cmd_auto_bind) that runs from working-state.sh on every prompt: if the
    session is still unbound after a substantive prompt, bind the TK- it names,
    or mint one via `tk new` and bind that. Trivial prompts (ok/yes/bare slash
    commands/<3 words) never mint a ticket; a per-tty flock lock + state file
    prevent duplicate creation; every `tk` failure fails open and rate-limits
    the retry so a broken `tk` binary is never hammered.
    
    Adds explicit_ticket_in_prompt / is_trivial_prompt / short_topic as pure,
    unit-tested helpers in ticket_binding.py.
    
    14 new tests (TicketPromptHelperTests + AutoBindTests). Confirmed the
    negative-test rule: on the pre-change tree these all ERROR
    (AttributeError: no cmd_auto_bind) -- exactly the "TK REQUIRED forever" gap
    this closes. Full suite 128/128 after the change (was 114/114 baseline).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01V29xaoBwcFwJyR4KC43ptn
---
 terminal_status.py      | 183 ++++++++++++++++++++++++++++++++++++++++++-
 test_terminal_status.py | 203 ++++++++++++++++++++++++++++++++++++++++++++++++
 ticket_binding.py       |  59 ++++++++++++++
 3 files changed, 443 insertions(+), 2 deletions(-)

diff --git a/terminal_status.py b/terminal_status.py
index e538186..4dde4e2 100644
--- a/terminal_status.py
+++ b/terminal_status.py
@@ -1422,6 +1422,167 @@ def backfill_heartbeat(painted, floored, skipped, live_owners, *, scan_error=Non
     return hb
 
 
+# --- TK-12168: auto-bind orchestration --------------------------------------
+# ticket_binding.discover() is deliberately READ-ONLY ("never create a second
+# ticket database" -- see its module docstring), so a plain top-level
+# interactive session that never ran `tk` and carries no TK- in its argv sits
+# unbound forever ("TK REQUIRED") no matter how much real work it does. This
+# closes that gap from the UserPromptSubmit hook (working-state.sh, backgrounded
+# so a slow/failed `tk` call never delays the prompt): if the session is still
+# unbound after a SUBSTANTIVE prompt, bind an explicit TK- the prompt names, or
+# mint one via `tk new` and bind that. Every decision point is injectable
+# (tk_new / ticket_set) so the whole flow is testable without a live ledger,
+# a real Store, or a subprocess.
+
+_AUTOBIND_RATE_LIMIT = float(os.environ.get("TERMINAL_STATUS_AUTOBIND_RATE_LIMIT", "20"))
+
+
+def _autobind_state_path(store, owner):
+    d = store.root / ".autobind"
+    d.mkdir(parents=True, exist_ok=True, mode=0o700)
+    return d / (owner.tty + ".json")
+
+
+@contextlib.contextmanager
+def _autobind_lock(path):
+    """Non-blocking advisory lock so two near-simultaneous prompts on the same
+    tty can never both decide to mint a ticket (the "never create duplicates"
+    guard). A caller that loses the race gets `acquired=False` and does
+    nothing -- fail open, the next prompt tries again."""
+    lock_path = path.with_suffix(".lock")
+    with open(lock_path, "a") as fh:
+        try:
+            fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
+        except (BlockingIOError, OSError):
+            yield False
+            return
+        try:
+            yield True
+        finally:
+            fcntl.flock(fh, fcntl.LOCK_UN)
+
+
+def _read_autobind_state(path):
+    try:
+        return json.loads(path.read_text())
+    except (OSError, ValueError):
+        return {}
+
+
+def _write_autobind_state(path, state):
+    try:
+        atomic_write(path, json.dumps(state))
+    except OSError:
+        pass  # best-effort bookkeeping; never let this raise into the hook
+
+
+def _default_tk_new(owner, topic, project):
+    """Shell out to the canonical `tk` CLI. Raises StatusError/OSError/
+    TimeoutExpired on any failure; the caller (cmd_auto_bind) always catches."""
+    tk_bin = Path.home() / "Projects/ticket-system/tk"
+    agent = "claude-%s" % owner.tty
+    env = dict(os.environ, TK_AGENT=agent)
+    out = subprocess.run([str(tk_bin), "new", topic, "-p", project],
+                         capture_output=True, text=True, timeout=15, env=env,
+                         cwd=str(tk_bin.parent))
+    if out.returncode != 0:
+        raise StatusError("tk new failed: " + (out.stderr or out.stdout).strip())
+    m = re.search(r"\bTK-\d+\b", out.stdout)
+    if not m:
+        raise StatusError("tk new produced no parseable ticket id: " + out.stdout.strip())
+    return m.group(0)
+
+
+def _default_ticket_set(store, owner, ticket_id):
+    """Same effect as the `ticket` subcommand: bind explicitly, preserving
+    whatever color/label the session already has."""
+    previous, _ = store.read(owner)
+    store.set(owner, previous["state"] if previous else "none",
+             label_ticket(previous["label"])[0] if previous else "",
+             ticket_update=ticket_id, variant=previous.get("variant", "") if previous else "")
+    # header() is a cosmetic tab-title refresh (adds the "up Nh Mm" suffix) on
+    # top of the dot that set() already painted -- the ticket is already bound
+    # in the record at this point, so a pty write hiccup here must not be
+    # reported as a bind failure (and must never block a headless unit test).
+    try:
+        store.header(owner)
+    except (StatusError, OSError):
+        pass
+
+
+def cmd_auto_bind(store, owner, prompt, project, *, now=None, tk_new=None, ticket_set=None):
+    """Bind `owner`'s tab to a ticket if it is still unbound after a
+    substantive prompt. Returns a dict describing what happened; never raises
+    (every failure mode is caught and reported as an `action`, per the
+    fail-open requirement -- a `tk` outage must never surface as a hook error).
+    """
+    now = now if now is not None else time.time()
+    tk_new = tk_new or _default_tk_new
+    ticket_set = ticket_set or _default_ticket_set
+    state_path = _autobind_state_path(store, owner)
+
+    record, _ = store.read(owner)
+    already = store.ticket_info(owner, record).get("id", "")
+    if already:
+        return {"action": "already-bound", "ticket": already}
+
+    with _autobind_lock(state_path) as acquired:
+        if not acquired:
+            return {"action": "locked"}  # a concurrent prompt is already deciding
+        state = _read_autobind_state(state_path)
+        if state.get("ticket"):
+            # We bound this session before; the record's own ticket field came
+            # back empty (e.g. an explicit /pinkdot --off or a clear). Re-assert
+            # our prior binding instead of minting a second ticket.
+            try:
+                ticket_set(store, owner, state["ticket"])
+            except (StatusError, OSError, subprocess.TimeoutExpired):
+                pass
+            return {"action": "rebound", "ticket": state["ticket"]}
+
+        last_attempt = float(state.get("last_attempt", 0) or 0)
+        if now - last_attempt < _AUTOBIND_RATE_LIMIT:
+            return {"action": "rate-limited"}
+
+        explicit = tickets.explicit_ticket_in_prompt(prompt)
+        if explicit:
+            try:
+                ticket_set(store, owner, explicit)
+            except (StatusError, OSError, subprocess.TimeoutExpired) as exc:
+                _write_autobind_state(state_path, {**state, "last_attempt": now})
+                return {"action": "explicit-bind-failed", "ticket": explicit, "error": str(exc)}
+            _write_autobind_state(state_path, {"ticket": explicit, "bound_at": now})
+            return {"action": "bound-explicit", "ticket": explicit}
+
+        if tickets.is_trivial_prompt(prompt):
+            # No last_attempt write here: the rate limit exists to throttle
+            # repeated FAILED `tk` attempts, not to punish "ok" / "yes" — the
+            # very next substantive prompt must still be free to create.
+            return {"action": "skipped-trivial"}
+
+        topic = tickets.short_topic(prompt)
+        try:
+            new_id = tk_new(owner, topic, project)
+        except (StatusError, OSError, subprocess.TimeoutExpired) as exc:
+            _write_autobind_state(state_path, {**state, "last_attempt": now})
+            return {"action": "create-failed", "error": str(exc)}
+        if not new_id:
+            # Defensive: a well-behaved tk_new raises rather than returning
+            # empty (see _default_tk_new), but never bind an empty id either
+            # way -- that would look like a real dot with no ticket at all.
+            _write_autobind_state(state_path, {**state, "last_attempt": now})
+            return {"action": "create-failed", "error": "tk_new returned no ticket id"}
+        if store.known_tickets is not None:
+            store.known_tickets.add(new_id)  # just minted; discover() hasn't re-scanned yet
+        try:
+            ticket_set(store, owner, new_id)
+        except (StatusError, OSError, subprocess.TimeoutExpired) as exc:
+            _write_autobind_state(state_path, {"ticket": new_id, "bound_at": now})
+            return {"action": "created-bind-failed", "ticket": new_id, "error": str(exc)}
+        _write_autobind_state(state_path, {"ticket": new_id, "bound_at": now})
+        return {"action": "created", "ticket": new_id}
+
+
 def main(argv=None):
     parser = argparse.ArgumentParser(description=__doc__)
     sub = parser.add_subparsers(dest="command", required=True)
@@ -1437,7 +1598,8 @@ def main(argv=None):
                          "the live process table, so a dead/reassigned tty is refused.")
     sv.add_argument("--force", action="store_true", help=argparse.SUPPRESS)
     sv.add_argument("--boot", action="store_true", help=argparse.SUPPRESS)
-    for name in ("set", "clear", "repaint", "current", "start", "ticket", "status", "headers"):
+    for name in ("set", "clear", "repaint", "current", "start", "ticket", "status", "headers",
+                 "auto-bind"):
         cmd = sub.add_parser(name)
         # TK-11791: --force / --boot ride the canonical owner_for_paint rail. --force =
         # conscious bridge opt-in (CLAUDE_COLORDOTS_FORCE=1 also honoured); --boot = an
@@ -1469,6 +1631,11 @@ def main(argv=None):
                                   "(orange/purple/yellow) instead of overwriting it")
         if name == "ticket":
             cmd.add_argument("ticket")
+        if name == "auto-bind":
+            # TK-12168: called from the UserPromptSubmit hook (working-state.sh),
+            # backgrounded, so a slow/failed `tk` call never delays the prompt.
+            cmd.add_argument("--prompt", default="",
+                             help="the user's raw prompt text; read from stdin if omitted")
         cmd.add_argument("--quiet", action="store_true")
     legacy = sub.add_parser("paint-legacy")
     legacy.add_argument("tty")
@@ -1596,7 +1763,7 @@ def main(argv=None):
     # `current --paintable`, the guard /color defers to.
     force = getattr(args, "force", False)
     boot = getattr(args, "boot", False)
-    guarded = args.command in ("set", "clear", "ticket", "set-variant") or (
+    guarded = args.command in ("set", "clear", "ticket", "set-variant", "auto-bind") or (
         args.command == "current" and getattr(args, "paintable", False))
     # TK-11835: a CROSS-TTY write (an external supervisor addressing another
     # session with --tty, or paint-legacy) must fail FAST on a busy target rather
@@ -1655,6 +1822,18 @@ def main(argv=None):
         store.header(caller)
         print(f'/terminal-ticket → {caller.tty} {record["ticket"] or "TK REQUIRED"}')
         return 0
+    if args.command == "auto-bind":
+        prompt = args.prompt
+        if not prompt and not sys.stdin.isatty():
+            try:
+                prompt = sys.stdin.read()
+            except (OSError, ValueError):
+                prompt = ""
+        project = Path.cwd().name
+        result = cmd_auto_bind(store, caller, prompt, project)
+        if not getattr(args, "quiet", False):
+            print(json.dumps(result))
+        return 0
     targets = [caller]
     if args.command != "paint-legacy" and getattr(args, "tty", ""):
         # External agents (e.g. greendot-agent) repaint another session's dot. This is
diff --git a/test_terminal_status.py b/test_terminal_status.py
index f48befe..d5e4fe6 100644
--- a/test_terminal_status.py
+++ b/test_terminal_status.py
@@ -701,6 +701,209 @@ class StatusTests(unittest.TestCase):
         self.assertEqual(record["state"], "purple")
 
 
+class TicketPromptHelperTests(unittest.TestCase):
+    """TK-12168: the pure, side-effect-free detectors cmd_auto_bind builds on."""
+
+    def test_explicit_ticket_extraction(self):
+        self.assertEqual(tb.explicit_ticket_in_prompt("work on TK-500 please"), "TK-500")
+        self.assertEqual(tb.explicit_ticket_in_prompt("tk-500 lowercase too"), "TK-500")
+        self.assertEqual(tb.explicit_ticket_in_prompt("no ticket mentioned here"), "")
+        self.assertEqual(tb.explicit_ticket_in_prompt(""), "")
+        self.assertEqual(tb.explicit_ticket_in_prompt(None), "")
+        # Two distinct ids -> ambiguous, abstain (same rule as AGENT/the bare-id
+        # scan in discover()).
+        self.assertEqual(tb.explicit_ticket_in_prompt("TK-500 or TK-501?"), "")
+        # The SAME id repeated is not ambiguous.
+        self.assertEqual(tb.explicit_ticket_in_prompt("TK-500 ... yeah TK-500"), "TK-500")
+
+    def test_trivial_prompt_guard(self):
+        trivial = ["", "   ", "ok", "OK", "Ok.", "yes", "yes.", "y", "no", "n",
+                   "thanks", "k", "/foo", "/status", "hi there", "two words"]
+        for prompt in trivial:
+            with self.subTest(prompt=prompt):
+                self.assertTrue(tb.is_trivial_prompt(prompt), prompt)
+        substantive = [
+            "fix the login bug on checkout",
+            "/deploy the checkout service now",
+            "why is the build failing today",
+            "TK-500 needs a follow-up patch",
+        ]
+        for prompt in substantive:
+            with self.subTest(prompt=prompt):
+                self.assertFalse(tb.is_trivial_prompt(prompt), prompt)
+
+    def test_short_topic_collapses_and_caps(self):
+        self.assertEqual(tb.short_topic("  fix   the\nlogin   bug  "), "fix the login bug")
+        long_prompt = "x " * 100
+        self.assertEqual(len(tb.short_topic(long_prompt, limit=70)), 70)
+
+
+class AutoBindTests(unittest.TestCase):
+    """TK-12168: a plain top-level interactive session that never ran `tk` and
+    carries no TK- in its argv sits unbound ("TK REQUIRED") forever, because
+    ticket_binding.discover() is deliberately read-only. cmd_auto_bind is the
+    UserPromptSubmit-hook orchestration that closes that gap.
+
+    NEGATIVE-TEST NOTE (CLAUDE.md TK-11431 amendment 3): every test below
+    exercises `ts.cmd_auto_bind`, which did not exist before this change --
+    on the pre-TK-12168 tree these all fail with
+    `AttributeError: module 'terminal_status' has no attribute 'cmd_auto_bind'`,
+    which is exactly the "10/14 tabs show TK REQUIRED forever" bug this closes.
+    """
+
+    def setUp(self):
+        self.temp = tempfile.TemporaryDirectory()
+        self.addCleanup(self.temp.cleanup)
+        self.root = Path(self.temp.name)
+        self.process = ts.Process(321, 1, "ttys077",
+                                 "Wed Sep 9 08:35:08 2026", "/usr/bin/claude")
+        self.rows = {321: self.process}
+        self.owner = self.process.owner()
+        self.paints = []
+        self.store = ts.Store(self.root, lambda: self.rows,
+                              lambda owner, record: self.paints.append(record.copy()))
+        self.store.known_tickets = {"TK-500", "TK-12168"}
+        self.tk_new_calls = []
+
+    def fake_tk_new(self, ticket_id="TK-99999", raises=None):
+        def _fn(owner, topic, project):
+            self.tk_new_calls.append((owner, topic, project))
+            if raises:
+                raise raises
+            return ticket_id
+        return _fn
+
+    def test_unbound_plus_substantive_prompt_creates_and_binds(self):
+        result = ts.cmd_auto_bind(self.store, self.owner,
+                                  "fix the login bug on the checkout page",
+                                  "my-project", tk_new=self.fake_tk_new("TK-99999"))
+        self.assertEqual(result, {"action": "created", "ticket": "TK-99999"})
+        self.assertEqual(len(self.tk_new_calls), 1)
+        _, topic, project = self.tk_new_calls[0]
+        self.assertEqual(project, "my-project")
+        self.assertIn("login bug", topic)
+        record, _ = self.store.load(self.owner)
+        self.assertEqual(record["ticket"], "TK-99999")
+        # A second call must see the session as already bound and never call
+        # tk_new again (the SAME "unbound" check that skipped it before).
+        result2 = ts.cmd_auto_bind(self.store, self.owner, "another real task here",
+                                   "my-project", tk_new=self.fake_tk_new())
+        self.assertEqual(result2["action"], "already-bound")
+        self.assertEqual(len(self.tk_new_calls), 1)
+
+    def test_trivial_prompts_never_create_a_ticket(self):
+        for prompt in ("", "ok", "Ok", "yes", "  yes  ", "/foo", "/foo\n", "hi there"):
+            with self.subTest(prompt=prompt):
+                result = ts.cmd_auto_bind(self.store, self.owner, prompt, "proj",
+                                          tk_new=self.fake_tk_new())
+                self.assertEqual(result["action"], "skipped-trivial", prompt)
+        self.assertEqual(self.tk_new_calls, [])
+        record, reason = self.store.load(self.owner)
+        self.assertEqual(reason, "missing")  # never wrote anything
+
+    def test_slash_command_with_arguments_is_substantive(self):
+        # A bare "/foo" is trivial (no task named); "/foo do the actual thing"
+        # names real work and must still earn a ticket.
+        result = ts.cmd_auto_bind(self.store, self.owner, "/deploy the checkout service",
+                                  "proj", tk_new=self.fake_tk_new("TK-1"))
+        self.assertEqual(result["action"], "created")
+        self.assertEqual(len(self.tk_new_calls), 1)
+
+    def test_explicit_ticket_in_prompt_binds_without_creating(self):
+        result = ts.cmd_auto_bind(self.store, self.owner,
+                                  "let's keep working on TK-500 today",
+                                  "proj", tk_new=self.fake_tk_new())
+        self.assertEqual(result, {"action": "bound-explicit", "ticket": "TK-500"})
+        self.assertEqual(self.tk_new_calls, [])  # never shells out when the user named one
+        record, _ = self.store.load(self.owner)
+        self.assertEqual(record["ticket"], "TK-500")
+        self.assertEqual(record["ticket_source"], "explicit")
+
+    def test_explicit_ticket_not_in_known_ledger_fails_open(self):
+        result = ts.cmd_auto_bind(self.store, self.owner,
+                                  "let's work on TK-777777 today",
+                                  "proj", tk_new=self.fake_tk_new())
+        self.assertEqual(result["action"], "explicit-bind-failed")
+        self.assertEqual(result["ticket"], "TK-777777")
+        record, reason = self.store.load(self.owner)
+        self.assertEqual(reason, "missing")  # refused, never fabricated a binding
+
+    def test_ambiguous_multiple_tickets_in_prompt_falls_through_to_create(self):
+        # Two distinct TK- mentions -> abstain on "which one", same conservatism
+        # as every other detector in ticket_binding.py -- falls through to the
+        # substantive-prompt create path instead of guessing.
+        result = ts.cmd_auto_bind(self.store, self.owner,
+                                  "is this related to TK-500 or TK-501 or something else",
+                                  "proj", tk_new=self.fake_tk_new("TK-2"))
+        self.assertEqual(result["action"], "created")
+
+    def test_never_creates_a_duplicate_for_the_same_session(self):
+        r1 = ts.cmd_auto_bind(self.store, self.owner, "build the new export feature",
+                              "proj", tk_new=self.fake_tk_new("TK-1"))
+        self.assertEqual(r1["action"], "created")
+        # Simulate the record's own ticket field going empty again (e.g. an
+        # explicit clear) WITHOUT clearing our autobind state file -- a second
+        # call must REBIND the same id, never mint TK-2.
+        self.store.set(self.owner, "none", ticket_update="")
+        r2 = ts.cmd_auto_bind(self.store, self.owner, "build the new export feature again",
+                              "proj", tk_new=self.fake_tk_new("TK-2"))
+        self.assertEqual(r2, {"action": "rebound", "ticket": "TK-1"})
+        self.assertEqual(len(self.tk_new_calls), 1)  # only the first call actually created
+
+    def test_concurrent_prompt_on_same_tty_cannot_double_create(self):
+        import fcntl
+        # Hold the lock the way a concurrent auto-bind invocation would.
+        state_path = ts._autobind_state_path(self.store, self.owner)
+        lock_path = state_path.with_suffix(".lock")
+        fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
+        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+        try:
+            result = ts.cmd_auto_bind(self.store, self.owner, "do the real work now",
+                                      "proj", tk_new=self.fake_tk_new())
+        finally:
+            fcntl.flock(fd, fcntl.LOCK_UN)
+            os.close(fd)
+        self.assertEqual(result, {"action": "locked"})
+        self.assertEqual(self.tk_new_calls, [])
+
+    def test_fails_open_when_tk_errors_and_rate_limits_the_retry(self):
+        boom = self.fake_tk_new(raises=ts.StatusError("tk: connection refused"))
+        r1 = ts.cmd_auto_bind(self.store, self.owner, "do the real work now",
+                              "proj", tk_new=boom, now=1000.0)
+        self.assertEqual(r1["action"], "create-failed")
+        self.assertIn("connection refused", r1["error"])
+        record, reason = self.store.load(self.owner)
+        self.assertEqual(reason, "missing")  # a failed create never fakes a binding
+        # A retry moments later is throttled -- never hammers a broken `tk`.
+        r2 = ts.cmd_auto_bind(self.store, self.owner, "do the real work now",
+                              "proj", tk_new=boom, now=1005.0)
+        self.assertEqual(r2["action"], "rate-limited")
+        self.assertEqual(len(self.tk_new_calls), 1)
+        # Well past the rate-limit window, it tries again.
+        r3 = ts.cmd_auto_bind(self.store, self.owner, "do the real work now",
+                              "proj", tk_new=self.fake_tk_new("TK-3"), now=1000.0 + 3600)
+        self.assertEqual(r3["action"], "created")
+
+    def test_trivial_prompt_never_consumes_the_rate_limit_window(self):
+        # A trivial "ok" right before the real prompt must not block it.
+        r1 = ts.cmd_auto_bind(self.store, self.owner, "ok", "proj",
+                              tk_new=self.fake_tk_new(), now=1000.0)
+        self.assertEqual(r1["action"], "skipped-trivial")
+        r2 = ts.cmd_auto_bind(self.store, self.owner, "now actually fix the bug",
+                              "proj", tk_new=self.fake_tk_new("TK-4"), now=1000.1)
+        self.assertEqual(r2["action"], "created")
+
+    def test_never_binds_an_empty_ticket_id(self):
+        # A helper that returns "" instead of raising (defensive belt-and-
+        # suspenders on _default_tk_new's own contract) must fail open, not
+        # bind a blank ticket that would look like a real dot with none at all.
+        result = ts.cmd_auto_bind(self.store, self.owner, "do the real work now",
+                                  "proj", tk_new=lambda o, t, p: "")
+        self.assertEqual(result["action"], "create-failed")
+        record, reason = self.store.load(self.owner)
+        self.assertEqual(reason, "missing")
+
+
 class EnrichmentCacheTests(unittest.TestCase):
     """TK-11831: the shared pid-keyed cache for discover()'s two `ps` enrichment calls.
 
diff --git a/ticket_binding.py b/ticket_binding.py
index 93c3f24..71f21a8 100644
--- a/ticket_binding.py
+++ b/ticket_binding.py
@@ -386,3 +386,62 @@ def discover(root, rows, live, chain, argv=None, env=None):
 
 def timestamp_start(value):
     return dt.datetime.strptime(value, "%a %b %d %H:%M:%S %Y").timestamp()
+
+
+# --- TK-12168: auto-bind pure helpers ---------------------------------------
+# A live top-level interactive session (no CLAUDE_CODE_CHILD_SESSION, never ran
+# a `tk` command, no TK- in argv) sits unbound forever under discover() above --
+# it is a purely READ-ONLY inference and by design never creates a ticket. The
+# auto-bind orchestration in terminal_status.py (cmd_auto_bind) closes that gap
+# from the UserPromptSubmit hook; these two detectors are the pure, side-effect
+# free pieces of that decision so they are unit-testable without a live ledger,
+# a Store, or a `tk` subprocess.
+
+EXPLICIT_TICKET = re.compile(r"\bTK-(\d+)\b", re.I)
+
+
+def explicit_ticket_in_prompt(prompt):
+    """The one TK- id the user typed, or "" if none/ambiguous.
+
+    Mirrors the "exactly one distinct id" conservatism used everywhere else in
+    this module (AGENT / the bare-id scan in discover()): a prompt naming two
+    different tickets abstains rather than guessing which one the user meant.
+    """
+    ids = {"TK-" + m for m in EXPLICIT_TICKET.findall(prompt or "")}
+    return next(iter(ids)) if len(ids) == 1 else ""
+
+
+# Bare acknowledgements that must never mint a ticket even though they clear
+# the word-count floor on their own re-reading (e.g. "ok ok ok"). Kept short
+# and literal -- anything not on this list falls through to the word-count
+# test below, which is the primary guard.
+_ACK_WORDS = {"ok", "okay", "yes", "y", "no", "n", "sure", "thanks", "thank you",
+              "thx", "k", "kk", "yep", "yup", "nope", "continue", "go", "proceed",
+              "done", "got it", "sounds good", "np", "cool", "great"}
+
+
+def is_trivial_prompt(prompt):
+    """True when a prompt carries no real task signal for auto-bind.
+
+    Per TK-12168: no ticket for trivial prompts ("ok", "yes", slash-commands
+    with no args, <3 words). Wait for a substantive one.
+    """
+    text = (prompt or "").strip()
+    if not text:
+        return True
+    first_line = text.splitlines()[0].strip()
+    if first_line.startswith("/") and " " not in first_line and "\t" not in first_line:
+        return True  # a bare slash-command with no arguments
+    words = text.split()
+    if len(words) < 3:
+        return True
+    bare = re.sub(r"[^a-z ]", "", text.lower()).strip()
+    if bare in _ACK_WORDS:
+        return True
+    return False
+
+
+def short_topic(prompt, limit=70):
+    """Collapse whitespace and cap length for a `tk new` title."""
+    text = " ".join((prompt or "").split())
+    return text[:limit]

← 5933ea1 Narrow the CHILD_SESSION paint guard so a bridge session tha  ·  back to Terminal Status  ·  TK-11317: never blank/green-float a prior needs-Steve colour b594dbb →