[object Object]

← back to Ticket System

Add actionable blocker lanes

2d6d9cea515dda566b90b2d0eb49a2948a727744 · 2026-08-31 10:21:33 -0700 · Steve Abrams

Files touched

Diff

commit 2d6d9cea515dda566b90b2d0eb49a2948a727744
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 10:21:33 2026 -0700

    Add actionable blocker lanes
---
 board.html              |  8 +++++++-
 lib.js                  | 25 +++++++++++++++++++++++--
 lib.test.js             | 12 +++++++++++-
 mcp-server.js           |  6 ++++++
 test/mcp-server.test.js |  5 +++--
 tk                      | 12 +++++++++++-
 6 files changed, 61 insertions(+), 7 deletions(-)

diff --git a/board.html b/board.html
index 317cad39..2a172c55 100644
--- a/board.html
+++ b/board.html
@@ -91,7 +91,7 @@
   <span id="pills"></span>
   <input class="search" id="q" placeholder="search all fields (space = AND)…">
   <label style="font-size:11px;color:var(--mut)">Group
-    <select class="gsel" id="grp"><option value="none">none</option><option value="status">status</option><option value="project">project</option><option value="assignee">owner</option></select>
+    <select class="gsel" id="grp"><option value="none">none</option><option value="status">status</option><option value="blocker">blocker lane</option><option value="project">project</option><option value="assignee">owner</option></select>
   </label>
   <span id="dtdchip" title="last DTD run-now sweep">DTD: —</span>
   <span class="grow"></span>
