[object Object]

← back to Ticket System

integrate Codex with shared ticket system

3443b04a8cac69e6e8c5f284ad7d8ac50db5ee9f · 2026-08-28 11:25:25 -0700 · Steve Abrams

Files touched

Diff

commit 3443b04a8cac69e6e8c5f284ad7d8ac50db5ee9f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 28 11:25:25 2026 -0700

    integrate Codex with shared ticket system
---
 CODEX-INTEGRATION.md        | 45 +++++++++++++++++++++++++++++++
 lib.js                      | 45 ++++++++++++++++++++++++++++---
 mcp-server.js               | 61 ++++++++++++++++++++++++++++++++++++++++++
 test/mcp-server.test.js     | 65 +++++++++++++++++++++++++++++++++++++++++++++
 verification/e2e-proof.json | 24 +++++++++++++++++
 5 files changed, 236 insertions(+), 4 deletions(-)

diff --git a/CODEX-INTEGRATION.md b/CODEX-INTEGRATION.md
new file mode 100644
index 00000000..f34670a4
--- /dev/null
+++ b/CODEX-INTEGRATION.md
@@ -0,0 +1,45 @@
+# Codex integration
+
+Codex and Claude use one ticket system and one append-only source of truth:
+
+`~/.claude/tickets/events.jsonl`
+
+Codex connects through the local stdio MCP server in `mcp-server.js`. The MCP is
+registered as `tickets` in `~/.codex/config.toml`; the existing `tk` CLI remains
+the fallback and the board at `tickets.agentabrams.com` remains a read/write view
+of the same ledger.
+
+## Native tools
+
+- `tickets_list`, `ticket_get`, `ticket_create`, `ticket_take`
+- `ticket_log`, `ticket_comment`, `ticket_status`
+- `ticket_dm`, `ticket_inbox`, `ticket_reply`, `ticket_thread`
+
+Every MCP mutation receives a correlation ID. Ticket actions/comments carry the
+ID through `lib.js` and the board API as `last_correlation_id`; A2A DMs and replies
+carry the same evidence in their raw events. Invalid mutations append nothing.
+
+## Operating loop
+
+For non-trivial work: check the inbox, scan for related tickets, create/take one,
+log material actions, use ticket-linked DMs for ownership and handoffs, then mark
+the ticket `done` or `blocked`. Subagents inherit the parent ticket. Approval gates
+are unchanged.
+
+Current Codex sessions need a restart or `/clear` to load a newly registered MCP.
+The CLI fallback is always available:
+
+```sh
+TK_AGENT=codex ~/Projects/ticket-system/tk inbox
+```
+
+## Verification
+
+```sh
+node --test test/mcp-server.test.js
+codex mcp get tickets
+```
+
+The public domain is protected by Cloudflare Access. Browser/API checks without a
+valid Access session correctly land on the Access sign-in page; loopback board API
+verification is the deterministic service-boundary proof.
diff --git a/lib.js b/lib.js
index a59d6090..460c50ca 100644
--- a/lib.js
+++ b/lib.js
@@ -4,7 +4,7 @@ const fs = require('fs');
 const path = require('path');
 const os = require('os');
 
-const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
+const DATA_DIR = process.env.TICKET_DATA_DIR || path.join(os.homedir(), '.claude', 'tickets');
 const EVENTS = path.join(DATA_DIR, 'events.jsonl');
 const LOCK = path.join(DATA_DIR, '.lock');
 
@@ -48,8 +48,9 @@ function tickets() {
       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 });
+      if (ev.correlation_id) t.last_correlation_id = ev.correlation_id;
+      if (ev.type === 'comment') t.comments.push({ ts: ev.ts, agent: ev.agent || '', kind: ev.kind || 'comment', text: ev.text, correlation_id: ev.correlation_id || '' });
+      else if (ev.type === 'action') t.actions.push({ ts: ev.ts, agent: ev.agent || '', text: ev.text, correlation_id: ev.correlation_id || '' });
       else if (ev.type === 'status' && STATUSES.includes(ev.status)) t.status = ev.status;
       else if (ev.type === 'assign') t.assignee = ev.agent || '';
     }
@@ -157,6 +158,41 @@ function nextId(title) {
   const slug = slugify(title);
   return 'TK-' + num + (slug ? '-' + slug : '');
 }
