[object Object]

← back to Terminal Status

TK-11665: bind a session's ticket via ledger agent claude@TERM_SESSION_ID

4187d75c197eae2c52b00097ca04ff0072dfd710 · 2026-09-14 00:30:03 -0700 · Steve Abrams

Add a 4th, last-resort binding source to ticket_binding.discover(): when no
MCP-correlation and no argv ticket bind a live session, resolve the session's
TERM_SESSION_ID from its process ENV (keyed PID->TSID, never tty) and bind the
most recent assign/create/action/comment event logged under claude@<TSID> (the
tk CLI's fallback identity when a session sets no TK_AGENT).

No epoch floor: TERM_SESSION_ID is a stable session identity across a --continue
resume in the same pane, so events predating this process's start are that
session's own prior work -- this is the fix for the resumed 'claude --continue'
class that permanently read "TK REQUIRED". Same `in known` safety as every other
source (never invents a ticket); never overrides a bound argv source; cannot
reach a self-chosen TK_AGENT (the honest KNOWN CEILING). Reads env via `ps eww`
minus the `-o args=` prefix to avoid the args+env concatenation trap.

Ships a positive test (resumed session, pre-start events bind) and a non-vacuous
negative test (read-noise / unknown ticket / no TSID / argv-present-no-override
all correctly abstain) -- proven to go RED on an injected fault.

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

Files touched

Diff

commit 4187d75c197eae2c52b00097ca04ff0072dfd710
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Sep 14 00:30:03 2026 -0700

    TK-11665: bind a session's ticket via ledger agent claude@TERM_SESSION_ID
    
    Add a 4th, last-resort binding source to ticket_binding.discover(): when no
    MCP-correlation and no argv ticket bind a live session, resolve the session's
    TERM_SESSION_ID from its process ENV (keyed PID->TSID, never tty) and bind the
    most recent assign/create/action/comment event logged under claude@<TSID> (the
    tk CLI's fallback identity when a session sets no TK_AGENT).
    
    No epoch floor: TERM_SESSION_ID is a stable session identity across a --continue
    resume in the same pane, so events predating this process's start are that
    session's own prior work -- this is the fix for the resumed 'claude --continue'
    class that permanently read "TK REQUIRED". Same `in known` safety as every other
    source (never invents a ticket); never overrides a bound argv source; cannot
    reach a self-chosen TK_AGENT (the honest KNOWN CEILING). Reads env via `ps eww`
    minus the `-o args=` prefix to avoid the args+env concatenation trap.
    
    Ships a positive test (resumed session, pre-start events bind) and a non-vacuous
    negative test (read-noise / unknown ticket / no TSID / argv-present-no-override
    all correctly abstain) -- proven to go RED on an injected fault.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01UVgEj4WxSqB62ieGpgrixR
---
 test_terminal_status.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++++
 ticket_binding.py       | 65 +++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 133 insertions(+), 1 deletion(-)

diff --git a/test_terminal_status.py b/test_terminal_status.py
index 30d9677..30279d7 100644
--- a/test_terminal_status.py
+++ b/test_terminal_status.py
@@ -117,6 +117,75 @@ class StatusTests(unittest.TestCase):
              "ts": dt.datetime.fromtimestamp(self.owner.epoch - 60, dt.timezone.utc).isoformat()})
             for t in tickets))
 
