← back to Ticket System
ticket-system: shared fleet ticket store + tk CLI + kanban board :9794
c5a54dfd7f57f19b46fd91f97344d6f7d1f88db0 · 2026-07-26 14:07:28 -0700 · Steve Abrams
Files touched
A .gitignoreA lib.jsA server.jsA tk
Diff
commit c5a54dfd7f57f19b46fd91f97344d6f7d1f88db0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 26 14:07:28 2026 -0700
ticket-system: shared fleet ticket store + tk CLI + kanban board :9794
---
.gitignore | 8 +++++++
lib.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 51 +++++++++++++++++++++++++++++++++++++++++++++
tk | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 191 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/lib.js b/lib.js
new file mode 100644
index 0000000..9367cd4
--- /dev/null
+++ b/lib.js
@@ -0,0 +1,62 @@
+// 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;
+}
+
+function nextId() {
+ let max = 0;
+ for (const ev of readEvents()) if (ev.type === 'create') { const n = parseInt(String(ev.id).replace('TK-', ''), 10); if (n > max) max = n; }
+ return 'TK-' + (max + 1);
+}
+
+module.exports = { withLock, append, tickets, nextId, STATUSES, EVENTS };
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..fd8c4c7
--- /dev/null
+++ b/server.js
@@ -0,0 +1,51 @@
+// Ticket board viewer — kanban over the shared ticket store. :9794, basic-auth admin/DW2024!, open /healthz.
+const http = require('http');
+const { tickets, STATUSES } = require('./lib.js');
+
+const PORT = process.env.PORT || 9794;
+const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toString('base64');
+
+const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+function page() {
+ const cols = { open: [], doing: [], blocked: [], done: [] };
+ for (const t of tickets().values()) cols[t.status].push(t);
+ for (const k of STATUSES) cols[k].sort((a, b) => a.updated_at < b.updated_at ? 1 : -1);
+ cols.done = cols.done.slice(0, 40);
+ const card = t => `<div class="card" onclick="this.classList.toggle('x')">
+ <div class="cid">${t.id}${t.project ? `<span class="proj">${esc(t.project)}</span>` : ''}</div>
+ <div class="ttl">${esc(t.title)}</div>
+ <div class="meta"><span class="who">${esc(t.assignee || 'unassigned')}</span>
+ <span class="when" title="${esc(t.created_at)}">🕓 ${new Date(t.created_at).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span></div>
+ <div class="thread">${t.comments.map(c => `<div class="c k-${c.kind}"><b>${esc(c.agent)}</b> <i>${esc(c.kind)}</i> ${esc(c.text)}<span class="cts">${new Date(c.ts).toLocaleString()}</span></div>`).join('')}
+ ${t.actions.map(a => `<div class="c k-action"><b>${esc(a.agent)}</b> <i>action</i> ${esc(a.text)}<span class="cts">${new Date(a.ts).toLocaleString()}</span></div>`).join('') || ''}
+ ${!t.comments.length && !t.actions.length ? '<div class="c none">no comments yet</div>' : ''}</div></div>`;
+ return `<!doctype html><meta charset="utf-8"><title>Fleet Tickets</title><meta http-equiv="refresh" content="30">
+<style>
+ body{margin:0;font:14px -apple-system,sans-serif;background:#0f1115;color:#e6e6e6}
+ h1{font-size:16px;margin:0;padding:14px 18px;border-bottom:1px solid #262a33;letter-spacing:.06em}
+ h1 small{color:#8a93a5;font-weight:400;margin-left:10px}
+ .board{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:14px;align-items:start}
+ .col h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#8a93a5;margin:4px 2px 8px}
+ .card{background:#171b22;border:1px solid #262a33;border-radius:8px;padding:10px 12px;margin-bottom:8px;cursor:pointer}
+ .cid{font-weight:600;color:#6db3f2;font-size:12px}.proj{float:right;color:#8a93a5;font-weight:400}
+ .ttl{margin:4px 0 6px}
+ .meta{display:flex;justify-content:space-between;font-size:11px;color:#8a93a5}
+ .when{white-space:nowrap}
+ .thread{display:none;margin-top:8px;border-top:1px dashed #2c3140;padding-top:6px}
+ .card.x .thread{display:block}
+ .c{font-size:12px;margin:4px 0;color:#c6ccd8}.c i{color:#8a93a5;font-style:normal;font-size:10px;margin:0 4px}
+ .c.k-note{color:#e8d48b}.c.k-action{color:#8fd49a}.c.none{color:#5b6270}
+ .cts{display:block;font-size:10px;color:#5b6270}
+ .col-doing .card{border-left:3px solid #6db3f2}.col-blocked .card{border-left:3px solid #e06c75}.col-done .card{opacity:.55}
+</style>
+<h1>FLEET TICKETS<small>every agent action rides a ticket — tk new / comment / note / log / take / done</small></h1>
+<div class="board">${STATUSES.map(s => `<div class="col col-${s}"><h2>${s} (${cols[s].length})</h2>${cols[s].map(card).join('') || '<div class="c none">empty</div>'}</div>`).join('')}</div>`;
+}
+
+http.createServer((req, res) => {
+ if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
+ if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
+ if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify([...tickets().values()])); }
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page());
+}).listen(PORT, '127.0.0.1', () => console.log('ticket board on :' + PORT));
diff --git a/tk b/tk
new file mode 100755
index 0000000..643e7a2
--- /dev/null
+++ b/tk
@@ -0,0 +1,70 @@
+#!/opt/homebrew/bin/node
+// tk — fleet ticket CLI. Every agent action ties to a ticket; comments/notes are the exchange channel.
+// 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 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 identity defaults to $TK_AGENT, else "claude@<tty-or-pid>".
+const { withLock, append, tickets, nextId, STATUSES } = 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); };
+const norm = id => { if (!id) die('missing ticket id'); id = id.toUpperCase(); return id.startsWith('TK-') ? id : 'TK-' + 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, project, agent, body }));
+ console.log(ev.id);
+} else if (cmd === 'comment' || cmd === 'note') {
+ 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 }); console.log(`${cmd} added to ${id}`);
+} 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 {
+ die('unknown command "' + cmd + '" — see header of this file for usage');
+}
(oldest)
·
back to Ticket System
·
auto-save: 2026-07-26T14:44:02 (2 files) — server.js tk 0744d30 →