← back to Ticket Action Viewer

server.js

196 lines

// Ticket Action Viewer — zero-dependency board over `tk` with action keys,
// next-steps analysis, priority ranking, and gated-item requirements.
// Basic-auth admin/DW2024!. Reads live `tk list`/`tk show`; writes via tk take/log/done/status.
const http = require('http');
const { execFile } = require('child_process');
const fs = require('fs');
const path = require('path');

const PORT = process.env.PORT || 10075;  // moved off 9971 (collided with gmc-viewer, which holds 9971)
const AGENT = process.env.TK_AGENT || 'cre-agent';
const USER = 'admin', PASS = 'DW2024!';
const ANALYSIS = path.join(__dirname, 'data', 'analysis.json');
const RUNSTATE = path.join(__dirname, 'data', 'runstate.json');

// ── run-state: when a ticket entered "doing", so the viewer can show a live timer ──
// Map of TK-#### → ISO start time. Stamped at RUN NOW / Take click, auto-backfilled
// for any doing ticket, and pruned the moment a ticket leaves "doing".
function loadRun() { try { return JSON.parse(fs.readFileSync(RUNSTATE, 'utf8')); } catch { return {}; } }
function saveRun(o) { try { fs.writeFileSync(RUNSTATE, JSON.stringify(o)); } catch (_) {} }
// Idempotent: the first start wins, so re-clicking RUN NOW never resets the clock.
function stampStart(shortId) { if (!shortId) return null; const r = loadRun(); if (!r[shortId]) { r[shortId] = new Date().toISOString(); saveRun(r); } return r[shortId]; }
function clearStart(shortId) { if (!shortId) return; const r = loadRun(); if (r[shortId]) { delete r[shortId]; saveRun(r); } }

// True "entered doing" time per ticket, derived from the shared append-only event log
// (so a ticket that's been doing for hours shows an accurate timer, not "just now").
// Keyed by short id (TK-####); value = ts of the most recent *contiguous* doing entry.
// Cached 5s so N /api/tickets polls don't each re-scan the multi-MB log.
const EVENTS_LOG = process.env.TK_EVENTS || path.join(process.env.HOME, '.claude', 'tickets', 'events.jsonl');
let _doingCache = { ts: 0, map: {} };
function doingSinceMap() {
  if (Date.now() - _doingCache.ts < 5000) return _doingCache.map;
  const cur = {};   // shortId → current status ts while doing, else absent
  try {
    for (const line of fs.readFileSync(EVENTS_LOG, 'utf8').split('\n')) {
      if (!line || line.indexOf('"status"') === -1) continue;
      let ev; try { ev = JSON.parse(line); } catch { continue; }
      if (ev.type !== 'status' || !ev.id) continue;
      const short = (String(ev.id).match(/^TK-[0-9]+/) || [''])[0];
      if (!short) continue;
      if (ev.status === 'doing') { if (!cur[short]) cur[short] = ev.ts; }  // enter doing → stamp (keep first of the run)
      else delete cur[short];                                             // any other status ends the doing run
    }
  } catch (_) {}
  _doingCache = { ts: Date.now(), map: cur };
  return cur;
}

function tk(args) {
  return new Promise((resolve) => {
    execFile('tk', args, { env: { ...process.env, TK_AGENT: AGENT }, maxBuffer: 8 * 1024 * 1024 },
      (err, stdout, stderr) => resolve({ ok: !err, out: (stdout || '') + (stderr || '') }));
  });
}

