← back to Answer Cockpit
lib/ticketlane.js
92 lines
'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 };