← back to Ticket System

tk

124 lines

#!/opt/homebrew/bin/node
// tk — fleet ticket CLI. Every agent action ties to a ticket; comments/notes are the exchange channel.
// Ids are TK-<5-digit>-<name-slug> (e.g. TK-00025-ga4-attribution-prep); reference
// tickets by any form — 25, TK-25, 00025, or the full id. Legacy TK-3-style ids resolve too.
// Usage:
//   tk new "title" [-p project] [-a agent] [-b "opening comment"]
//   tk comment TK-3 "text" [-a agent]         # discussion comment
//   tk note TK-3 "text" [-a agent]            # agent-to-agent handoff note
//   tk win TK-3 "text" [-a agent]             # record a WIN (something landed)
//   tk challenge TK-3 "text" [-a agent]       # record a CHALLENGE (what fought back)
//   tk cody TK-3 "text" [-a agent]            # record Cody the Contrarian's dissent
//   tk log TK-3 "action taken" [-a agent]     # record an action performed under this ticket
//   tk take TK-3 -a agent                     # assign to an agent
//   tk status TK-3 open|doing|blocked|done
//   tk done TK-3 [-a agent]                   # shorthand for status done
//   tk list [--status s] [--project p] [--agent a] [--all]
//   tk show TK-3
//   --- agent-to-agent direct messages ---
//   tk dm <to-agent> "text" [-t TK-3] [-a from]   # DM another agent (to='all' broadcasts)
//   tk inbox [-a agent] [--all] [--peek]          # my unread DMs (--all=incl read, --peek=don't mark read)
//   tk reply <mid> "text" [-a agent]              # reply in a DM thread (routes back to sender)
//   tk thread <mid>                               # print a whole DM conversation
//   Also: an @agent mention inside a comment/note auto-DMs that agent, linked to the ticket.
// Agent identity defaults to $TK_AGENT, else "claude@<tty-or-pid>".
const { withLock, append, tickets, nextId, resolveId, STATUSES,
  nextMid, messages, inbox, thread, resolveMid, parseMentions, knownAgents, isBroadcast } = require(require('path').join(__dirname, 'lib.js'));

const argv = process.argv.slice(2);
const cmd = argv.shift();
function opt(flag) { const i = argv.findIndex(a => a === flag); if (i === -1) return undefined; const v = argv[i + 1]; argv.splice(i, 2); return v; }
function flagSet(flag) { const i = argv.indexOf(flag); if (i === -1) return false; argv.splice(i, 1); return true; }
const agentOpt = opt('-a') || opt('--agent-id');
const agent = agentOpt || process.env.TK_AGENT || ('claude@' + (process.env.TERM_SESSION_ID || process.pid));
const ts = new Date().toISOString();
const die = m => { console.error(m); process.exit(1); };
// Resolve any reference (24, TK-24, 00024, or the full 5-digit+slug id) to the
// canonical stored id — legacy short ids and new TK-00024-slug ids both work.
const norm = ref => { if (!ref) die('missing ticket id'); const id = resolveId(ref); if (!id) die('no such ticket ' + ref); return id; };
const fmt = t => `${t.id} [${t.status}] (${t.assignee || 'unassigned'})${t.project ? ' {' + t.project + '}' : ''} ${t.title}`;