@@ -126,6 +126,8 @@ const elapsed=s=>{const ms=Math.max(0,Date.now()-new Date(s||Date.now()).getTime
 const ticketCell=t=>{const short=esc(t.id.replace(/^(TK-\d+).*/,'$1')),running=t.status==='doing',since=running?(t.status_since||t.created_at):t.created_at,age=elapsed(since);return `<span class="ticket-top"><a class="cell mono" href="#" onclick="setQ('${esc(t.id)}');return false" title="${esc(t.id)}">${short}</a><span class="ticket-runtime${running?' running':''}" title="since ${esc(since||'unknown')}">${running?'running':'age'} ${age}</span></span>`;};
 const KIND_FACE={task:'Task',continuous_loop:'Continuous Loop',scheduled_job:'Scheduled Job'};
 const scheduleCell=t=>{const s=t.schedule||{};if(t.kind!=='scheduled_job')return t.kind==='continuous_loop'?'event-driven':'—';return esc(s.cron||s.cadence||'schedule missing')+(s.scheduler_label?`<br><small>${esc(s.scheduler_label)}</small>`:'');};
+const BLOCKER_FACE={steve_action:'👤 Steve action',external_wait:'⏳ External wait',technical_dependency:'🛠 Technical',intentional_guardrail:'🛡 Guardrail',resolved_candidate:'✓ Verify/close'};
+const blockerCell=t=>t.blocker?`<span class="stpill" title="${esc(t.blocker.condition)}">${BLOCKER_FACE[t.blocker.type]||esc(t.blocker.type)}</span>`:'—';
 // ── DTD run-now verdicts (fetched from /api/dtd): { TK-id: {verdict,yes,no,confidence} } ──
 let DTD={running:false,verdicts:null};
 const verdictOf=t=>{const v=DTD.verdicts&&DTD.verdicts.tickets&&DTD.verdicts.tickets[t.id];return v||null;};
@@ -151,6 +153,10 @@ const COLS=[
  {k:'ratings',l:'Ratings',g:'Priority',w:150, def:1, cell:t=>barsCell(t), raw:t=>t.priority||0},
  {k:'id',    l:'Ticket', g:'Core', w:150, def:1, cell:t=>ticketCell(t), raw:t=>t.id},
  {k:'status',l:'Status', g:'Core', w:96,  def:1, cell:t=>`<span class="stpill s-${t.status}"><span class="dot" style="background:currentColor"></span>${t.status==='stopped'?'TicketStopped':t.status}</span>`, raw:t=>t.status},
+ {k:'blocker',l:'Blocker lane',g:'Blocker',w:150,def:1,cell:t=>blockerCell(t),raw:t=>t.blocker?.type||''},
+ {k:'next_action',l:'Next unblock',g:'Blocker',w:320,def:1,cell:t=>esc(t.blocker?.next_action||'—'),raw:t=>t.blocker?.next_action||''},
+ {k:'blocker_owner',l:'Blocker owner',g:'Blocker',w:145,def:1,cell:t=>esc(t.blocker?.owner||'—'),raw:t=>t.blocker?.owner||''},
+ {k:'recheck_at',l:'Recheck',g:'Blocker',w:150,def:1,cell:t=>dt(t.blocker?.recheck_at),raw:t=>t.blocker?.recheck_at||''},
  {k:'kind',l:'Designation',g:'Core',w:130,def:1,cell:t=>esc(KIND_FACE[t.kind||'task']||t.kind),raw:t=>t.kind||'task'},
  {k:'schedule',l:'Schedule',g:'Core',w:170,def:1,cell:t=>scheduleCell(t),raw:t=>(t.schedule&&((t.schedule.cron||t.schedule.cadence)))||''},
  {k:'runnow',l:'Run now?',g:'Core',w:118, def:1, cell:t=>verdictCell(t), raw:t=>verdictRank(t)},
diff --git a/lib.js b/lib.js
index 82dfaa3b..2febe64b 100644
--- a/lib.js
+++ b/lib.js
@@ -39,6 +39,22 @@ function append(ev) { ensure(); fs.appendFileSync(EVENTS, JSON.stringify(ev) + '
 // last so legacy 4-status consumers that index by position are unaffected.
 const STATUSES = ['open', 'doing', 'blocked', 'done', 'stopped'];
 const KINDS = ['task', 'continuous_loop', 'scheduled_job'];
+const BLOCKER_TYPES = ['steve_action', 'external_wait', 'technical_dependency', 'intentional_guardrail', 'resolved_candidate'];
+function cleanBlocker(value) {
+  const src = value && typeof value === 'object' ? value : {};
+  const type = String(src.type || '').trim().toLowerCase();
+  if (!BLOCKER_TYPES.includes(type)) throw new Error('invalid blocker type: ' + type);
+  const out = { type };
+  for (const key of ['condition', 'next_action', 'owner', 'evidence_at', 'recheck_at']) {
+    if (src[key] != null && String(src[key]).trim()) out[key] = String(src[key]).trim();
+  }
+  out.steve_one_action = src.steve_one_action === true || String(src.steve_one_action).toLowerCase() === 'true';
+  if (!out.condition) throw new Error('blocker condition is required');
+  if (!out.next_action) throw new Error('blocker next_action is required');
+  if (!out.owner) throw new Error('blocker owner is required');
+  if (!out.evidence_at) throw new Error('blocker evidence_at is required');
+  return out;
+}
 const cleanSchedule = value => {
   const src = value && typeof value === 'object' ? value : {};
   const out = {};
@@ -63,6 +79,7 @@ function tickets() {
       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; t.status_since = ev.ts; }
       else if (ev.type === 'assign') t.assignee = ev.agent || '';
+      else if (ev.type === 'blocker') t.blocker = cleanBlocker(ev.blocker);
       else if (ev.type === 'designation' && KINDS.includes(ev.kind)) {
         t.kind = ev.kind;
         t.schedule = cleanSchedule(ev.schedule);
@@ -202,6 +219,10 @@ function ticketStatus(ref, status, { agent = 'codex', correlation_id = correlati
   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 ticketBlocker(ref, blocker, { agent = 'codex', correlation_id = correlationId('blocker') } = {}) {
+  const id = requireTicket(ref);
+  return append({ ts: now(), type: 'blocker', id, blocker: cleanBlocker(blocker), agent, correlation_id });
+}
 function ticketDesignate(ref, kind, { schedule = {}, parent_id = '', agent = 'codex', correlation_id = correlationId('designation') } = {}) {
   const id = requireTicket(ref); kind = String(kind || '').toLowerCase();
   if (!KINDS.includes(kind)) throw new Error('invalid kind: ' + kind);
@@ -243,7 +264,7 @@ const resolveList = (refs, map) => { const out = []; if (!Array.isArray(refs)) r
   for (const r of refs) { if (!REFRE.test(String(r).trim())) continue; const id = resolveId(r, map); if (id && IDRE.test(id)) out.push(id); }
   return [...new Set(out)]; };
 
-module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, KINDS, cleanSchedule, EVENTS,
+module.exports = { withLock, append, tickets, nextId, resolveId, idNum, slugify, STATUSES, KINDS, BLOCKER_TYPES, cleanBlocker, cleanSchedule, EVENTS,
   IDRE, REFRE, resolveList,
   nextMid, messages, inbox, thread, resolveMid, threadParticipants, parseMentions, isBroadcast, knownAgents,
-  correlationId, createTicket, ticketComment, ticketAction, ticketAssign, ticketStatus, ticketDesignate, sendDm, markMessageRead };
+  correlationId, createTicket, ticketComment, ticketAction, ticketAssign, ticketStatus, ticketBlocker, ticketDesignate, sendDm, markMessageRead };
diff --git a/lib.test.js b/lib.test.js
index 132f7332..f4cf23c4 100644
--- a/lib.test.js
+++ b/lib.test.js
@@ -13,7 +13,7 @@ process.env.TICKET_DATA_DIR = TMP;
 
 const lib = require('./lib.js');
 const {
-  append, tickets, resolveId, resolveList, IDRE, STATUSES, KINDS, ticketDesignate,
+  append, tickets, resolveId, resolveList, IDRE, STATUSES, KINDS, BLOCKER_TYPES, ticketBlocker, ticketDesignate,
   nextMid, messages, isBroadcast, withLock, EVENTS,
 } = lib;
 
@@ -88,6 +88,16 @@ test('scheduled designation requires cadence and folds scheduler metadata', () =
   assert.deepEqual(tickets().get(id).schedule, { cadence: '10m', scheduler_label: 'com.example.poll', enabled: true });
 });
 
+test('structured blocker metadata folds and validates required fields', () => {
+  const id = 'TK-10032-blocker';
+  append({ ts: iso(), type: 'create', id, title: 'Blocked dependency', agent: 'a' });
+  ticketBlocker(id, { type:'technical_dependency', condition:'API scope missing', next_action:'Grant read scope', owner:'steve', evidence_at:iso(), recheck_at:iso(3600000), steve_one_action:true });
+  const b = tickets().get(id).blocker;
+  assert.equal(b.type, 'technical_dependency'); assert.equal(b.owner, 'steve'); assert.equal(b.steve_one_action, true);
+  assert.deepEqual(BLOCKER_TYPES, ['steve_action','external_wait','technical_dependency','intentional_guardrail','resolved_candidate']);
+  assert.throws(() => ticketBlocker(id, { type:'bad' }), /invalid blocker type/);
+});
+
 // ── 3. comment vs action vs assign fold correctly; create body seeds a comment ─
 test('comment, action, and assign events fold into the right places', () => {
   const id = 'TK-10004-fold';
diff --git a/mcp-server.js b/mcp-server.js
index 4febc6cb..28236cb9 100644
--- a/mcp-server.js
+++ b/mcp-server.js
@@ -17,6 +17,11 @@ const TOOLS = [
   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_blocker', 'Set structured blocker metadata without weakening the ticket gate.', {
+    ticket: str('Ticket id or number'), type: str('Blocker lane', { enum: tk.BLOCKER_TYPES }), condition: str('Exact blocking condition'),
+    next_action: str('Single next-unblock action'), owner: str('Person or system responsible'), evidence_at: str('Evidence timestamp'),
+    recheck_at: str('Optional recheck timestamp'), steve_one_action: bool('Steve can resolve in one action'), agent: str('Actor identity')
+  }, ['ticket','type','condition','next_action','owner','evidence_at']),
   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']),
@@ -38,6 +43,7 @@ function call(name, a = {}) {
   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_blocker') { const ev = tk.ticketBlocker(a.ticket, a, { 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}; }
diff --git a/test/mcp-server.test.js b/test/mcp-server.test.js
index 96a34d77..a34a12c2 100644
--- a/test/mcp-server.test.js
+++ b/test/mcp-server.test.js
@@ -12,7 +12,7 @@ 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));
+  for (const name of ['tickets_list','ticket_create','ticket_take','ticket_log','ticket_blocker','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);
 });
 
@@ -23,9 +23,10 @@ test('Codex ticket lifecycle persists to one canonical event ledger', () => {
   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_blocker',{ticket:id,type:'technical_dependency',condition:'scope missing',next_action:'grant scope',owner:'steve',evidence_at:new Date().toISOString(),steve_one_action:true});
   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);
+  assert.equal(got.status,'done'); assert.equal(got.blocker.type,'technical_dependency'); 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));
 });
diff --git a/tk b/tk
index 574b9fae..1f0e5795 100755
--- a/tk
+++ b/tk
@@ -12,6 +12,7 @@
 //   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 blocker TK-3 <type> --condition "..." --next "..." --owner agent --evidence-at ISO [--recheck-at ISO] [--steve-one-action]
 //   tk done TK-3 [-a agent]                   # shorthand for status done
 //   tk designate TK-3 task|continuous_loop|scheduled_job [--cadence 10m|--cron "..."] [--scheduler-label label]
 //   tk list [--status s] [--project p] [--agent a] [--kind k] [--all]
@@ -23,7 +24,7 @@
 //   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, KINDS, cleanSchedule,
+const { withLock, append, tickets, nextId, resolveId, STATUSES, KINDS, BLOCKER_TYPES, cleanBlocker, cleanSchedule,
   nextMid, messages, inbox, thread, resolveMid, parseMentions, knownAgents, isBroadcast } = require(require('path').join(__dirname, 'lib.js'));
 
 const argv = process.argv.slice(2);
@@ -91,6 +92,14 @@ if (cmd === 'new') {
 } 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 === 'blocker') {
+  const id = norm(argv.shift()); const type = String(argv.shift() || '').toLowerCase();
+  const blocker = cleanBlocker({
+    type, condition: opt('--condition'), next_action: opt('--next'), owner: opt('--owner'),
+    evidence_at: opt('--evidence-at'), recheck_at: opt('--recheck-at'), steve_one_action: flagSet('--steve-one-action'),
+  });
+  append({ ts, type: 'blocker', id, blocker, agent });
+  console.log(`${id} blocker → ${blocker.type}`);
 } else if (cmd === 'designate') {
   const id = norm(argv.shift()); const kind = String(argv.shift() || '').toLowerCase();
   if (!KINDS.includes(kind)) die('kind must be one of: ' + KINDS.join(' '));
@@ -117,6 +126,7 @@ if (cmd === 'new') {
   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);
   if (t.kind !== 'task') console.log('designation ' + t.kind + '  schedule ' + JSON.stringify(t.schedule || {}) + (t.parent_id ? '  parent ' + t.parent_id : ''));
+  if (t.blocker) console.log('blocker ' + JSON.stringify(t.blocker));
   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') {

← b038f035 separate recurring work from task queue  ·  back to Ticket System  ·  Backfill blocked ticket classifications 94c9fbd3 →