+    def session_ledger(self, tsid, *events, created=()):
+        """Write a ledger of claude@<tsid> events (each (type, ticket, offset_s))
+        plus optional standalone create events, for the TK-11665 session route."""
+        path = self.root / ".claude/tickets/events.jsonl"
+        path.parent.mkdir(parents=True, exist_ok=True)
+        lines = []
+        for tk in created:
+            lines.append(json.dumps({"id": tk, "type": "create",
+                "ts": dt.datetime.fromtimestamp(self.owner.epoch - 3600, dt.timezone.utc).isoformat()}))
+        for etype, ticket, offset in events:
+            lines.append(json.dumps({"id": ticket, "type": etype,
+                "agent": "claude@" + tsid,
+                "ts": dt.datetime.fromtimestamp(self.owner.epoch + offset, dt.timezone.utc).isoformat()}))
+        path.write_text("\n".join(lines))
+
+    def test_session_ledger_binds_resumed_session_via_term_session_id(self):
+        # TK-11665. A bare `claude --continue` carries NO ticket in argv, so every
+        # argv source abstains. Its work is logged under claude@<TERM_SESSION_ID>,
+        # and a resume in the same pane keeps that id -- so events written BEFORE
+        # this process started (offset -3600, i.e. before owner.epoch) are still
+        # this session's own work and MUST bind. Keyed PID->TERM_SESSION_ID via env.
+        tsid = "w7t0p0:E2D0289F-FA6C-45F7-8D42-42A35D406E46"
+        self.session_ledger(tsid,
+                             ("assign", "TK-11665-fix-x", -3600),
+                             ("action", "TK-11665-fix-x", -1800),
+                             created=("TK-11665-fix-x",))
+        found, _ = ts.tickets.discover(
+            self.root, self.rows, ts.owners(self.rows), ts.ancestors,
+            argv={123: "claude --continue"}, env={123: tsid})
+        self.assertEqual(found.get("ttys010"),
+                         {"id": "TK-11665", "at": self.owner.epoch - 1800,
+                          "source": "session_ledger"})
+
+    def test_session_ledger_is_non_vacuous_and_never_launders(self):
+        # TK-11665 NEGATIVE test (TK-11431 rule 3): the source must go quiet on
+        # every shape it must not bind, so a green result is evidence, not habit.
+        tsid = "w7t0p0:AAAAAAAA-0000-0000-0000-000000000000"
+        base = dict(argv={123: "claude --continue"}, env={123: tsid})
+        bind = lambda: ts.tickets.discover(self.root, self.rows,
+                                           ts.owners(self.rows), ts.ancestors, **base)
+
+        # (a) `read` events are board-viewing NOISE, never a binding.
+        self.session_ledger(tsid, ("read", "TK-11665-x", -60), created=("TK-11665-x",))
+        self.assertNotIn("ttys010", bind()[0])
+
+        # (b) a ticket the session touched but that was NEVER created cannot bind
+        #     (the `in known` safety -- it can never invent a ticket).
+        self.session_ledger(tsid, ("action", "TK-99999999-ghost", -60))
+        self.assertNotIn("ttys010", bind()[0])
+
+        # (c) no TERM_SESSION_ID in env (a session under a self-chosen TK_AGENT --
+        #     the ticket's KNOWN CEILING): stays honest, binds nothing.
+        self.session_ledger(tsid, ("action", "TK-11665-x", -60), created=("TK-11665-x",))
+        self.assertNotIn("ttys010",
+                         ts.tickets.discover(self.root, self.rows, ts.owners(self.rows),
+                                             ts.ancestors, argv={123: "claude --continue"},
+                                             env={})[0])
+
+        # (d) an argv ticket present: the session route must NEVER override a bound
+        #     argv source (it is the last resort, not a competitor).
+        self.session_ledger(tsid,
+                            ("action", "TK-11665-x", -60),
+                            created=("TK-11665-x", "TK-11317"))
+        found, _ = ts.tickets.discover(
+            self.root, self.rows, ts.owners(self.rows), ts.ancestors,
+            argv={123: "claude drive TK-11317 to done"}, env={123: tsid})
+        self.assertEqual(found["ttys010"]["source"], "main_process_argument")
+        self.assertEqual(found["ttys010"]["id"], "TK-11317")
+
     def bind(self, argv):
         return ts.tickets.discover(self.root, self.rows, ts.owners(self.rows),
                                    ts.ancestors, argv=argv)
diff --git a/ticket_binding.py b/ticket_binding.py
index 2c8fce0..4b38908 100644
--- a/ticket_binding.py
+++ b/ticket_binding.py
@@ -60,9 +60,17 @@ def timestamp(value):
         return 0
 
 
-def discover(root, rows, live, chain, argv=None):
+def discover(root, rows, live, chain, argv=None, env=None):
     """Attribute events only to a currently live main agent's own MCP process."""
     known, claims, actions = set(), {}, {}
+    # TK-11665: the session's OWN ledger identity. When a session sets no TK_AGENT,
+    # tk logs it under `claude@<TERM_SESSION_ID>` (the tk CLI's fallback identity --
+    # see the agent line in ~/Projects/ticket-system/tk). session_events[tsid] holds
+    # the MOST RECENT assign/create/action/comment that session wrote (never `read`,
+    # which is board-viewing noise, nor `status`/`blocker`). Bound below to a live
+    # session resolved PID->TERM_SESSION_ID, so a bare `claude`/`claude --continue`
+    # that carries no ticket in its argv still shows what it is working on.
+    session_events = {}
     descendants = {}
     for pid, p in rows.items():
         nearest = next((a for a in chain(pid, rows) if a.runtime), None)