if (cmd === 'new') {
  const project = opt('-p') || opt('--project') || '';
  const body = opt('-b') || opt('--body') || '';
  const title = argv.join(' ').trim(); if (!title) die('usage: tk new "title" [-p project] [-a agent]');
  const ev = withLock(() => append({ ts, type: 'create', id: nextId(title), title, project, agent, body }));
  console.log(ev.id);
} else if (cmd === 'comment' || cmd === 'note' || cmd === 'win' || cmd === 'challenge' || cmd === 'cody') {
  const id = norm(argv.shift()); const text = argv.join(' ').trim(); if (!text) die('missing text');
  if (!tickets().has(id)) die('no such ticket ' + id);
  append({ ts, type: 'comment', id, kind: cmd, agent, text });
  // Route @agent mentions to their inbox as a ticket-linked DM — but only to KNOWN
  // agents (or a broadcast), so a stray "@agent"/"@here" in prose isn't a dead letter.
  const known = knownAgents();
  const all = parseMentions(text).filter(a => a !== agent);
  const mentioned = all.filter(a => isBroadcast(a) || known.has(a));
  const skipped = all.filter(a => !mentioned.includes(a));
  for (const to of mentioned) append({ ts, type: 'dm', mid: withLock(() => nextMid()), from: agent, to, text, ticket: id });
  console.log(`${cmd} added to ${id}${mentioned.length ? ' · dm→ ' + mentioned.join(', ') : ''}${skipped.length ? ' · (ignored @' + skipped.join(', @') + ' — unknown agent)' : ''}`);
} else if (cmd === 'log') {
  const id = norm(argv.shift()); const text = argv.join(' ').trim(); if (!text) die('missing action text');
  if (!tickets().has(id)) die('no such ticket ' + id);
  append({ ts, type: 'action', id, agent, text }); console.log(`action logged on ${id}`);
} else if (cmd === 'take') {
  const id = norm(argv.shift()); if (!tickets().has(id)) die('no such ticket ' + id);
  append({ ts, type: 'assign', id, agent }); append({ ts, type: 'status', id, status: 'doing', agent });
  console.log(`${id} → ${agent} (doing)`);
} else if (cmd === 'status') {
  const id = norm(argv.shift()); const s = (argv.shift() || '').toLowerCase();
  if (!STATUSES.includes(s)) die('status must be one of: ' + STATUSES.join(' '));
  if (!tickets().has(id)) die('no such ticket ' + id);
  append({ ts, type: 'status', id, status: s, agent }); console.log(`${id} → ${s}`);
} else if (cmd === 'done') {
  const id = norm(argv.shift()); if (!tickets().has(id)) die('no such ticket ' + id);
  append({ ts, type: 'status', id, status: 'done', agent }); console.log(`${id} → done`);
} else if (cmd === 'list' || cmd === undefined) {
  const s = opt('--status'), p = opt('--project'), a = opt('--agent'); const all = flagSet('--all');
  let rows = [...tickets().values()];
  if (!all && !s) rows = rows.filter(t => t.status !== 'done');
  if (s) rows = rows.filter(t => t.status === s);
  if (p) rows = rows.filter(t => t.project === p);
  if (a) rows = rows.filter(t => t.assignee === a);
  rows.sort((x, y) => x.created_at < y.created_at ? -1 : 1);
  if (!rows.length) console.log('(no tickets)');
  for (const t of rows) console.log(fmt(t));
} else if (cmd === 'show') {
  const t = tickets().get(norm(argv.shift())); if (!t) die('no such ticket');
  console.log(fmt(t)); console.log('created ' + t.created_at + '  updated ' + t.updated_at);
  for (const c of t.comments) console.log(`  [${c.kind}] ${c.ts} ${c.agent}: ${c.text}`);
  for (const a of t.actions) console.log(`  [action] ${a.ts} ${a.agent}: ${a.text}`);
} else if (cmd === 'dm') {
  const to = argv.shift(); const ticketRef = opt('-t') || opt('--ticket');
  const text = argv.join(' ').trim();
  if (!to || !text) die('usage: tk dm <to-agent> "text" [-t TK-3]');
  const ticket = ticketRef ? norm(ticketRef) : '';
  const mid = withLock(() => { const m = nextMid(); append({ ts, type: 'dm', mid: m, from: agent, to, text, ticket }); return m; });
  console.log(`${mid} · ${agent} → ${to}${ticket ? ' {' + ticket + '}' : ''}`);
} else if (cmd === 'reply') {
  const ref = argv.shift(); const mid0 = resolveMid(ref); if (!mid0) die('no such message ' + ref);
  const parent = messages().get(mid0);
  const text = argv.join(' ').trim(); if (!text) die('missing reply text');
  const to = parent.from === agent ? parent.to : parent.from;   // route back to the other party
  const mid = withLock(() => { const m = nextMid(); append({ ts, type: 'dm', mid: m, from: agent, to, text, ticket: parent.ticket || '', re: mid0 }); return m; });
  // reading + replying implies I've seen the parent
  append({ ts, type: 'read', mid: mid0, agent });
  console.log(`${mid} · ${agent} → ${to} (re ${mid0})`);
} else if (cmd === 'inbox') {
  const who = agent; const showAll = flagSet('--all'); const peek = flagSet('--peek');
  const msgs = inbox(who, { unreadOnly: !showAll });
  if (!msgs.length) { console.log(`(inbox empty for ${who})`); }
  else {
    for (const m of msgs) {
      const seen = m.reads.includes(who) ? ' ✓' : ' •';
      console.log(`${seen} ${m.mid}${m.re ? ' (re ' + m.re + ')' : ''} ${m.ticket ? '{' + m.ticket + '} ' : ''}${m.from} → ${m.to}: ${m.text}  [${new Date(m.ts).toLocaleString()}]`);
    }
    if (!peek) for (const m of msgs) if (!m.reads.includes(who)) append({ ts, type: 'read', mid: m.mid, agent: who });
    if (!peek) console.error(`(${msgs.filter(m => !m.reads.includes(who)).length} marked read — use --peek to keep unread)`);
  }
} else if (cmd === 'thread') {
  const mid0 = resolveMid(argv.shift()); if (!mid0) die('no such message');
  for (const m of thread(mid0)) console.log(`  ${m.mid}${m.re ? ' (re ' + m.re + ')' : ''} ${m.ticket ? '{' + m.ticket + '} ' : ''}${m.from} → ${m.to}: ${m.text}  [${new Date(m.ts).toLocaleString()}]`);
} else {
  die('unknown command "' + cmd + '" — see header of this file for usage');
}