← back to Terminal Status
bind a session by its launcher-declared TK_AGENT, so a slug carrying two TK ids no longer reads TK REQUIRED (TK-11631)
23158082f6718c14959a724fc73a06407892b584 · 2026-09-13 16:25:48 -0700 · Steve Abrams
run-ticket.sh bakes `export TK_AGENT=<prefix>-<IDNUM>` into the launch prompt and
IDNUM is the DRIVING ticket's number alone. The pre-existing argv rule scans for bare
TK ids and binds only when it finds exactly one, so a session driving a ticket whose
slug embeds a referenced ticket (TK-11630-tk-11340-...) yields two ids, abstains, and
is mislabelled TK REQUIRED for its whole life while logging real work on the board.
54 of 1783 tickets carry that slug shape.
New source main_process_agent is tried first, still gated on the known-ticket set, and
does not change the single-id rule it falls through to. Measured live on the same
process table: 40 bound -> 42 bound, 0 lost, 0 rebound to a different id.
Tests ship with the injected-fault proof required by CLAUDE.md TK-11431 amendment 3:
removing the new source reddens the positive test, removing the known-set guard or
guessing an id reddens the anti-invention test, and a greedy capture that swallows the
prompt's trailing period reddens the positive test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011yMsG8b5h9zymuwmtabAMJ
Files touched
M test_terminal_status.pyM ticket_binding.py
Diff
commit 23158082f6718c14959a724fc73a06407892b584
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Sep 13 16:25:48 2026 -0700
bind a session by its launcher-declared TK_AGENT, so a slug carrying two TK ids no longer reads TK REQUIRED (TK-11631)
run-ticket.sh bakes `export TK_AGENT=<prefix>-<IDNUM>` into the launch prompt and
IDNUM is the DRIVING ticket's number alone. The pre-existing argv rule scans for bare
TK ids and binds only when it finds exactly one, so a session driving a ticket whose
slug embeds a referenced ticket (TK-11630-tk-11340-...) yields two ids, abstains, and
is mislabelled TK REQUIRED for its whole life while logging real work on the board.
54 of 1783 tickets carry that slug shape.
New source main_process_agent is tried first, still gated on the known-ticket set, and
does not change the single-id rule it falls through to. Measured live on the same
process table: 40 bound -> 42 bound, 0 lost, 0 rebound to a different id.
Tests ship with the injected-fault proof required by CLAUDE.md TK-11431 amendment 3:
removing the new source reddens the positive test, removing the known-set guard or
guessing an id reddens the anti-invention test, and a greedy capture that swallows the
prompt's trailing period reddens the positive test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011yMsG8b5h9zymuwmtabAMJ
---
test_terminal_status.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++
ticket_binding.py | 25 ++++++++++++++++++++-
2 files changed, 82 insertions(+), 1 deletion(-)
diff --git a/test_terminal_status.py b/test_terminal_status.py
index 908a42a..06cb918 100644
--- a/test_terminal_status.py
+++ b/test_terminal_status.py
@@ -3,6 +3,7 @@ import datetime as dt
import json
import os
from pathlib import Path
+import re
import tempfile
import subprocess
import sys
@@ -98,6 +99,63 @@ class StatusTests(unittest.TestCase):
self.assertEqual(found["ttys010"]["id"], "TK-2")
self.assertEqual(len(known), 4)
+ # TK-11631: run-ticket.sh's own `export TK_AGENT=<prefix>-<IDNUM>` carries only
+ # the DRIVING ticket's number, so it binds sessions the bare-id scan must abstain
+ # on. RUN_TICKET_ARGV is the verbatim argv of live pid 48691 (2026-09-13).
+ RUN_TICKET_ARGV = (
+ "claude --model opus export TK_AGENT=claude-run-11630. You are driving ticket "
+ "TK-11630-tk-11340-follow-on-2-live-gated-memos-we to completion. First run: tk "
+ "inbox (act on any DMs), then tk show TK-11630-tk-11340-follow-on-2-live-gated-"
+ "memos-we for full context. You now OWN this ticket: tk take TK-11630-tk-11340-"
+ "follow-on-2-live-gated-memos-we.")
+
+ def ledger(self, *tickets):
+ path = self.root / ".claude/tickets/events.jsonl"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("\n".join(json.dumps(
+ {"id": t, "type": "create",
+ "ts": dt.datetime.fromtimestamp(self.owner.epoch - 60, dt.timezone.utc).isoformat()})
+ for t in tickets))
+
+ def bind(self, argv):
+ return ts.tickets.discover(self.root, self.rows, ts.owners(self.rows),
+ ts.ancestors, argv=argv)
+
+ def test_declared_agent_binds_driving_ticket_when_slug_embeds_a_second_id(self):
+ self.rows[124] = ts.Process(124, 1, "ttys011", self.process.started, "/bin/claude")
+ self.ledger("TK-11630-tk-11340-follow-on-2-live-gated-memos-we", "TK-11317")
+ # The pre-existing rule cannot bind this argv: the slug embeds a second id.
+ self.assertEqual(len(set(re.findall(r"\bTK-\d+\b", self.RUN_TICKET_ARGV, re.I))), 2)
+ # The prompt sentence puts a period immediately after the token, so a greedy
+ # capture yields "claude-run-11630." and binds nothing. The digits are anchored.
+ self.assertIn("TK_AGENT=claude-run-11630. You are", self.RUN_TICKET_ARGV)
+ self.assertEqual(ts.tickets.AGENT.search(self.RUN_TICKET_ARGV)[1], "11630")
+ found, known = self.bind({123: self.RUN_TICKET_ARGV,
+ 124: "claude --model opus Please drive TK-11317 to done"})
+ self.assertEqual(found.get("ttys010"), {"id": "TK-11630", "at": self.owner.epoch,
+ "source": "main_process_agent"})
+ # ...and the single-id rule it defers to is untouched.
+ self.assertEqual(found["ttys011"], {"id": "TK-11317", "at": self.owner.epoch,
+ "source": "main_process_argument"})
+ self.assertEqual(known, {"TK-11630", "TK-11317"})
+
+ def test_declared_agent_never_invents_a_ticket_nor_launders_a_bare_session(self):
+ self.rows[124] = ts.Process(124, 1, "ttys011", self.process.started, "/bin/claude")
+ self.ledger("TK-11630-tk-11340-follow-on-2-live-gated-memos-we")
+ unknown = self.RUN_TICKET_ARGV.replace("TK_AGENT=claude-run-11630",
+ "TK_AGENT=claude-run-99999999")
+ found, _ = self.bind({123: "claude", 124: unknown})
+ # A declared id that was never created binds nothing -- and does NOT fall
+ # back to guessing one of the ids the slug happens to mention.
+ self.assertNotIn("ttys011", found)
+ # A genuinely ticketless session stays unbound and still reads TK REQUIRED.
+ self.assertNotIn("ttys010", found)
+ self.store.ticket_evidence = found
+ self.store.set(self.owner, "green", "Working")
+ self.assertIn("TK REQUIRED", self.store.row(self.owner)["label"])
+ # TK-11369/TK-11505: an unavailable `ps` degrades the label, never the paint.
+ self.assertEqual(self.bind({})[0], {})
+
def test_all_colors_write_canonical_and_identical_mirrors(self):
for color in ts.COLORS:
r = self.store.set(self.owner, color, "" if color == "none" else "Task")
diff --git a/ticket_binding.py b/ticket_binding.py
index 2dd7172..d4ac9df 100644
--- a/ticket_binding.py
+++ b/ticket_binding.py
@@ -13,6 +13,19 @@ _PS_TIMEOUT = float(os.environ.get("TERMINAL_STATUS_PS_TIMEOUT", "90"))
SHORT = re.compile(r"^TK-\d+(?=$|-)", re.I)
CID = re.compile(r"^(?:assign|create|action)-[a-z0-9]+-(\d+)-[a-z0-9]+$")
+# TK-11631: the LAUNCHER-DECLARED ticket. run-ticket.sh bakes
+# `export TK_AGENT=<AGENT_PREFIX>-<IDNUM>` into the launch prompt, where IDNUM is
+# ${ID#TK-} truncated at the first dash -- i.e. the DRIVING ticket's number alone,
+# never a referenced one. That makes it unambiguous BY CONSTRUCTION, unlike the
+# bare-id scan in discover(): the house slug convention embeds a referenced ticket
+# inside the new ticket's own id ("TK-11630-tk-11340-follow-on-..."), so a session
+# driving one of those has TWO TK ids in its argv, the len(ids)==1 guard abstains,
+# and the tab reads "TK REQUIRED" for that session's entire life even while it logs
+# real work on the board (54 of 1783 tickets, 3.0%, carry that shape). The prefixes
+# are exactly the AGENT_PREFIX values run-ticket.sh can emit.
+AGENT = re.compile(r"\bTK_AGENT=(?:claude-run|codex-run|local-qwen-27b-run"
+ r"|local-qwen-14b-run|local-qwen-14b-mac1-run)-(\d+)\b", re.I)
+
def short(value):
match = SHORT.match(str(value))
@@ -90,7 +103,17 @@ def discover(root, rows, live, chain, argv=None):
for tty, owner in live.items():
if result[tty]:
continue
- ids = set(re.findall(r"\bTK-\d+\b", (argv or {}).get(owner.pid, ""), re.I))
+ command = (argv or {}).get(owner.pid, "")
+ # The launcher's own declaration is tried FIRST: it names only the driving
+ # ticket, so it survives the slug collision that makes the scan below
+ # abstain. Same safety condition as that scan -- a declared id that is not
+ # a ticket we have seen created is ignored, so this can never invent one.
+ declared = AGENT.search(command)
+ if declared and "TK-" + declared[1] in known:
+ result[tty] = {"id": "TK-" + declared[1], "at": owner.epoch,
+ "source": "main_process_agent"}
+ continue
+ ids = set(re.findall(r"\bTK-\d+\b", command, re.I))
if len(ids) == 1 and next(iter(ids)).upper() in known:
result[tty] = {"id": next(iter(ids)).upper(), "at": owner.epoch,
"source": "main_process_argument"}
← 4e63168 stopped marker must not reuse the lightblue base codepoint
·
back to Terminal Status
·
require `export ` + a single declaration before binding a TK b85d85b →