← 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!)`));