← back to Answer Cockpit
TK-11793 cycle 3: ticket-backlog lane — tickets waiting on Steve with NO open pane (steve_action/external_wait/blocked) surfaced under the orphan panel, urgency-sorted, route-only (adopted from night-TK-11793's parked offer)
bc4853b06263df8c925d8109dea28376e498ba22 · 2026-09-15 20:45:36 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Files touched
M lib/queue.jsA lib/ticketlane.jsM public/index.html
Diff
commit bc4853b06263df8c925d8109dea28376e498ba22
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 15 20:45:36 2026 -0700
TK-11793 cycle 3: ticket-backlog lane — tickets waiting on Steve with NO open pane (steve_action/external_wait/blocked) surfaced under the orphan panel, urgency-sorted, route-only (adopted from night-TK-11793's parked offer)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
lib/queue.js | 13 +++++++-
lib/ticketlane.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 14 ++++++++-
3 files changed, 116 insertions(+), 2 deletions(-)
diff --git a/lib/queue.js b/lib/queue.js
index 0620c2f..f185285 100644
--- a/lib/queue.js
+++ b/lib/queue.js
@@ -201,7 +201,18 @@ async function build(opts = {}) {
}
const linked = new Set(items.map((i) => i.ticket).filter(Boolean));
const orphanMemos = opts.orphans ? memos.filter((m) => !m.ticket || !linked.has(m.ticket)).map((m) => ({ ...m, excerpt: m.excerpt.slice(0, 600) })) : [];
- return { items, remaining: items.length, orphanMemos, orphanCount: memos.filter((m) => !m.ticket || !linked.has(m.ticket)).length, scannedAt: res.scannedAt, cost: '$0 (local)', stale: !!res.stale, source: res.source, error: res.error, terminalApi: res.terminalApi };
+ // Ticket backlog lane (TK-11793 cycle 3, from night-TK-11793's offer): tickets waiting on
+ // Steve (steve_action / external_wait blockers, or status blocked/stopped) whose pane is NOT
+ // open — invisible to a tty scan by construction. Display/route only: nothing to type into.
+ let ticketBacklog = [], ticketBacklogCount = 0;
+ try {
+ const all = await require('./ticketlane').loadTickets();
+ const lane = all.filter((c) => !linked.has(c.ticket)).map((c) => ({ ...c, urgency: require('./ticketlane').urgency(c), body: String(c.body || '').slice(0, 800) }))
+ .sort((a, b) => b.urgency - a.urgency || new Date(a.created) - new Date(b.created));
+ ticketBacklogCount = lane.length;
+ ticketBacklog = opts.orphans ? lane.slice(0, 80) : [];
+ } catch (e) { /* lane is best-effort; a read failure never blocks the live stream */ }
+ return { items, remaining: items.length, orphanMemos, orphanCount: memos.filter((m) => !m.ticket || !linked.has(m.ticket)).length, ticketBacklog, ticketBacklogCount, scannedAt: res.scannedAt, cost: '$0 (local)', stale: !!res.stale, source: res.source, error: res.error, terminalApi: res.terminalApi };
}
/** one item, fresh (uncached scan) — for the post-answer confirm. */
diff --git a/lib/ticketlane.js b/lib/ticketlane.js
new file mode 100644
index 0000000..e93766e
--- /dev/null
+++ b/lib/ticketlane.js
@@ -0,0 +1,91 @@
+'use strict';
+// ticketlane.js — the "ticket backlog" lane (TK-11793 cycle 3).
+//
+// Tickets waiting on Steve whose iTerm pane is NOT open are invisible to a tty scan by
+// construction. This reduces ~/.claude/tickets/events.jsonl into needs-Steve TICKET cards:
+// a `steve_action` / `external_wait` blocker, or status blocked/stopped, and not done.
+// DISPLAY / ROUTE ONLY — there is no pane, so nothing is ever typed for these.
+//
+// Adopted (trimmed) from night-TK-11793's parked offer:
+// tmp/night-TK-11793-competing/sources.js (memo loading omitted — lib/memo.js owns memos)
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const readline = require('readline');
+
+const EVENTS_FILE = path.join(os.homedir(), '.claude', 'tickets', 'events.jsonl');
+const TTL_MS = 30_000;
+let _ticketCache = { t: 0, v: null };
+
+function shortTk(id) { const m = String(id).match(/^TK-\d+/); return m ? m[0] : id; }
+function niceTitle(id, title) {
+ if (title) return title;
+ return String(id).replace(/^TK-\d+-?/, '').replace(/-/g, ' ').trim() || id;
+}
+
+function loadTickets() {
+ return new Promise((resolve) => {
+ if (Date.now() - _ticketCache.t < TTL_MS && _ticketCache.v) return resolve(_ticketCache.v);
+ const state = new Map(); // id -> reduced
+ let stream;
+ try { stream = fs.createReadStream(EVENTS_FILE, { encoding: 'utf8' }); }
+ catch { _ticketCache = { t: Date.now(), v: [] }; return resolve([]); }
+
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+ rl.on('line', (line) => {
+ if (!line || line.indexOf('"type":"read"') !== -1) return; // ~90% of lines are reads
+ let e; try { e = JSON.parse(line); } catch { return; }
+ const id = e.id; if (!id) return;
+ let s = state.get(id);
+ if (!s) { s = { id, title: null, project: null, owner: null, status: null, blocker: null, created: e.ts, updated: e.ts }; state.set(id, s); }
+ s.updated = e.ts;
+ switch (e.type) {
+ case 'create': s.title = e.title || s.title; s.project = e.project || s.project; s.created = e.ts; s.owner = s.owner || e.agent || null; break;
+ case 'status': s.status = e.status; break;
+ case 'assign': s.owner = e.agent || s.owner; break;
+ case 'blocker': s.blocker = { ...(e.blocker || {}), ts: e.ts }; break;
+ }
+ });
+ rl.on('close', () => {
+ const cards = [];
+ for (const s of state.values()) {
+ if (s.status === 'done') continue;
+ const bt = s.blocker && s.blocker.type;
+ const steveKind = bt === 'steve_action' || bt === 'external_wait';
+ const blockedStatus = s.status === 'blocked' || s.status === 'stopped';
+ if (!steveKind && !blockedStatus) continue;
+ const kind = bt === 'external_wait' ? 'external-wait' : bt === 'steve_action' ? 'steve-action' : 'blocked-ticket';
+ const ask = (s.blocker && (s.blocker.next_action || s.blocker.condition)) || 'Blocked — needs Steve to unblock or redirect.';
+ const parts = [];
+ if (s.blocker) {
+ if (s.blocker.type) parts.push('Blocker type: ' + s.blocker.type);
+ if (s.blocker.condition) parts.push('Condition: ' + s.blocker.condition);
+ if (s.blocker.next_action) parts.push('Next action (Steve): ' + s.blocker.next_action);
+ }
+ parts.push('Status: ' + (s.status || '?') + ' · Project: ' + (s.project || '?') + ' · Owner: ' + (s.owner || '?'));
+ cards.push({
+ id: 'tk:' + s.id, kind, title: niceTitle(s.id, s.title), ticket: shortTk(s.id), owner: s.owner || null,
+ project: s.project || null, status: s.status || null, created: s.created, updated: s.updated,
+ ask, body: parts.join('\n'), source: s.id,
+ });
+ }
+ _ticketCache = { t: Date.now(), v: cards };
+ resolve(cards);
+ });
+ rl.on('error', () => { _ticketCache = { t: Date.now(), v: [] }; resolve([]); });
+ });
+}
+
+function urgency(card) {
+ let u = 0;
+ const ageDays = (Date.now() - new Date(card.created).getTime()) / 86_400_000;
+ if (card.kind === 'external-wait') {
+ const dm = card.ask && card.ask.match(/(\d{4}-\d{2}-\d{2})/);
+ if (dm && new Date(dm[1]).getTime() < Date.now()) u += 120; else u += 40; // past its date → actionable now
+ } else if (card.kind === 'steve-action') u += 100;
+ else if (card.kind === 'blocked-ticket') u += 50;
+ u += Math.min(40, (Number.isFinite(ageDays) ? ageDays : 0) * 1.5); // older → higher, capped
+ return Math.round(u);
+}
+
+module.exports = { loadTickets, urgency, shortTk, niceTitle, EVENTS_FILE };
diff --git a/public/index.html b/public/index.html
index dc93638..9eca0b2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -227,7 +227,7 @@ async function poll(){
if(e.status===401)return;
state.down=true;banner((e.status===503||e.status===502)?'iTerm unreachable / scan stale — actions disabled':(e.status?('backend error '+e.status+' — actions disabled'):'backend unreachable — actions disabled (retrying every 8s)'));render();return;}
state.down=false;state.stale=!!q.stale;state.scannedAt=q.scannedAt||new Date().toISOString();
- state.orphans=q.orphanMemos||[];state.remaining=(typeof q.remaining==='number')?q.remaining:(q.items||[]).length;
+ state.orphans=q.orphanMemos||[];state.backlog=q.ticketBacklog||[];state.backlogCount=q.ticketBacklogCount||0;state.remaining=(typeof q.remaining==='number')?q.remaining:(q.items||[]).length;
$('cost').textContent=q.cost||'$0 (local)';
banner(state.stale?'iTerm unreachable / scan stale — actions disabled':'');
const fresh=q.items||[];const byKey=new Map(fresh.map(i=>[i.key,i]));
@@ -386,6 +386,18 @@ function renderOrphans(){const o=$('orph');const on=get('orph','0')==='1';o.hidd
let note='';if(d!=='approve'){note=prompt(d.toUpperCase()+' note for '+file+':');if(note==null)return;}
else if(!confirm('APPROVE '+file+' → moves to _approved/ (undo available)?'))return;
decide(file,d,note,null);}));
+ // ---- ticket backlog lane (TK-11793 cycle 3): tickets waiting on Steve with NO open pane.
+ // Display/route only — there is nothing to type into. Sorted by urgency (steve_action >
+ // past-due external_wait > blocked), then oldest first. The ticket board owns the actions.
+ const bl=state.backlog||[];
+ const kindPill=k=>k==='steve-action'?'warn':k==='external-wait'?'':'bad';
+ o.innerHTML+='<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px;margin-top:18px">ticket backlog (no open pane) — '+esc(String(state.backlogCount||bl.length))+' waiting on you — route only</h3>'+
+ (bl.length?bl.map(t=>
+ '<div class="item"><b>'+esc(t.ticket)+' · '+esc(t.title)+' <span class="pill '+kindPill(t.kind)+'">'+esc(t.kind)+'</span></b>'+
+ '<div class="m mono" title="'+esc(t.created||'')+'">🕓 '+esc(fmtWhen(t.created))+' · '+esc(t.owner||'unowned')+(t.project?' · '+esc(t.project):'')+' · urgency '+esc(String(t.urgency))+'</div>'+
+ '<pre style="margin-top:8px;max-height:120px">'+esc(t.ask)+'</pre>'+
+ '<div class="actions"><a class="btn" style="min-height:32px;padding:4px 10px;font-size:12px" href="http://127.0.0.1:9794" target="_blank" rel="noopener noreferrer">ticket board ↗</a></div></div>').join('')
+ :'<div class="m">none</div>');
}
function bind(it){
← 2502297 TK-11793: expectKey digest covers only the menu (question +
·
back to Answer Cockpit
·
TK-11793: ?panel=1 forces the orphan/backlog panel open (boo 1e893e7 →