← back to Ticket System

lib.js

172 lines

// Shared ticket store — append-only JSONL event log, state derived on read.
// Events file lives outside the repo so every agent/session shares one store.
const fs = require('fs');
const path = require('path');
const os = require('os');

const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
const EVENTS = path.join(DATA_DIR, 'events.jsonl');
const LOCK = path.join(DATA_DIR, '.lock');

function ensure() { fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(EVENTS)) fs.writeFileSync(EVENTS, ''); }

// Tiny O_EXCL lockfile so concurrent sessions can't mint the same ticket id.
function withLock(fn) {
  ensure();
  const deadline = Date.now() + 5000;
  for (;;) {
    try { const fd = fs.openSync(LOCK, 'wx'); try { return fn(); } finally { fs.closeSync(fd); fs.unlinkSync(LOCK); } }
    catch (e) {
      if (e.code !== 'EEXIST') throw e;
      // stale lock (>10s old) gets broken
      try { if (Date.now() - fs.statSync(LOCK).mtimeMs > 10000) { fs.unlinkSync(LOCK); continue; } } catch {}
      if (Date.now() > deadline) throw new Error('ticket store lock timeout');
      const until = Date.now() + 50; while (Date.now() < until); // brief spin, CLI-scale contention only
    }
  }
}

function readEvents() {
  ensure();
  return fs.readFileSync(EVENTS, 'utf8').split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
}

function append(ev) { ensure(); fs.appendFileSync(EVENTS, JSON.stringify(ev) + '\n'); return ev; }

const STATUSES = ['open', 'doing', 'blocked', 'done'];

// Fold the event log into current ticket state.
function tickets() {
  const map = new Map();
  for (const ev of readEvents()) {
    if (ev.type === 'create') {
      map.set(ev.id, { id: ev.id, title: ev.title, project: ev.project || '', agent: ev.agent || '', assignee: ev.agent || '', status: 'open', created_at: ev.ts, updated_at: ev.ts, comments: [], actions: [] });
      if (ev.body) map.get(ev.id).comments.push({ ts: ev.ts, agent: ev.agent || '', kind: 'comment', text: ev.body });
    } else {
      const t = map.get(ev.id); if (!t) continue; t.updated_at = ev.ts;
      if (ev.type === 'comment') t.comments.push({ ts: ev.ts, agent: ev.agent || '', kind: ev.kind || 'comment', text: ev.text });
      else if (ev.type === 'action') t.actions.push({ ts: ev.ts, agent: ev.agent || '', text: ev.text });
      else if (ev.type === 'status' && STATUSES.includes(ev.status)) t.status = ev.status;
      else if (ev.type === 'assign') t.assignee = ev.agent || '';
    }
  }
  return map;
}

// ── Agent-to-agent direct messages (DMs) ────────────────────────────────────
// Same append-only event log. A `dm` event is an addressed message between
// agents; `to` may be a specific agent, or 'all'/'*' for a broadcast. Replies
// carry `re` (the parent mid) so a conversation threads. A `read` event marks a
// message seen by an agent (per-agent read state; the human board shows all).
function nextMid() {
  let max = 0;
  for (const ev of readEvents()) if (ev.type === 'dm') { const n = parseInt(String(ev.mid).replace(/^M-/, ''), 10); if (n > max) max = n; }
  return 'M-' + String(max + 1).padStart(5, '0');
}
const isBroadcast = to => to === 'all' || to === '*' || to === 'everyone';

// Fold dm/read events into message objects: { mid, from, to, text, ticket, re, ts, reads:[agent] }
function messages() {
  const map = new Map();
  for (const ev of readEvents()) {
    if (ev.type === 'dm') map.set(ev.mid, { mid: ev.mid, from: ev.from || ev.agent || '', to: ev.to || '', text: ev.text || '', ticket: ev.ticket || '', re: ev.re || '', ts: ev.ts, reads: [] });
    else if (ev.type === 'read') { const m = map.get(ev.mid); if (m && ev.agent && !m.reads.includes(ev.agent)) m.reads.push(ev.agent); }
  }
  return map;
}

// Walk re-links up to the conversation root mid. Visited set prevents infinite
// loop if a corrupt/cyclical re-chain ever appears in the event log.
function threadRootMid(mid, mm) { let id = mid, c = mm.get(mid); const seen = new Set([id]); while (c && c.re && mm.get(c.re)) { if (seen.has(c.re)) break; seen.add(c.re); id = c.re; c = mm.get(id); } return id; }