const LINE = /^(TK-\S+)\s+\[(\w+)\]\s+\(([^)]*)\)\s+\{([^}]*)\}\s+(.*)$/;
const LA_TZ = { timeZone: 'America/Los_Angeles', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' };
function loadAnalysis() { try { return JSON.parse(fs.readFileSync(ANALYSIS, 'utf8')); } catch { return {}; } }

// Derive a sensible default when we have no curated analysis for a ticket.
function derive(t) {
  if (t.status === 'open') return { priority: 55, nextStep: 'Take it and start the first increment.', gated: false, needs: '' };
  if (t.status === 'doing') return { priority: 50, nextStep: 'Continue — resume where the owner parked (see Detail).', gated: false, needs: '' };
  // blocked
  return { priority: 30, nextStep: 'Unblock — read the owner’s last note for the specific blocker (Detail).', gated: true, needs: 'Open Detail; the blocker is in the last action line. Usually a Steve-only step (approval, key, console click, or a machine/toolchain fix).' };
}

async function getTickets() {
  const { out } = await tk(['list']);
  const overlay = loadAnalysis();
  const tickets = [];
  for (const raw of out.split('\n')) {
    const m = raw.match(LINE);
    if (!m) continue;
    const [, fullId, status, owner, project, title] = m;
    const shortId = (fullId.match(/^TK-\d+/) || [fullId])[0];
    const d = derive({ status });
    const a = overlay[shortId] || d;
    tickets.push({ fullId, shortId, status, owner, project, title,
      priority: a.priority ?? d.priority,
      nextStep: a.nextStep ?? d.nextStep,
      gated: a.gated ?? d.gated,
      needs: a.needs ?? d.needs,
      note: a.note || '', startedAt: null });
  }
  // Start time per doing ticket, by priority:
  //   1. explicit click-stamp from runstate (RUN NOW / Take — exact click moment)
  //   2. true doing-entry from the shared event log (accurate for long-running tickets)
  //   3. now (last-resort so a doing ticket always has a ticking timer)
  // NOTE: backfill (2/3) is computed fresh every load and never written back — only explicit
  // click-stamps live in runstate, so a stale poll can't freeze a wrong start time.
  const run = loadRun();
  const doing = doingSinceMap();
  const doingIds = new Set();
  for (const t of tickets) {
    if (t.status === 'doing') { t.startedAt = run[t.shortId] || doing[t.shortId] || new Date().toISOString(); doingIds.add(t.shortId); }
  }
  // Prune click-stamps for anything that has since left doing (done/blocked/reopened).
  let pruned = false;
  for (const k of Object.keys(run)) if (!doingIds.has(k)) { delete run[k]; pruned = true; }
  if (pruned) saveRun(run);
  tickets.sort((x, y) => y.priority - x.priority || x.shortId.localeCompare(y.shortId));
  return tickets;
}

function auth(req) {
  const h = req.headers.authorization || '';
  const [, b64] = h.split(' ');
  if (!b64) return false;
  const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
  return u === USER && p === PASS;
}
function body(req) { return new Promise(r => { let d = ''; req.on('data', c => d += c); req.on('end', () => r(d)); }); }
function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }

