← 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
M terminal_status.pyM test_terminal_status.pyM ticket_binding.py
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 →