// Every agent that has sent or been addressed in a message's conversation.
function threadParticipants(mid, mm) {
  const root = threadRootMid(mid, mm);
  const set = new Set();
  for (const m of mm.values()) { if (threadRootMid(m.mid, mm) !== root) continue; if (m.from) set.add(m.from); if (m.to && !isBroadcast(m.to)) set.add(m.to); }
  return set;
}

// Messages addressed to `agent` (direct, broadcast, or any thread it's part of).
// unreadOnly (default true) hides ones the agent already read or sent itself.
function inbox(agent, { unreadOnly = true } = {}) {
  const mm = messages();
  const out = [];
  for (const m of mm.values()) {
    if (m.from === agent) continue;
    const addressed = m.to === agent || isBroadcast(m.to) || threadParticipants(m.mid, mm).has(agent);
    if (!addressed) continue;
    if (unreadOnly && m.reads.includes(agent)) continue;
    out.push(m);
  }
  return out.sort((a, b) => a.ts < b.ts ? -1 : 1);
}

// Full conversation (root + replies) for any mid, chronological.
function thread(mid) {
  const mm = messages();
  if (!mm.has(mid)) return [];
  const root = threadRootMid(mid, mm);
  return [...mm.values()].filter(m => threadRootMid(m.mid, mm) === root).sort((a, b) => a.ts < b.ts ? -1 : 1);
}

// Resolve any reference to a stored mid (M-4, m-00004, 4, or full M-00004).
function resolveMid(ref, mm) {
  if (!ref) return null;
  const m = mm || messages();
  let r = String(ref).toUpperCase(); if (!r.startsWith('M-')) r = 'M-' + r;
  const n = parseInt(r.replace(/^M-/, ''), 10);
  for (const id of m.keys()) if (id === r || parseInt(id.replace(/^M-/, ''), 10) === n) return id;
  return null;
}

// Pull @agent mentions out of comment/note text so they route to inboxes too.
function parseMentions(text) {
  const out = new Set();
  for (const m of String(text || '').matchAll(/@([A-Za-z0-9][\w.\-:@]*)/g)) out.add(m[1].replace(/[.:,]+$/, ''));
  return [...out];
}

// Every agent identity that has ever acted (create/comment/action/assign) or been
// party to a DM (from/to). Used to gate @mention routing so a stray "@agent" in
// prose doesn't create a dead-letter DM to a nonexistent recipient.
function knownAgents() {
  const set = new Set();
  for (const ev of readEvents()) { for (const k of ['agent', 'from', 'to']) if (ev[k] && !isBroadcast(ev[k])) set.add(ev[k]); }
  return set;
}

// Ticket ids are 5-digit zero-padded + a slug of the title (Steve, 2026-07-26):
//   TK-00025-ga4-attribution-prep
// The numeric part is the tracking key; legacy short ids (TK-3, TK-17) remain
// valid in the log and resolvable by number.
function slugify(title) {
  return String(title || '').toLowerCase()
    .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
}
function idNum(id) { return parseInt(String(id).replace(/^TK-/i, ''), 10); } // parseInt stops at the slug dash
function nextId(title) {
  const FLOOR = 10000; // new tickets start at TK-10000 (Steve, 2026-07-27)
  let max = 0;
  for (const ev of readEvents()) if (ev.type === 'create') { const n = idNum(ev.id); if (n > max) max = n; }
  const num = String(Math.max(max + 1, FLOOR)).padStart(5, '0');
  const slug = slugify(title);
  return 'TK-' + num + (slug ? '-' + slug : '');
}
// Resolve any reference — full id, TK-24, 24, 00024, or 00024-partial-slug —
// to the canonical stored id (or null if nothing matches).
function resolveId(ref, map) {
  if (!ref) return null;
  const m = map || tickets();
  let r = String(ref).toUpperCase();
  if (!r.startsWith('TK-')) r = 'TK-' + r;
  for (const id of m.keys()) if (id.toUpperCase() === r) return id;
  const n = idNum(r);
  if (!Number.isFinite(n)) return null;
  for (const id of m.keys()) if (idNum(id) === n) return id;
  return null;
}

module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, EVENTS,
  nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast, knownAgents };