const server = http.createServer(async (req, res) => {
  if (!auth(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
  const u = new URL(req.url, `http://x`);

  if (u.pathname === '/favicon.ico') { res.writeHead(204); return res.end(); }
  if (u.pathname === '/' ) {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    return res.end(fs.readFileSync(path.join(__dirname, 'public', 'index.html')));
  }
  if (u.pathname === '/api/tickets') { return json(res, 200, { agent: AGENT, tickets: await getTickets() }); }
  if (u.pathname === '/api/ticket') { const r = await tk(['show', u.searchParams.get('id') || '']); return json(res, 200, r); }
  if (u.pathname === '/api/run' && req.method === 'POST') {
    let p; try { p = JSON.parse(await body(req)); } catch { return json(res, 400, { ok: false, out: 'bad json' }); }
    const shortId = (String(p.id || '').match(/^TK-[0-9]+/) || [''])[0];
    if (!shortId) return json(res, 400, { ok: false, out: 'bad id' });
    const title = String(p.title || '').replace(/["`$\\]/g, ' ').slice(0, 160);
    const project = String(p.project || '').replace(/[^A-Za-z0-9_-]/g, '');
    // resolve project dir: ~/Projects/<project> if it exists, else ~/Projects
    const home = process.env.HOME;
    let dir = path.join(home, 'Projects');
    if (project && fs.existsSync(path.join(home, 'Projects', project))) dir = path.join(home, 'Projects', project);
    // Reserve + timestamp IMMEDIATELY at click (before the new window boots) so the ticket
    // is assigned to me and stamped the moment RUN NOW is pressed — no race, no double-work.
    const when = new Date().toLocaleString('en-US', LA_TZ);
    const startedAt = stampStart(shortId);   // start the live timer at the click, before the window boots
    await tk(['take', p.id]);                 // tk take also flips status → doing (the "doing now" state)
    await tk(['log', p.id, `▶ STARTED ${when} PT — RESERVED for ${AGENT} + launched in a new iTerm2 window (RUN NOW)`]);
    const sh = path.join('/tmp', `tav-run-${shortId}.sh`);
    const N = Math.max(2, Math.min(8, parseInt(p.agents, 10) || 4)); // fan-out width (2..8, default 4)
    const prompt = [
      `Drive ticket ${p.id} (${title}) to DONE using GRAPH ENGINEERING with a2a coordination. It is already assigned to you and stamped STARTED (${when} PT).`,
      `1) TICKETS + a2a FIRST: run \`tk inbox\` and act on any DMs; run \`tk show ${p.id}\`; scan related/blocking tickets so you don't re-solve a peer's in-flight work.`,
      `2) PLAN: read the ticket, split the work where it NATURALLY divides into independent parts (delete arrows that don't exist).`,
      `3) SPLIT: fan out ${N} parallel subagents via the Agent tool, EACH on fresh clean context working ONE independent part, all "working under ${shortId}". Each subagent: checks \`tk inbox\`, @-mentions/\`tk dm\` the owner before editing a shared file, and keeps COPIOUS \`tk log\`/\`tk note\` notes. Match fan-out to the real number of independent parts.`,
      `4) ARGUE: a SEPARATE contrarian agent (subagent_type "contrarian") adversarially reviews the merged result — never let a producer grade its own work. VERIFY every claimed defect before acting on it (reproduce it); drop phantoms.`,
      `5) MERGE: synthesize the verified results; commit each success (author steve@designerwallcoverings.com); \`tk log\` every action; \`tk done ${p.id}\` when genuinely complete, else \`tk status ${p.id} blocked\` with the reason.`,
      `ROBUSTNESS: if stuck or overlapping another agent's surface, \`tk dm <owner> "..." -t ${shortId}\` (or broadcast to \`all\`) and ask for help rather than colliding.`,
      `HARD GATES (never loosened by delegation): any customer-facing / deploy / Apple / USPTO / money / prod / DNS / send-to-list / remote-push action drafts an APPROVE/REVISE/BLOCK memo to ~/.claude/yolo-queue/pending-approval and STOPS for Steve — never auto-execute.`
    ].join('\n');
    fs.writeFileSync(sh, `#!/bin/bash\ncd ${dir}\nexport TK_AGENT=${AGENT}\nexec claude ${JSON.stringify(prompt)}\n`);
    fs.chmodSync(sh, 0o755);
    const osa = `tell application "iTerm2"\n activate\n create window with default profile\n tell current session of current window\n  write text "clear; echo '▶ RUN ${shortId} — ${title.replace(/'/g, '')}'; echo 'dir: ${dir}'; bash ${sh}"\n end tell\nend tell`;
    return execFile('osascript', ['-e', osa], (err, so, se) =>
      json(res, 200, { ok: !err, out: err ? String(se || err) : `Opened iTerm2 window running ${shortId} in ${dir}`, dir, startedAt }));
  }
  if (u.pathname === '/api/action' && req.method === 'POST') {
    let p; try { p = JSON.parse(await body(req)); } catch { return json(res, 400, { ok: false, out: 'bad json' }); }
    const { id, action, text } = p;
    const short = (String(id || '').match(/^TK-[0-9]+/) || [''])[0];
    // TAKE = assign to me AND stamp the start date+time into the ticket log.
    if (action === 'take') {
      const startedAt = stampStart(short);   // start the live timer at the click
      const r1 = await tk(['take', id]);     // tk take also flips status → doing (the "doing now" state)
      const when = new Date().toLocaleString('en-US', LA_TZ);
      const r2 = await tk(['log', id, `▶ STARTED ${when} PT — taken by ${AGENT} via Ticket Action Viewer`]);
      return json(res, 200, { ok: r1.ok && r2.ok, out: r1.out + '\n' + r2.out, started: when, startedAt });
    }
    let args;
    if (action === 'done') args = ['done', id];
    else if (action === 'block') args = ['status', id, 'blocked'];
    else if (action === 'log') args = ['log', id, text || ''];
    else return json(res, 400, { ok: false, out: 'unknown action' });
    const r = await tk(args);
    if (action === 'done' || action === 'block') clearStart(short);   // leaving doing → stop the timer
    return json(res, 200, r);
  }
  // ── static files under public/ (nav-agent asset, etc.) — traversal-guarded ──
  // Raw http has no framework static middleware, so serve public/* here before the 404.
  if (req.method === 'GET') {
    const PUBLIC = path.join(__dirname, 'public');
    const rel = decodeURIComponent(u.pathname).replace(/^\/+/, '');
    const fp = path.normalize(path.join(PUBLIC, rel));
    if (fp.startsWith(PUBLIC + path.sep) && fs.existsSync(fp) && fs.statSync(fp).isFile()) {
      const TYPES = { '.js': 'application/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon', '.woff2': 'font/woff2' };
      res.writeHead(200, { 'Content-Type': TYPES[path.extname(fp)] || 'application/octet-stream' });
      return res.end(fs.readFileSync(fp));
    }
  }
  res.writeHead(404); res.end('nf');
});
server.listen(PORT, () => console.log(`Ticket Action Viewer → http://127.0.0.1:${PORT} (admin/DW2024!)`));