@@ -83,6 +91,18 @@ def discover(root, rows, live, chain, argv=None):
                 continue
             if event.get("type") == "create":
                 known.add(ticket)
+            # TK-11665: capture the session-identity ledger in this same pass (no
+            # second file read). No epoch floor here -- unlike the correlation-id
+            # PID match below, TERM_SESSION_ID is a STABLE session identity across a
+            # --continue resume in the same pane, so events predating this process's
+            # start ARE this session's own prior work and must be kept.
+            agent = event.get("agent", "")
+            if agent.startswith("claude@") and event.get("type") in (
+                    "assign", "create", "action", "comment"):
+                tsid = agent[len("claude@"):]
+                at = timestamp(event.get("ts"))
+                if tsid and at >= session_events.get(tsid, {}).get("at", 0):
+                    session_events[tsid] = {"id": ticket, "at": at}
             match = CID.fullmatch(event.get("correlation_id", ""))
             if not match or int(match[1]) not in descendants:
                 continue
@@ -121,6 +141,36 @@ def discover(root, rows, live, chain, argv=None):
                 _record_enum_blind("ticket_binding ps -p: " + type(exc).__name__)
             except Exception:
                 pass
+    # TK-11665: resolve each live session's TERM_SESSION_ID from its process ENV
+    # (keyed on PID). `ps eww` prints ARGS AND ENV concatenated, so the env is the
+    # tail after the clean args string -- reading TERM_SESSION_ID off the raw output
+    # would hit the token sitting in a PROMPT argument (the measurement trap the
+    # ticket calls out). We strip the args prefix (already fetched above via
+    # `-o args=`) and parse only the remainder; if it does not start with the args
+    # we skip that pid rather than risk the trap. Optional enrichment like the argv
+    # read: a slow/absent ps degrades the label, never the paint.
+    if env is None and live:
+        env = {}
+        try:
+            output = subprocess.run(["ps", "eww", "-o", "pid=,command=",
+                                     "-p", ",".join(str(o.pid) for o in live.values())],
+                                    capture_output=True, text=True, timeout=_PS_TIMEOUT)
+            for line in output.stdout.splitlines():
+                parts = line.strip().split(None, 1)
+                if len(parts) != 2:
+                    continue
+                pid, full = int(parts[0]), parts[1]
+                args = (argv or {}).get(pid, "")
+                remainder = full[len(args):] if args and full.startswith(args) else ""
+                m = re.search(r"TERM_SESSION_ID=(\S+)", remainder)
+                if m:
+                    env[pid] = m.group(1)
+        except (OSError, subprocess.TimeoutExpired) as exc:
+            try:
+                from terminal_status import _record_enum_blind
+                _record_enum_blind("ticket_binding ps eww: " + type(exc).__name__)
+            except Exception:
+                pass
     for tty, owner in live.items():
         if result[tty]:
             continue
@@ -139,6 +189,19 @@ def discover(root, rows, live, chain, argv=None):
         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"}
+        # TK-11665: LAST resort -- only when no MCP-correlation and no argv ticket
+        # bound. The session's own ledger identity (PID->TERM_SESSION_ID) reflects
+        # what it is working on NOW, which is the only signal that survives a
+        # --continue resume (argv carries no ticket there). Same `in known` safety
+        # as every other source: it can never invent a ticket. Cannot reach a
+        # session that logs under a self-chosen TK_AGENT (the ticket's KNOWN CEILING);
+        # that stays honest "TK REQUIRED".
+        if not result[tty]:
+            tsid = (env or {}).get(owner.pid)
+            cand = session_events.get(tsid) if tsid else None
+            if cand and cand["id"] in known:
+                result[tty] = {"id": cand["id"], "at": cand["at"],
+                               "source": "session_ledger"}
     return {tty: value for tty, value in result.items() if value}, known
 
 

← 664d1de TK-11672: fix permanently-red terminal-status test via injec  ·  back to Terminal Status  ·  TK-11666: measure the TK-REQUIRED false-positive rate (the m 9731e7f →