← 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 };