+
+const now = () => new Date().toISOString();
+const correlationId = (prefix = 'tk') => `${prefix}-${Date.now().toString(36)}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
+function requireTicket(ref) { const id = resolveId(ref); if (!id) throw new Error('no such ticket ' + ref); return id; }
+function createTicket({ title, project = '', agent = 'codex', body = '', correlation_id = correlationId('create') }) {
+  title = String(title || '').trim(); if (!title) throw new Error('title is required');
+  return withLock(() => append({ ts: now(), type: 'create', id: nextId(title), title, project, agent, body, correlation_id }));
+}
+function ticketComment(ref, text, { kind = 'comment', agent = 'codex', correlation_id = correlationId(kind) } = {}) {
+  const id = requireTicket(ref); text = String(text || '').trim(); if (!text) throw new Error('text is required');
+  const ev = append({ ts: now(), type: 'comment', id, kind, agent, text, correlation_id });
+  const known = knownAgents();
+  for (const to of parseMentions(text).filter(a => a !== agent && (isBroadcast(a) || known.has(a))))
+    sendDm(to, text, { ticket: id, agent, correlation_id });
+  return ev;
+}
+function ticketAction(ref, text, { agent = 'codex', correlation_id = correlationId('action') } = {}) {
+  const id = requireTicket(ref); text = String(text || '').trim(); if (!text) throw new Error('text is required');
+  return append({ ts: now(), type: 'action', id, agent, text, correlation_id });
+}
+function ticketAssign(ref, agent = 'codex', correlation_id = correlationId('assign')) {
+  const id = requireTicket(ref), ts = now(); append({ ts, type: 'assign', id, agent, correlation_id }); append({ ts, type: 'status', id, status: 'doing', agent, correlation_id }); return { id, agent, status: 'doing', correlation_id };
+}
+function ticketStatus(ref, status, { agent = 'codex', correlation_id = correlationId('status') } = {}) {
+  const id = requireTicket(ref); status = String(status || '').toLowerCase(); if (!STATUSES.includes(status)) throw new Error('invalid status: ' + status);
+  return append({ ts: now(), type: 'status', id, status, agent, correlation_id });
+}
+function sendDm(to, text, { ticket = '', agent = 'codex', re = '', correlation_id = correlationId('dm') } = {}) {
+  to = String(to || '').trim(); text = String(text || '').trim(); if (!to || !text) throw new Error('to and text are required');
+  const ticketId = ticket ? requireTicket(ticket) : '';
+  return withLock(() => { const mid = nextMid(); return append({ ts: now(), type: 'dm', mid, from: agent, to, text, ticket: ticketId, re, correlation_id }); });
+}
+function markMessageRead(mid, agent = 'codex', correlation_id = correlationId('read')) {
+  const id = resolveMid(mid); if (!id) throw new Error('no such message ' + mid); return append({ ts: now(), type: 'read', mid: id, agent, correlation_id });
+}
 // 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) {
@@ -172,4 +208,5 @@ function resolveId(ref, map) {
 }
 
 module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, EVENTS,
-  nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast, knownAgents };
+  nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast, knownAgents,
+  correlationId, createTicket, ticketComment, ticketAction, ticketAssign, ticketStatus, sendDm, markMessageRead };
diff --git a/mcp-server.js b/mcp-server.js
new file mode 100644
index 00000000..4febc6cb
--- /dev/null
+++ b/mcp-server.js
@@ -0,0 +1,61 @@
+#!/opt/homebrew/bin/node
+'use strict';
+
+const readline = require('readline');
+const tk = require('./lib.js');
+const SERVER = { name: 'agentabrams-tickets', version: '1.0.0' };
+const AGENT = process.env.TK_AGENT || 'codex';
+
+const tool = (name, description, properties = {}, required = []) => ({ name, description, inputSchema: { type: 'object', properties, required, additionalProperties: false } });
+const str = (description, extra = {}) => ({ type: 'string', description, ...extra });
+const bool = description => ({ type: 'boolean', description });
+const TOOLS = [
+  tool('tickets_list', 'List canonical tickets from tickets.agentabrams.com.', { status: str('Optional status filter', { enum: tk.STATUSES }), project: str('Optional project filter'), agent: str('Optional assignee filter'), include_done: bool('Include done tickets') }),
+  tool('ticket_get', 'Read one ticket with actions, comments, and correlation evidence.', { ticket: str('Ticket id or number') }, ['ticket']),
+  tool('ticket_create', 'Create a ticket in the canonical shared ledger.', { title: str('Ticket title'), project: str('Project name'), body: str('Opening context'), agent: str('Actor identity') }, ['title']),
+  tool('ticket_take', 'Assign a ticket and move it to doing.', { ticket: str('Ticket id or number'), agent: str('Assignee identity') }, ['ticket']),
+  tool('ticket_log', 'Append an auditable action to a ticket.', { ticket: str('Ticket id or number'), text: str('Action and evidence'), agent: str('Actor identity') }, ['ticket', 'text']),
+  tool('ticket_comment', 'Add a comment, handoff note, win, challenge, or dissent. Known @mentions become ticket-linked DMs.', { ticket: str('Ticket id or number'), text: str('Comment text'), kind: str('Comment kind', { enum: ['comment','note','win','challenge','cody'] }), agent: str('Actor identity') }, ['ticket', 'text']),
+  tool('ticket_status', 'Set ticket status.', { ticket: str('Ticket id or number'), status: str('New status', { enum: tk.STATUSES }), agent: str('Actor identity') }, ['ticket', 'status']),
+  tool('ticket_dm', 'Send an A2A direct message, optionally correlated to a ticket.', { to: str('Recipient agent or all'), text: str('Message'), ticket: str('Optional ticket id'), agent: str('Sender identity') }, ['to', 'text']),
+  tool('ticket_inbox', 'Read A2A messages for an agent.', { agent: str('Recipient identity'), include_read: bool('Include already-read messages'), mark_read: bool('Append read receipts') }),
+  tool('ticket_reply', 'Reply in an A2A thread.', { message: str('Parent message id'), text: str('Reply text'), agent: str('Sender identity') }, ['message', 'text']),
+  tool('ticket_thread', 'Read a complete A2A DM thread.', { message: str('Any message id in the thread') }, ['message']),
+];
+
+const actor = a => String(a || AGENT);
+const cleanTicket = t => t ? { ...t } : null;
+function call(name, a = {}) {
+  if (name === 'tickets_list') {
+    let rows = [...tk.tickets().values()];
+    if (!a.include_done) rows = rows.filter(t => t.status !== 'done'); if (a.status) rows = rows.filter(t => t.status === a.status);
+    if (a.project) rows = rows.filter(t => t.project === a.project); if (a.agent) rows = rows.filter(t => t.assignee === a.agent);
+    return rows.sort((x,y) => x.updated_at < y.updated_at ? 1 : -1).map(cleanTicket);
+  }
+  if (name === 'ticket_get') { const id = tk.resolveId(a.ticket); if (!id) throw Error('no such ticket ' + a.ticket); return cleanTicket(tk.tickets().get(id)); }
+  if (name === 'ticket_create') { const ev = tk.createTicket({ title:a.title, project:a.project, body:a.body, agent:actor(a.agent) }); return { ticket: cleanTicket(tk.tickets().get(ev.id)), correlation_id: ev.correlation_id }; }
+  if (name === 'ticket_take') { const ev = tk.ticketAssign(a.ticket, actor(a.agent)); return { ticket: cleanTicket(tk.tickets().get(ev.id)), correlation_id: ev.correlation_id }; }
+  if (name === 'ticket_log') { const ev = tk.ticketAction(a.ticket, a.text, { agent:actor(a.agent) }); return { ticket: cleanTicket(tk.tickets().get(ev.id)), correlation_id: ev.correlation_id }; }
+  if (name === 'ticket_comment') { const ev = tk.ticketComment(a.ticket, a.text, { kind:a.kind || 'comment', agent:actor(a.agent) }); return { ticket: cleanTicket(tk.tickets().get(ev.id)), correlation_id: ev.correlation_id }; }
+  if (name === 'ticket_status') { const ev = tk.ticketStatus(a.ticket, a.status, { agent:actor(a.agent) }); return { ticket: cleanTicket(tk.tickets().get(ev.id)), correlation_id: ev.correlation_id }; }
+  if (name === 'ticket_dm') { const ev = tk.sendDm(a.to, a.text, { ticket:a.ticket, agent:actor(a.agent) }); return { message: ev, correlation_id: ev.correlation_id }; }
+  if (name === 'ticket_inbox') { const who=actor(a.agent), rows=tk.inbox(who,{unreadOnly:!a.include_read}); if(a.mark_read) for(const m of rows) if(!m.reads.includes(who)) tk.markMessageRead(m.mid,who); return rows; }
+  if (name === 'ticket_reply') { const mid=tk.resolveMid(a.message), parent=mid&&tk.messages().get(mid); if(!parent) throw Error('no such message '+a.message); const from=actor(a.agent), to=parent.from===from?parent.to:parent.from; const ev=tk.sendDm(to,a.text,{ticket:parent.ticket,agent:from,re:mid}); tk.markMessageRead(mid,from,ev.correlation_id); return {message:ev,correlation_id:ev.correlation_id}; }
+  if (name === 'ticket_thread') { const mid=tk.resolveMid(a.message); if(!mid) throw Error('no such message '+a.message); return tk.thread(mid); }
+  throw Error('unknown tool ' + name);
+}
+
+const payload = value => ({ content: [{ type:'text', text:JSON.stringify(value,null,2) }], structuredContent:value });
+function handle(msg) {
+  if (msg.method === 'initialize') return { jsonrpc:'2.0', id:msg.id, result:{ protocolVersion:msg.params?.protocolVersion || '2025-06-18', capabilities:{tools:{listChanged:false}}, serverInfo:SERVER } };
+  if (msg.method === 'ping') return { jsonrpc:'2.0', id:msg.id, result:{} };
+  if (msg.method === 'tools/list') return { jsonrpc:'2.0', id:msg.id, result:{tools:TOOLS} };
+  if (msg.method === 'tools/call') { try{return {jsonrpc:'2.0',id:msg.id,result:payload(call(msg.params?.name,msg.params?.arguments||{}))};}catch(e){return {jsonrpc:'2.0',id:msg.id,result:{isError:true,content:[{type:'text',text:e.message}]}};} }
+  if (msg.id != null) return { jsonrpc:'2.0', id:msg.id, error:{code:-32601,message:'Method not found'} };
+  return null;
+}
+
+if (require.main === module) {
+  readline.createInterface({input:process.stdin,crlfDelay:Infinity}).on('line', line => { try { const out=handle(JSON.parse(line)); if(out) process.stdout.write(JSON.stringify(out)+'\n'); } catch(e) { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:null,error:{code:-32700,message:e.message}})+'\n'); } });
+}
+module.exports = { TOOLS, call, handle };
diff --git a/test/mcp-server.test.js b/test/mcp-server.test.js
new file mode 100644
index 00000000..96a34d77
--- /dev/null
+++ b/test/mcp-server.test.js
@@ -0,0 +1,65 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tickets-mcp-'));
+process.env.TICKET_DATA_DIR = tmp;
+process.env.TK_AGENT = 'codex-test';
+const { TOOLS, call, handle } = require('../mcp-server');
+
+test('advertises native ticket and A2A tools', () => {
+  const names = new Set(TOOLS.map(t => t.name));
+  for (const name of ['tickets_list','ticket_create','ticket_take','ticket_log','ticket_dm','ticket_inbox','ticket_reply']) assert.ok(names.has(name));
+  assert.equal(handle({jsonrpc:'2.0',id:1,method:'tools/list'}).result.tools.length, TOOLS.length);
+});
+
+test('Codex ticket lifecycle persists to one canonical event ledger', () => {
+  const made = call('ticket_create',{title:'MCP E2E',project:'ticket-system'});
+  const id = made.ticket.id;
+  assert.match(made.correlation_id,/^create-/);
+  call('ticket_take',{ticket:id});
+  const logged = call('ticket_log',{ticket:id,text:'verified shared write'});
+  call('ticket_comment',{ticket:id,text:'handoff ready',kind:'note'});
+  call('ticket_status',{ticket:id,status:'done'});
+  const got = call('ticket_get',{ticket:id});
+  assert.equal(got.status,'done'); assert.equal(got.actions.at(-1).text,'verified shared write'); assert.equal(got.actions.at(-1).correlation_id,logged.correlation_id);
+  const lines=fs.readFileSync(path.join(tmp,'events.jsonl'),'utf8').trim().split('\n').map(JSON.parse);
+  assert.ok(lines.every(e=>e.correlation_id));
+});
+
+test('A2A DM receipt, reply, correlation, and read transition work end to end', () => {
+  const t=call('ticket_create',{title:'A2A E2E'}).ticket;
+  const sent=call('ticket_dm',{to:'reviewer',text:'please verify',ticket:t.id});
+  const received=call('ticket_inbox',{agent:'reviewer'});
+  assert.equal(received.at(-1).mid,sent.message.mid); assert.equal(received.at(-1).ticket,t.id);
+  const reply=call('ticket_reply',{message:sent.message.mid,text:'verified',agent:'reviewer'});
+  assert.equal(reply.message.re,sent.message.mid); assert.match(reply.correlation_id,/^dm-/);
+  call('ticket_inbox',{agent:'codex-test',mark_read:true});
+  const thread=call('ticket_thread',{message:sent.message.mid});
+  assert.equal(thread.length,2); assert.equal(thread[1].text,'verified');
+});
+
+test('invalid ticket mutation fails without appending', () => {
+  const before=fs.readFileSync(path.join(tmp,'events.jsonl'),'utf8');
+  assert.throws(()=>call('ticket_log',{ticket:'TK-99999',text:'bad'}),/no such ticket/);
+  assert.equal(fs.readFileSync(path.join(tmp,'events.jsonl'),'utf8'),before);
+});
+
+test('stdio MCP transport initializes, lists tools, and executes a correlated write', async () => {
+  const transportDir=fs.mkdtempSync(path.join(os.tmpdir(),'tickets-mcp-transport-'));
+  const child=spawn(process.execPath,[path.join(__dirname,'..','mcp-server.js')],{env:{...process.env,TICKET_DATA_DIR:transportDir,TK_AGENT:'codex-e2e'},stdio:['pipe','pipe','pipe']});
+  const pending=new Map(); let buf='';
+  child.stdout.on('data',chunk=>{buf+=chunk;for(;;){const i=buf.indexOf('\n');if(i<0)break;const line=buf.slice(0,i);buf=buf.slice(i+1);if(!line)continue;const m=JSON.parse(line);const done=pending.get(m.id);if(done){pending.delete(m.id);done(m);}}});
+  const rpc=(id,method,params={})=>new Promise((resolve,reject)=>{pending.set(id,resolve);child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n',e=>e&&reject(e));setTimeout(()=>{if(pending.delete(id))reject(Error('MCP timeout '+method));},3000).unref();});
+  const init=await rpc(1,'initialize',{protocolVersion:'2025-06-18',capabilities:{},clientInfo:{name:'test',version:'1'}});
+  assert.equal(init.result.serverInfo.name,'agentabrams-tickets');
+  const listed=await rpc(2,'tools/list'); assert.ok(listed.result.tools.some(t=>t.name==='ticket_create'));
+  const made=await rpc(3,'tools/call',{name:'ticket_create',arguments:{title:'Transport E2E'}});
+  assert.equal(made.result.isError,undefined); assert.match(made.result.structuredContent.correlation_id,/^create-/);
+  child.stdin.end(); await new Promise(resolve=>child.once('exit',resolve));
+  const events=fs.readFileSync(path.join(transportDir,'events.jsonl'),'utf8').trim().split('\n').map(JSON.parse);
+  assert.equal(events.length,1); assert.equal(events[0].title,'Transport E2E');
+});
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
new file mode 100644
index 00000000..156005ce
--- /dev/null
+++ b/verification/e2e-proof.json
@@ -0,0 +1,24 @@
+{
+  "schema_version": "1.0",
+  "ticket": "TK-10924-integrate-codex-with-tickets-agentabrams",
+  "intent": "Integrate Codex natively with tickets.agentabrams.com without creating a second ticket store.",
+  "risk_tier": 3,
+  "architecture": {
+    "decision": "Option A: local stdio MCP backed by the existing lib.js and canonical ledger",
+    "dtd_vote": "5/5 available voters for A; Claude unavailable",
+    "post_decision_codex": "KEEP",
+    "source_of_truth": "~/.claude/tickets/events.jsonl",
+    "cli_fallback": "~/Projects/ticket-system/tk"
+  },
+  "checks": [
+    {"boundary":"unit","assertion":"tool discovery and ticket lifecycle","result":"PASS"},
+    {"boundary":"stdio MCP","assertion":"initialize, tools/list, and correlated ticket_create","result":"PASS"},
+    {"boundary":"A2A","assertion":"DM receipt, ticket correlation, reply threading, and read transition","result":"PASS"},
+    {"boundary":"negative","assertion":"invalid ticket mutation appends no event","result":"PASS"},
+    {"boundary":"Codex config","assertion":"codex mcp get tickets reports enabled stdio server","result":"PASS"},
+    {"boundary":"canonical ledger to board API","assertion":"MCP action correlation action-mtd9kxz3-50344-cidbnd appears on TK-10924","result":"PASS"},
+    {"boundary":"public edge","assertion":"tickets.agentabrams.com is protected by Cloudflare Access","result":"PASS: redirected to Access sign-in as designed"}
+  ],
+  "cleanup":"Temporary test ledgers used isolated directories. Canonical TK-10924 and its evidence are intentionally retained.",
+  "verdict":"PASS"
+}

← 73459dec auto-data-snapshot: 2026-08-27T10:10:28 (1 data files) — TK-  ·  back to Ticket System  ·  TK-10971: hermetic test suite for ticket-system core (lib.js 6f55ede9 →