← back to Ticket System
ticket board: group + multi-select, batched-DTD run-now yes/no, TicketStopped status, Run=open iTerm2 Claude session (TK-10527)
a1c6c32ee34568d455b1334b6e14bd3652ecfd35 · 2026-08-13 08:25:17 -0700 · Steve Abrams
Files touched
M board.htmlA dtd-run.jsM lib.jsM morning-digest.jsA run-ticket.shM server.js
Diff
commit a1c6c32ee34568d455b1334b6e14bd3652ecfd35
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 13 08:25:17 2026 -0700
ticket board: group + multi-select, batched-DTD run-now yes/no, TicketStopped status, Run=open iTerm2 Claude session (TK-10527)
---
board.html | 140 ++++++++++++++++++++++++++++++++++++++++-------
dtd-run.js | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib.js | 6 +-
morning-digest.js | 4 +-
run-ticket.sh | 50 +++++++++++++++++
server.js | 96 ++++++++++++++++++++++++++++++--
6 files changed, 429 insertions(+), 28 deletions(-)
diff --git a/board.html b/board.html
index ce902a0a..3381cd26 100644
--- a/board.html
+++ b/board.html
@@ -2,10 +2,10 @@
<title>Fleet Tickets — Table</title>
<style>
:root{--bg:#0f1115;--card:#171b22;--line:#262a33;--ink:#e6e6e6;--mut:#8a93a5;--faint:#5b6270;--acc:#6db3f2;--gold:#C8A24B;
- --open:#4ea1ff;--doing:#6db3f2;--blocked:#e06c75;--done:#34d399;--fs:12.5px;--rowpad:7px}
+ --open:#4ea1ff;--doing:#6db3f2;--blocked:#e06c75;--done:#34d399;--stopped:#6b7280;--fs:12.5px;--rowpad:7px}
*{box-sizing:border-box}
body{margin:0;font:14px -apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--ink)}
- header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
+ header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:7}
h1{font-size:15px;margin:0;letter-spacing:.06em;white-space:nowrap}
.pill{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);background:var(--card);border:1px solid var(--line);border-radius:20px;padding:3px 9px;cursor:pointer;user-select:none}
.pill b{color:var(--ink)} .pill.on{outline:1px solid var(--acc)}
@@ -13,9 +13,12 @@
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.search{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 11px;font-size:13px;min-width:200px}
.btn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 10px;font-size:12px;cursor:pointer}
- .btn:hover{border-color:var(--acc)}
+ .btn:hover{border-color:var(--acc)} .btn:disabled{opacity:.4;cursor:default}
+ select.gsel{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 8px;font-size:12px}
.grow{flex:1 1 auto}
#dens{width:96px;accent-color:var(--acc)}
+ #dtdchip{font-size:11px;color:var(--mut);white-space:nowrap}
+ #dtdchip b{color:var(--ink)}
.tblwrap{overflow:auto;max-height:calc(100vh - 52px)}
table.g{border-collapse:separate;border-spacing:0;width:100%;font-size:var(--fs);white-space:nowrap;table-layout:fixed}
table.g thead th{position:sticky;top:0;background:#1b2230;color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;
@@ -24,18 +27,36 @@
table.g thead th.asc::after{content:' ▲';color:var(--gold)} table.g thead th.desc::after{content:' ▼';color:var(--gold)}
th.dragging{opacity:.4} th.dropL{box-shadow:inset 3px 0 0 var(--gold)} th.dropR{box-shadow:inset -3px 0 0 var(--gold)}
th .rsz{position:absolute;right:0;top:0;width:7px;height:100%;cursor:col-resize;z-index:3}
+ th.cb,td.cb{width:34px;text-align:center;padding-left:6px;padding-right:6px;cursor:default}
+ th.cb input,td.cb input{cursor:pointer;accent-color:var(--acc)}
table.g td{padding:var(--rowpad) 10px;border-bottom:1px solid #1c2028;overflow:hidden;text-overflow:ellipsis;vertical-align:top}
tr.row{cursor:pointer} tr.row:hover td{background:#141821}
+ tr.row.sel td{background:#182233} tr.row.sel:hover td{background:#1c2942}
+ tr.row.stopped td{opacity:.5}
tr.det td{background:#10131a;white-space:normal;cursor:default}
+ tr.grp td{background:#12161f;font-weight:600;color:#c9a4f2;cursor:pointer;letter-spacing:.02em}
+ tr.grp td .gcount{color:var(--faint);font-weight:400;margin-left:8px} tr.grp td .gcx{color:var(--faint);font-size:10px;margin-right:8px}
+ tr.grp input{accent-color:var(--acc);margin-right:8px;cursor:pointer;vertical-align:-1px}
.mono{font-family:ui-monospace,Menlo,monospace}
a.cell{color:var(--acc);text-decoration:none} a.cell:hover{text-decoration:underline}
.stpill{display:inline-flex;align-items:center;gap:5px;font-size:11px;padding:1px 8px;border-radius:20px;border:1px solid var(--line)}
- .s-open{color:var(--open)} .s-doing{color:var(--doing)} .s-blocked{color:var(--blocked)} .s-done{color:var(--done);opacity:.8}
+ .s-open{color:var(--open)} .s-doing{color:var(--doing)} .s-blocked{color:var(--blocked)} .s-done{color:var(--done);opacity:.8} .s-stopped{color:var(--stopped);text-decoration:line-through}
+ .vb{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 7px;border-radius:20px;border:1px solid var(--line)}
+ .vb.yes{color:#34d399;border-color:#2b6f52} .vb.no{color:#e06c75} .vb.unknown{color:var(--faint)} .vb.none{color:var(--faint);opacity:.6}
+ .vb small{color:var(--faint)}
.who{color:#c9a4f2;cursor:pointer} .proj{color:var(--mut);cursor:pointer}
.num{color:var(--mut);text-align:right}
.thread .c{font-size:12px;margin:4px 0;color:#c6ccd8} .thread .c i{color:var(--mut);font-style:normal;font-size:10px;margin:0 4px}
.c.k-note{color:#e8d48b}.c.k-action{color:#8fd49a}.c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}.c.none{color:var(--faint)}
.cts{color:var(--faint);font-size:10px;margin-left:6px}
+ /* selection action bar */
+ #actionbar{display:none;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--line);background:#12161f;position:sticky;top:52px;z-index:6}
+ #actionbar.show{display:flex}
+ #actionbar .n{color:var(--acc);font-weight:600}
+ .btn.run{border-color:#2b6f52;color:#8fd49a}.btn.run:hover{border-color:#34d399}
+ .btn.stop{border-color:#7a3b40;color:#e79aa2}.btn.stop:hover{border-color:#e06c75}
+ .btn.dtd{border-color:#5a4a7a;color:#d8bdf6}.btn.dtd:hover{border-color:#c9a4f2}
+ .btn.reopen{border-color:#3a5a7a;color:#9ec7f2}
#colmenu,#pillmenu,#detmenu{position:absolute;top:44px;right:14px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px;display:none;z-index:10;max-height:70vh;overflow:auto;min-width:180px}
#colmenu.show,#pillmenu.show,#detmenu.show{display:block} #colmenu label,#pillmenu label,#detmenu label{display:flex;gap:7px;align-items:center;font-size:12px;padding:3px 6px;cursor:pointer;border-radius:6px} #colmenu label:hover,#pillmenu label:hover,#detmenu label:hover{background:#1c2130}
#colmenu .gh,#pillmenu .gh,#detmenu .gh{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--faint);margin:6px 6px 2px}
@@ -51,6 +72,10 @@
<h1>FLEET TICKETS</h1>
<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>
+ </label>
+ <span id="dtdchip" title="last DTD run-now sweep">DTD: —</span>
<span class="grow"></span>
<input type="range" id="dens" min="4" max="14" step="1" title="row density">
<button class="btn" id="colsBtn">▦ Columns</button>
@@ -60,6 +85,16 @@
<button class="btn" id="resetBtn" title="Reset all view settings">↺ Reset</button>
<a class="btn" href="/kanban">Kanban</a><a class="btn" href="/office">Office</a>
</header>
+<div id="actionbar">
+ <span><span class="n" id="seln">0</span> selected</span>
+ <button class="btn run" id="runBtn" title="Open a new iTerm2 window with Claude loaded, one per selected ticket">▶ Run Now</button>
+ <button class="btn dtd" id="dtdBtn" title="Batched /dtd — clear yes/no 'run now' verdict per recent ticket">🧠 Analyze w/ DTD</button>
+ <button class="btn" id="yesBtn" title="Add every ticket DTD said YES to the selection">✅ Select all YES</button>
+ <button class="btn stop" id="stopBtn" title="TicketStopped — stop forever (reversible)">⛔ Stop Forever</button>
+ <button class="btn reopen" id="reopenBtn" title="Un-stop / reopen selected">↩ Reopen</button>
+ <span class="grow"></span>
+ <button class="btn" id="clearBtn">Clear selection</button>
+</div>
<div id="colmenu"></div>
<div id="pillmenu"></div>
<div id="detmenu"></div>
@@ -69,11 +104,20 @@
const $=s=>document.querySelector(s), esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const dt=s=>{if(!s)return '—';const d=new Date(s);if(isNaN(d))return '—';return '<span title="'+esc(new Date(s).toISOString())+'">'+esc(d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}))+'</span>';};
const agoH=s=>s?((Date.now()-new Date(s).getTime())/3600000):null;
+// ── 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;};
+const verdictRank=t=>{const v=verdictOf(t);if(!v)return -1;return v.verdict==='yes'?2:v.verdict==='no'?0:1;};
+function verdictCell(t){const v=verdictOf(t);if(!v)return '<span class="vb none">—</span>';
+ const cls=v.verdict==='yes'?'yes':v.verdict==='no'?'no':'unknown';
+ const face=v.verdict==='yes'?'✅ YES':v.verdict==='no'?'⛔ no':'? unk';
+ return `<span class="vb ${cls}" title="${esc(v.voters?v.voters.join(' '):'')}">${face} <small>${v.yes}✓/${v.no}✗</small></span>`;}
// column defs — order here is the default order; every field sortable; g=group for the toggle menu
const COLS=[
{k:'id', l:'Ticket', g:'Core', w:150, def:1, cell:t=>`<a class="cell mono" href="#" onclick="setQ('${esc(t.id)}');return false" title="${esc(t.id)}">${esc(t.id.replace(/^(TK-\d+).*/,'$1'))}</a>`, 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}</span>`, raw:t=>t.status},
- {k:'title', l:'Title', g:'Core', w:420, def:1, cell:t=>esc(t.title), raw:t=>t.title},
+ {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:'runnow',l:'Run now?',g:'Core',w:118, def:1, cell:t=>verdictCell(t), raw:t=>verdictRank(t)},
+ {k:'title', l:'Title', g:'Core', w:400, def:1, cell:t=>esc(t.title), raw:t=>t.title},
{k:'project',l:'Project',g:'Core',w:150, def:1, cell:t=>t.project?`<span class="proj" onclick="setQ('${esc(t.project)}')">${esc(t.project)}</span>`:'—', raw:t=>t.project||''},
{k:'assignee',l:'Owner',g:'Core', w:150, def:1, cell:t=>t.assignee?`<span class="who" onclick="setQ('${esc(t.assignee)}')">${esc(t.assignee)}</span>`:'—', raw:t=>t.assignee||''},
{k:'updated_at',l:'Updated',g:'Time',w:150,def:1,cell:t=>dt(t.updated_at),raw:t=>t.updated_at||''},
@@ -89,20 +133,28 @@ const CK=COLS.map(c=>c.k);
const LS=(k,d)=>{try{return JSON.parse(localStorage.getItem(k))??d}catch(e){return d}}, save=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}};
let DATA=[], q='', sortKey='age', sortDir=-1;
let VIS=LS('tkCols',null)||Object.fromEntries(COLS.map(c=>[c.k,!!c.def]));
+for(const c of COLS)if(!(c.k in VIS))VIS[c.k]=!!c.def; // new cols (runnow) default ON even with an old stored set
let ORDER=(Array.isArray(LS('tkOrder',null))?LS('tkOrder',null):[]).filter(k=>CK.includes(k)); for(const k of CK)if(!ORDER.includes(k))ORDER.push(k);
let WID=LS('tkWid',{}), DENS=LS('tkDens',7);
+let GROUP=LS('tkGroup','none');
+let SEL=new Set(); // selected ticket ids — survives re-render/auto-refresh
+let COLLAPSED=new Set(); // collapsed group keys
// toggleable stat pills — mirrors the column-visibility pattern (persists to localStorage)
-const PILLS=[{k:'open',l:'open'},{k:'doing',l:'doing'},{k:'blocked',l:'blocked'},{k:'done',l:'done'},{k:'stale',l:'stale'},{k:'total',l:'total'},{k:'shown',l:'shown'}];
+const PILLS=[{k:'open',l:'open'},{k:'doing',l:'doing'},{k:'blocked',l:'blocked'},{k:'done',l:'done'},{k:'stopped',l:'stopped'},{k:'stale',l:'stale'},{k:'total',l:'total'},{k:'shown',l:'shown'}];
let PILL_VIS=Object.assign(Object.fromEntries(PILLS.map(p=>[p.k,1])),LS('tkPills',{})); // merge → new pills default ON even with an old stored set
// toggleable expanded-row detail fields (by kind) + DM-panel show/hide — both persist
const DET=[{k:'comment',l:'comments'},{k:'note',l:'notes'},{k:'action',l:'actions'},{k:'win',l:'wins'},{k:'challenge',l:'challenges'},{k:'cody',l:'cody dissents'}];
let DET_VIS=LS('tkDet',null)||Object.fromEntries(DET.map(d=>[d.k,1]));
let DM_ON=LS('tkDM',1), LASTMSG=[];
-document.documentElement.style.setProperty('--rowpad',DENS+'px'); $('#dens').value=DENS;
+document.documentElement.style.setProperty('--rowpad',DENS+'px'); $('#dens').value=DENS; $('#grp').value=GROUP;
const ordered=()=>ORDER.map(k=>COLS.find(c=>c.k===k)).filter(c=>c&&VIS[c.k]);
function renderHead(){
const th=$('#thead'); th.innerHTML='';
+ // fixed leading select-all checkbox (outside the draggable/resizable COLS)
+ const cbth=document.createElement('th'); cbth.className='cb';
+ cbth.innerHTML='<input type="checkbox" id="selall" title="select all shown">';
+ th.appendChild(cbth);
for(const c of ordered()){
const el=document.createElement('th'); el.dataset.k=c.k; el.draggable=true;
el.innerHTML=esc(c.l)+'<span class="rsz"></span>';
@@ -122,42 +174,87 @@ function renderHead(){
ORDER=ORDER.filter(k=>k!==src);let i=ORDER.indexOf(c.k);ORDER.splice(after?i+1:i,0,src);save('tkOrder',ORDER);render();});
th.appendChild(el);
}
+ $('#selall').onclick=e=>{const on=e.target.checked;const vis=DATA.filter(match);if(on)for(const t of vis)SEL.add(t.id);else for(const t of vis)SEL.delete(t.id);render();syncBar();};
}
function match(t){if(!q)return true;
// search the FULL thread — every comment (note/win/challenge/cody incl.) + every action — not just lastText
const thread=[...(t.comments||[]).map(c=>c.text),...(t.actions||[]).map(a=>a.text)].join(' ');
const hay=(t.id+' '+t.title+' '+(t.project||'')+' '+(t.assignee||'')+' '+t.status+' '+thread).toLowerCase();
return q.toLowerCase().split(/\s+/).every(w=>hay.includes(w));}
+const groupLabel=(k,v)=>k==='status'?(v==='stopped'?'TicketStopped':v):k==='assignee'?(v||'unassigned'):(v||'—');
function render(){
renderHead();
- const cols=ordered();
+ const cols=ordered(); const span=cols.length+1;
let rows=DATA.filter(match);
const sc=COLS.find(c=>c.k===sortKey);
rows.sort((a,b)=>{let x=sc.raw(a),y=sc.raw(b);if(typeof x==='number'&&typeof y==='number')return (x-y)*sortDir;return String(x).localeCompare(String(y))*sortDir;});
const tb=$('#tbody');tb.innerHTML='';
- for(const t of rows){
- const tr=document.createElement('tr');tr.className='row';
- tr.innerHTML=cols.map(c=>`<td>${c.cell(t)}</td>`).join('');
- tr.onclick=e=>{if(e.target.closest('a,.who,.proj'))return;const nx=tr.nextElementSibling;if(nx&&nx.classList.contains('det')){nx.remove();return;}
+ const rowTr=t=>{
+ const tr=document.createElement('tr');tr.className='row'+(SEL.has(t.id)?' sel':'')+(t.status==='stopped'?' stopped':'');tr.dataset.id=t.id;
+ tr.innerHTML=`<td class="cb"><input type="checkbox" class="rowcb" ${SEL.has(t.id)?'checked':''}></td>`+cols.map(c=>`<td>${c.cell(t)}</td>`).join('');
+ tr.querySelector('.rowcb').onclick=e=>{e.stopPropagation();if(e.target.checked)SEL.add(t.id);else SEL.delete(t.id);tr.classList.toggle('sel',e.target.checked);syncBar();};
+ tr.onclick=e=>{if(e.target.closest('a,.who,.proj,input'))return;const nx=tr.nextElementSibling;if(nx&&nx.classList.contains('det')){nx.remove();return;}
const d=document.createElement('tr');d.className='det';
const cShown=(t.comments||[]).filter(c=>DET_VIS[c.kind]!==0);
const aShown=DET_VIS.action!==0?(t.actions||[]).slice(-6):[];
const thread=cShown.map(c=>`<div class="c k-${c.kind}"><b>${esc(c.agent)}</b> <i>${esc(c.kind)}</i> ${esc(c.text)}<span class="cts">${dt(c.ts)}</span></div>`).join('')
+aShown.map(a=>`<div class="c k-action"><b>${esc(a.agent)}</b> <i>action</i> ${esc(a.text)}<span class="cts">${dt(a.ts)}</span></div>`).join('')
+(!cShown.length&&!aShown.length?'<div class="c none">no visible detail (toggle via ⤢ Detail)</div>':'');
- d.innerHTML=`<td colspan="${cols.length}"><div class="thread">${thread}</div></td>`;tr.after(d);};
+ d.innerHTML=`<td colspan="${span}"><div class="thread">${thread}</div></td>`;tr.after(d);};
tb.appendChild(tr);
+ };
+ if(GROUP==='none'){
+ for(const t of rows)rowTr(t);
+ }else{
+ const groups=new Map();
+ for(const t of rows){const gv=GROUP==='status'?t.status:GROUP==='project'?(t.project||''):(t.assignee||'');(groups.get(gv)||groups.set(gv,[]).get(gv)).push(t);}
+ // order groups: for status use canonical order, else alpha
+ const order=GROUP==='status'?['open','doing','blocked','done','stopped']:[...groups.keys()].sort((a,b)=>String(a).localeCompare(String(b)));
+ for(const gv of order){const list=groups.get(gv);if(!list)continue;
+ const gkey=GROUP+':'+gv; const collapsed=COLLAPSED.has(gkey);
+ const allSel=list.every(t=>SEL.has(t.id))&&list.length>0;
+ const gtr=document.createElement('tr');gtr.className='grp';
+ gtr.innerHTML=`<td colspan="${span}"><span class="gcx">${collapsed?'▸':'▾'}</span><input type="checkbox" ${allSel?'checked':''} title="select all in group"> ${esc(groupLabel(GROUP,gv))}<span class="gcount">${list.length}</span></td>`;
+ gtr.querySelector('td').onclick=e=>{if(e.target.tagName==='INPUT')return;if(collapsed)COLLAPSED.delete(gkey);else COLLAPSED.add(gkey);render();};
+ gtr.querySelector('input').onclick=e=>{e.stopPropagation();if(e.target.checked)for(const t of list)SEL.add(t.id);else for(const t of list)SEL.delete(t.id);render();syncBar();};
+ tb.appendChild(gtr);
+ if(!collapsed)for(const t of list)rowTr(t);
+ }
}
$('#count')&&($('#count').textContent=rows.length);
- renderPills();
+ const vis=DATA.filter(match); const sa=$('#selall'); if(sa)sa.checked=vis.length>0&&vis.every(t=>SEL.has(t.id));
+ renderPills(); syncBar();
}
+// ── selection action bar ──
+function syncBar(){const n=SEL.size;$('#seln').textContent=n;$('#actionbar').classList.toggle('show',n>0);}
+function selIds(){return [...SEL];}
+async function post(url,body){const r=await fetch(location.origin+url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});return r.json();}
+$('#clearBtn').onclick=()=>{SEL.clear();render();syncBar();};
+$('#runBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;if(!confirm(`Open ${ids.length} new iTerm2 window(s), each running Claude on a ticket?`))return;
+ $('#runBtn').disabled=true;try{const r=await post('/api/run',{ids});alert(`Launched ${((r.launched||[]).length)} session(s)`+((r.skipped&&r.skipped.length)?`\nSkipped: ${r.skipped.map(s=>s.id+'('+s.why+')').join(', ')}`:''));}catch(e){alert('run failed: '+e.message);}finally{$('#runBtn').disabled=false;SEL.clear();load();}};
+$('#stopBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;if(!confirm(`Stop ${ids.length} ticket(s) FOREVER (TicketStopped)? Reversible via Reopen.`))return;
+ try{await post('/api/stop',{ids});}catch(e){alert('stop failed: '+e.message);}SEL.clear();load();};
+$('#reopenBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;try{await post('/api/reopen',{ids});}catch(e){alert('reopen failed: '+e.message);}SEL.clear();load();};
+$('#dtdBtn').onclick=async()=>{const ids=selIds();$('#dtdBtn').disabled=true;
+ try{const r=await post('/api/dtd',ids.length?{ids}:{});if(r.already)dtdChip('analyzing…');else dtdChip('analyzing '+(ids.length?ids.length+' selected':'recent')+'…');pollDTD();}catch(e){alert('dtd failed: '+e.message);}finally{setTimeout(()=>{$('#dtdBtn').disabled=false;},1500);}};
+$('#yesBtn').onclick=()=>{if(!DTD.verdicts||!DTD.verdicts.tickets)return alert('Run "Analyze w/ DTD" first.');
+ let n=0;for(const t of DATA){const v=verdictOf(t);if(v&&v.verdict==='yes'){SEL.add(t.id);n++;}}render();syncBar();if(!n)alert('No YES verdicts yet.');};
+// ── DTD status feed ──
+function dtdChip(txt){$('#dtdchip').innerHTML='DTD: '+txt;}
+function renderDtdChip(){if(DTD.running){dtdChip('<b>analyzing…</b>');return;}
+ const v=DTD.verdicts;if(!v||!v.tickets){dtdChip('—');return;}
+ const arr=Object.values(v.tickets);const yes=arr.filter(x=>x.verdict==='yes').length;
+ dtdChip(`<b>${yes}</b>✓ / <b>${arr.length-yes}</b>✗ · ${v.ranAt?new Date(v.ranAt).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):''}`);}
+async function loadDTD(){try{const r=await fetch(location.origin+'/api/dtd').then(r=>r.json());DTD.running=!!r.running;DTD.verdicts=r.verdicts||DTD.verdicts;renderDtdChip();render();}catch(e){}}
+let dtdPoll=null;
+function pollDTD(){if(dtdPoll)clearInterval(dtdPoll);let n=0;dtdPoll=setInterval(async()=>{await loadDTD();n++;if(!DTD.running||n>80){clearInterval(dtdPoll);dtdPoll=null;}},5000);}
function renderPills(){
- const by={open:0,doing:0,blocked:0,done:0};for(const t of DATA)by[t.status]=(by[t.status]||0)+1;
+ const by={open:0,doing:0,blocked:0,done:0,stopped:0};for(const t of DATA)by[t.status]=(by[t.status]||0)+1;
const stale=DATA.filter(t=>t.status==='doing'&&agoH(lastTs(t))>24).length;
const x=k=>`<span class="px" title="hide this pill" onclick="event.stopPropagation();hidePill('${k}')">✕</span>`;
- const statPill=s=>`<span class="pill" onclick="setQ('${s}')"><span class="dot s-${s}" style="background:currentColor"></span>${s} <b>${by[s]}</b>${x(s)}</span>`;
+ const statPill=s=>`<span class="pill" onclick="setQ('${s}')"><span class="dot s-${s}" style="background:currentColor"></span>${s==='stopped'?'stopped':s} <b>${by[s]}</b>${x(s)}</span>`;
const html={
- open:statPill('open'), doing:statPill('doing'), blocked:statPill('blocked'), done:statPill('done'),
+ open:statPill('open'), doing:statPill('doing'), blocked:statPill('blocked'), done:statPill('done'), stopped:statPill('stopped'),
stale:`<span class="pill" title="doing >24h">⚠ stale <b>${stale}</b>${x('stale')}</span>`,
total:`<span class="pill">total <b>${DATA.length}</b>${x('total')}</span>`,
shown:`<span class="pill">shown <b id="count">${DATA.filter(match).length}</b>${x('shown')}</span>`,
@@ -182,13 +279,14 @@ function renderDetMenu(){
}
window.setQ=v=>{$('#q').value=v;q=v;render();};
$('#q').oninput=e=>{q=e.target.value;render();};
+$('#grp').onchange=e=>{GROUP=e.target.value;save('tkGroup',GROUP);render();};
$('#dens').oninput=e=>{DENS=+e.target.value;document.documentElement.style.setProperty('--rowpad',DENS+'px');save('tkDens',DENS);};
const closeMenus=except=>['#colmenu','#pillmenu','#detmenu'].forEach(id=>{if(id!==except)$(id).classList.remove('show');});
$('#colsBtn').onclick=()=>{closeMenus('#colmenu');$('#colmenu').classList.toggle('show');};
$('#pillsBtn').onclick=()=>{closeMenus('#pillmenu');$('#pillmenu').classList.toggle('show');};
$('#detBtn').onclick=()=>{closeMenus('#detmenu');$('#detmenu').classList.toggle('show');};
$('#dmBtn').onclick=()=>{DM_ON=DM_ON?0:1;save('tkDM',DM_ON);$('#dmBtn').classList.toggle('off',!DM_ON);dmPanel(LASTMSG);};
-$('#resetBtn').onclick=()=>{['tkCols','tkOrder','tkWid','tkDens','tkPills','tkDet','tkDM'].forEach(k=>localStorage.removeItem(k));location.reload();};
+$('#resetBtn').onclick=()=>{['tkCols','tkOrder','tkWid','tkDens','tkPills','tkDet','tkDM','tkGroup'].forEach(k=>localStorage.removeItem(k));location.reload();};
document.addEventListener('click',e=>{if(!e.target.closest('#colmenu,#colsBtn'))$('#colmenu').classList.remove('show');if(!e.target.closest('#pillmenu,#pillsBtn'))$('#pillmenu').classList.remove('show');if(!e.target.closest('#detmenu,#detBtn'))$('#detmenu').classList.remove('show');});
function dmPanel(mm){
if(!DM_ON){$('#dmwrap').innerHTML='';return;}
@@ -205,6 +303,8 @@ async function load(){
const base=location.origin; // never carries userinfo — fixes "URL includes credentials" fetch error when opened via admin:pass@host
const [t,m]=await Promise.all([fetch(base+'/api/tickets').then(r=>r.json()),fetch(base+'/api/messages').then(r=>r.json())]);
DATA=t; LASTMSG=m;
+ // drop any selected ids that no longer exist
+ const live=new Set(DATA.map(x=>x.id));for(const id of [...SEL])if(!live.has(id))SEL.delete(id);
// Deep-link support: a URL like /#q=TK-10454 (from the morning digest email)
// pre-filters the board to that ticket so you land right on it.
const hq=decodeURIComponent((location.hash.match(/[#&]q=([^&]+)/)||[])[1]||'');
@@ -213,5 +313,5 @@ async function load(){
}catch(e){$('#tbody').innerHTML=`<tr><td>load failed: ${esc(e.message)}</td></tr>`;}
}
window.addEventListener('hashchange',()=>{const hq=decodeURIComponent((location.hash.match(/[#&]q=([^&]+)/)||[])[1]||'');setQ(hq);});
-renderColMenu(); renderPillMenu(); renderDetMenu(); $('#dmBtn').classList.toggle('off',!DM_ON); load(); setInterval(load,30000);
+renderColMenu(); renderPillMenu(); renderDetMenu(); $('#dmBtn').classList.toggle('off',!DM_ON); load(); loadDTD(); setInterval(load,30000);
</script></body></html>
diff --git a/dtd-run.js b/dtd-run.js
new file mode 100755
index 00000000..9ae1ea38
--- /dev/null
+++ b/dtd-run.js
@@ -0,0 +1,161 @@
+#!/opt/homebrew/bin/node
+// dtd-run — ONE batched /dtd sweep that returns a committed RUN-NOW yes/no for
+// each recent (or explicitly-listed) ticket. Steve's rule (2026-08-13, TK-10527):
+// "analyze with dtd every ticket with clear yes or no to run now … consider all
+// recent tickets." Cost fork resolved to a SINGLE panel.sh call (not one-per-
+// ticket) — ~$0.05 total (Codex + Grok + Kimi metered; Qwen + Muse are $0 local).
+//
+// Flow: gather tickets -> frame ONE decision question that asks every panelist for
+// a strict JSON verdict array -> panel.sh fans to the 5 LLMs -> tally per-ticket
+// yes/no votes -> COMMIT a majority verdict + confidence -> write verdicts file.
+// The board reads the file and renders a Run-Now badge + "select all YES".
+//
+// Usage:
+// node dtd-run.js --recent [--days 14] # auto-pick open/doing/blocked, recent
+// node dtd-run.js TK-10500 TK-10501 … # explicit ids
+// node dtd-run.js --recent --annotate # also drop a note on each ticket
+//
+// Verdicts land in ~/.claude/tickets/dtd-verdicts.json; a .lock-style .running
+// marker lets the board show "analyzing…" without racing a second sweep.
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { execFileSync, spawnSync } = require('child_process');
+const { tickets, resolveId, append, withLock } = require('./lib.js');
+
+const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
+const OUT = path.join(DATA_DIR, 'dtd-verdicts.json');
+const RUNNING = path.join(DATA_DIR, 'dtd-verdicts.running');
+const PANEL = path.join(os.homedir(), '.claude', 'skills', 'dtd', 'scripts', 'panel.sh');
+
+const argv = process.argv.slice(2);
+const flag = f => { const i = argv.indexOf(f); if (i === -1) return false; argv.splice(i, 1); return true; };
+const opt = f => { const i = argv.indexOf(f); if (i === -1) return undefined; const v = argv[i + 1]; argv.splice(i, 2); return v; };
+const RECENT = flag('--recent');
+const ANNOTATE = flag('--annotate');
+const DAYS = parseInt(opt('--days') || '14', 10);
+const explicitRefs = argv.slice();
+
+// ── pick the ticket set ──────────────────────────────────────────────────────
+const all = tickets();
+const lastTs = t => {
+ let ms = +new Date(t.updated_at || t.created_at || 0);
+ for (const a of (t.actions || [])) ms = Math.max(ms, +new Date(a.ts));
+ for (const c of (t.comments || [])) ms = Math.max(ms, +new Date(c.ts));
+ return ms;
+};
+const lastText = t => {
+ const evs = [...(t.actions || []), ...(t.comments || [])].sort((a, b) => +new Date(b.ts) - +new Date(a.ts));
+ return evs[0] ? evs[0].text : '';
+};
+
+let chosen = [];
+if (explicitRefs.length) {
+ for (const ref of explicitRefs) { const id = resolveId(ref, all); if (id) chosen.push(all.get(id)); }
+} else {
+ // --recent (default): actionable (open/doing/blocked, NOT done/stopped), touched within DAYS
+ const cutoff = Date.now() - DAYS * 86400000;
+ chosen = [...all.values()].filter(t =>
+ ['open', 'doing', 'blocked'].includes(t.status) && lastTs(t) >= cutoff);
+}
+// never run DTD on a stopped ticket even if explicitly passed — stopped = forever
+chosen = chosen.filter(t => t && t.status !== 'stopped');
+
+if (!chosen.length) {
+ fs.writeFileSync(OUT, JSON.stringify({ ranAt: new Date().toISOString(), count: 0, tickets: {}, note: 'no eligible tickets' }, null, 2));
+ console.log('dtd-run: no eligible tickets'); process.exit(0);
+}
+// cap so one sweep can't build a giant prompt (and blow the panel timeout)
+const CAP = 40;
+if (chosen.length > CAP) { chosen.sort((a, b) => lastTs(b) - lastTs(a)); chosen = chosen.slice(0, CAP); }
+
+// ── frame the ONE batched decision question ──────────────────────────────────
+const digest = chosen.map(t => {
+ const idleH = ((Date.now() - lastTs(t)) / 3600000).toFixed(0);
+ return `- ${t.id} [${t.status}] {${t.project || 'no-project'}} owner=${t.assignee || 'unassigned'} idle=${idleH}h :: ${t.title} :: last="${(lastText(t) || '').slice(0, 160).replace(/"/g, "'")}"`;
+}).join('\n');
+
+const QUESTION = `You are the DTD decision panel. For EACH ticket below, decide RUN NOW = "yes" or "no".
+
+RUN NOW = yes -> this ticket is actionable RIGHT NOW: clearly scoped, unblocked, worth spawning a dedicated Claude session to drive to done.
+RUN NOW = no -> not now: it is blocked/waiting on a human gate or another ticket, too vague to act on, already effectively finished, a duplicate, or stale/abandoned and should be reconsidered rather than run.
+
+Be decisive — every ticket gets a clear yes or no, no "maybe".
+
+Return ONLY a JSON array, nothing else, one object per ticket, exactly this shape:
+[{"id":"TK-#####","run_now":"yes","reason":"<=12 words"}, ...]
+
+TICKETS:
+${digest}`;
+
+// ── run the panel ────────────────────────────────────────────────────────────
+fs.writeFileSync(RUNNING, JSON.stringify({ startedAt: new Date().toISOString(), count: chosen.length }));
+console.log(`dtd-run: fanning ${chosen.length} tickets to the panel…`);
+let dir = null;
+try {
+ const out = execFileSync('bash', [PANEL, QUESTION], { encoding: 'utf8', timeout: 360000, maxBuffer: 32 * 1024 * 1024 });
+ const m = out.match(/^DIR=(.+)$/m);
+ dir = m ? m[1].trim() : null;
+} catch (e) {
+ try { fs.unlinkSync(RUNNING); } catch {}
+ fs.writeFileSync(OUT, JSON.stringify({ ranAt: new Date().toISOString(), error: 'panel failed: ' + e.message, count: 0, tickets: {} }, null, 2));
+ console.error('dtd-run: panel.sh failed:', e.message); process.exit(1);
+}
+
+// ── parse each panelist's JSON verdict array (tolerant) ──────────────────────
+const numOf = id => parseInt(String(id).replace(/^TK-/i, ''), 10); // stops at slug dash
+function parseVotes(text) {
+ const votes = new Map(); // num -> "yes"|"no"
+ if (!text) return votes;
+ const a = text.indexOf('['), b = text.lastIndexOf(']');
+ if (a === -1 || b === -1 || b < a) return votes;
+ let arr;
+ try { arr = JSON.parse(text.slice(a, b + 1)); } catch { return votes; }
+ if (!Array.isArray(arr)) return votes;
+ for (const o of arr) {
+ if (!o || o.id == null || o.run_now == null) continue;
+ const n = numOf(o.id); if (!Number.isFinite(n)) continue;
+ const v = String(o.run_now).trim().toLowerCase();
+ votes.set(n, v.startsWith('y') ? 'yes' : 'no');
+ }
+ return votes;
+}
+
+const panelists = ['codex', 'qwen', 'grok', 'kimi', 'muse'];
+const perPanel = {};
+if (dir) for (const p of panelists) {
+ try { perPanel[p] = parseVotes(fs.readFileSync(path.join(dir, `${p}.txt`), 'utf8')); }
+ catch { perPanel[p] = new Map(); }
+}
+
+// ── tally + COMMIT a verdict per ticket ──────────────────────────────────────
+const result = { ranAt: new Date().toISOString(), dir, count: chosen.length, tickets: {} };
+for (const t of chosen) {
+ const n = numOf(t.id);
+ let yes = 0, no = 0; const voters = [];
+ for (const p of panelists) {
+ const v = perPanel[p] && perPanel[p].get(n);
+ if (v === 'yes') { yes++; voters.push(`${p}:yes`); }
+ else if (v === 'no') { no++; voters.push(`${p}:no`); }
+ }
+ const total = yes + no;
+ // COMMIT: majority; tie or zero votes -> conservative "no" (never auto-run on a tie)
+ const verdict = total === 0 ? 'unknown' : (yes > no ? 'yes' : 'no');
+ const confidence = total === 0 ? 0 : +(Math.max(yes, no) / total).toFixed(2);
+ result.tickets[t.id] = { verdict, yes, no, total, confidence, voters, title: t.title, status: t.status };
+}
+
+fs.writeFileSync(OUT, JSON.stringify(result, null, 2));
+try { fs.unlinkSync(RUNNING); } catch {}
+
+// optional: drop a note on each ticket so the verdict shows in its thread
+if (ANNOTATE) {
+ for (const t of chosen) {
+ const r = result.tickets[t.id];
+ withLock(() => append({ ts: new Date().toISOString(), type: 'comment', id: t.id, agent: 'dtd', kind: 'note',
+ text: `DTD run-now verdict: ${r.verdict.toUpperCase()} (${r.yes}✓/${r.no}✗, conf ${r.confidence}) [${r.voters.join(' ')}]` }));
+ }
+}
+
+const yesN = Object.values(result.tickets).filter(r => r.verdict === 'yes').length;
+console.log(`dtd-run: committed ${chosen.length} verdicts — ${yesN} YES / ${chosen.length - yesN} not-now. -> ${OUT}`);
diff --git a/lib.js b/lib.js
index c0622c71..a59d6090 100644
--- a/lib.js
+++ b/lib.js
@@ -33,7 +33,11 @@ function readEvents() {
function append(ev) { ensure(); fs.appendFileSync(EVENTS, JSON.stringify(ev) + '\n'); return ev; }
-const STATUSES = ['open', 'doing', 'blocked', 'done'];
+// 'stopped' (shown as "TicketStopped") = stopped forever: excluded from Run-Now
+// and DTD consideration, kept on the board in its own group. Reversible — it's
+// just an appended status event, so `tk status <id> open` un-stops it. Added
+// last so legacy 4-status consumers that index by position are unaffected.
+const STATUSES = ['open', 'doing', 'blocked', 'done', 'stopped'];
// Fold the event log into current ticket state.
function tickets() {
diff --git a/morning-digest.js b/morning-digest.js
index 40b4d554..2cf08c02 100755
--- a/morning-digest.js
+++ b/morning-digest.js
@@ -26,7 +26,7 @@ overnightStart.setHours(FROM_HOUR, 0, 0, 0);
if (overnightStart > now) overnightStart.setDate(overnightStart.getDate() - 1);
const day24Start = new Date(now.getTime() - 24 * 3600 * 1000);
-const STATUSES = ['open', 'doing', 'blocked', 'done'];
+const STATUSES = ['open', 'doing', 'blocked', 'done', 'stopped']; // 'stopped' = TicketStopped (kept in sync with lib.js)
const events = fs.readFileSync(EVENTS, 'utf8').split('\n').filter(Boolean)
.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
@@ -54,7 +54,7 @@ const overnight = closedIn(overnightStart);
const last24 = closedIn(day24Start);
const openRank = { doing: 0, blocked: 1, open: 2 };
-const openTix = all.filter(t => t.status !== 'done')
+const openTix = all.filter(t => t.status !== 'done' && t.status !== 'stopped') // TicketStopped is closed-forever, not "still open"
.sort((a, b) => (openRank[a.status] - openRank[b.status]) || (a.updated_at < b.updated_at ? 1 : -1));
// ── rendering helpers ──
diff --git a/run-ticket.sh b/run-ticket.sh
new file mode 100755
index 00000000..c40bcc26
--- /dev/null
+++ b/run-ticket.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# run-ticket.sh — open ONE new iTerm2 window with `claude` pre-loaded to drive a
+# ticket to done (the "Run Now" action from the board; mirrors ~/.zsh/task.sh but
+# hardened: it takes only a VALIDATED ticket id + an optional cwd, so no agent-
+# authored ticket title ever reaches a shell). Steve, TK-10527 (2026-08-13):
+# "run ticket like the approvals opens a new iterm2 terminal with claude loaded first."
+#
+# Usage: run-ticket.sh <TK-id> [start-dir]
+set -euo pipefail
+
+ID="${1:?usage: run-ticket.sh <TK-id> [start-dir]}"
+# Hard gate: id must be TK-<digits> optionally + a lowercase slug. Reject anything else.
+if [[ ! "$ID" =~ ^TK-[0-9]+(-[a-z0-9-]+)?$ ]]; then
+ echo "run-ticket: refusing unsafe id '$ID'" >&2; exit 2
+fi
+IDNUM="${ID#TK-}"; IDNUM="${IDNUM%%-*}"
+
+CWD="${2:-$HOME}"
+# only allow an existing dir under $HOME; else fall back home (never an arbitrary path)
+case "$CWD" in
+ "$HOME"|"$HOME"/*) [[ -d "$CWD" ]] || CWD="$HOME" ;;
+ *) CWD="$HOME" ;;
+esac
+
+# The prompt references ONLY the safe id — the session pulls full context itself
+# via `tk show`. Encodes Steve's standing operating loop (tickets-first, copious
+# notes, gated actions draft to pending-approval).
+read -r -d '' PROMPT <<PROMPT_EOF || true
+export TK_AGENT=claude-run-${IDNUM}. You are driving ticket ${ID} to completion. First run: tk inbox (act on any DMs), then tk show ${ID} for full context. You now OWN this ticket: tk take ${ID}. Work it to done per CLAUDE.md's operating loop — tk log EVERY action, tk comment reasoning, dm peers with tk dm if you need help. Any customer-facing / destructive / spend / DNS / publish / send-to-list / remote-push / canonical dw_unified or Shopify write is GATED: draft it to ~/.claude/yolo-queue/pending-approval/ and STOP — never auto-fire. When the work is genuinely complete AND verified: tk done ${ID}.
+PROMPT_EOF
+
+# Build the inner shell payload, then escape for an AppleScript double-quoted literal
+# (same technique as task.sh): backslash first, then double-quote.
+payload="cd $(printf %q "$CWD") && claude $(printf %q "$PROMPT")"
+osa_title="Run ${ID}"
+esc() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; }
+osa_payload="$(esc "$payload")"
+osa_title="$(esc "$osa_title")"
+
+/usr/bin/osascript <<OSA
+tell application "iTerm"
+ activate
+ set newPane to current session of (create window with default profile)
+ tell newPane
+ set name to "$osa_title"
+ write text "$osa_payload"
+ end tell
+end tell
+OSA
+echo "run-ticket: launched iTerm2 window for $ID (cwd $CWD)"
diff --git a/server.js b/server.js
index 1f458bb5..e9e3d1f1 100644
--- a/server.js
+++ b/server.js
@@ -2,8 +2,19 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
-const { exec } = require('child_process');
-const { tickets, STATUSES, messages } = require('./lib.js');
+const os = require('os');
+const { exec, execFile, spawn } = require('child_process');
+const { tickets, STATUSES, messages, resolveId, append, withLock } = require('./lib.js');
+
+// ── ticket-run + DTD wiring (TK-10527) ──
+const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
+const VERDICTS = path.join(DATA_DIR, 'dtd-verdicts.json'); // last batched dtd run-now verdicts
+const DTD_RUNNING = path.join(DATA_DIR, 'dtd-verdicts.running'); // present while a sweep is in flight
+const RUN_SH = path.join(__dirname, 'run-ticket.sh'); // opens an iTerm2 Claude session
+const DTD_RUN = path.join(__dirname, 'dtd-run.js'); // batched panel.sh sweep
+const IDRE = /^TK-[0-9]+(-[a-z0-9-]+)?$/; // hard id gate before any shell use
+const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+function readJson(req, cb) { let b = ''; req.on('data', d => { b += d; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { cb(JSON.parse(b || '{}')); } catch { cb(null); } }); }
// ── live "running" signal: online pm2 processes + live claude CLI sessions ──
// Cached 5s so N polling browsers don't each spawn a `pm2 jlist` on a busy box.
@@ -37,10 +48,11 @@ const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toSt
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);
+ const cols = { open: [], doing: [], blocked: [], done: [], stopped: [] };
+ for (const t of tickets().values()) (cols[t.status] || (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);
+ cols.stopped = cols.stopped.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>
@@ -67,7 +79,7 @@ function page() {
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}
+ .board{display:grid;grid-template-columns:repeat(5,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}
@@ -81,6 +93,7 @@ function page() {
.c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}
.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}
+ .col-stopped .card{opacity:.4;border-left:3px solid #6b7280}.col-stopped h2{color:#9aa2b1}
.dms{margin:0 14px 6px;background:#141821;border:1px solid #262a33;border-radius:8px}
.dms>summary{cursor:pointer;padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#c9a4f2}
.dms>summary small{text-transform:none;letter-spacing:0;color:#8a93a5;margin-left:8px;font-size:11px}
@@ -101,6 +114,79 @@ ${dmPanel}
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'); }
+
+ // ── writes / actions (all auth-gated; ids resolved server-side from the real store) ──
+ // Resolve a list of client refs to canonical, shell-safe ticket ids. A RAW ref
+ // must itself look like a clean ticket ref FIRST — otherwise "TK-1; rm -rf /"
+ // would numeric-resolve to the real TK-1 (resolveId stops parseInt at the first
+ // non-digit). Pre-gating the raw ref blocks that whole class before resolveId.
+ const REFRE = /^(TK-?)?\d+(-[a-z0-9-]+)?$/i;
+ const resolveList = (refs, map) => { const out = []; if (!Array.isArray(refs)) return out;
+ 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)]; };
+
+ // Run Now — open one iTerm2 Claude session per selected ticket (staggered so iTerm doesn't drop windows).
+ if (req.method === 'POST' && req.url === '/api/run') {
+ return readJson(req, body => {
+ if (!body) return json(res, 400, { error: 'bad json' });
+ const map = tickets(); const ids = resolveList(body.ids, map);
+ const launched = [], skipped = [];
+ ids.forEach((id, i) => {
+ const t = map.get(id);
+ if (t && t.status === 'stopped') { skipped.push({ id, why: 'stopped' }); return; }
+ let cwd = os.homedir();
+ const proj = t && t.project;
+ if (proj && /^[a-z0-9._-]+$/i.test(proj)) { const p = path.join(os.homedir(), 'Projects', proj); if (fs.existsSync(p)) cwd = p; }
+ setTimeout(() => execFile('bash', [RUN_SH, id, cwd], { timeout: 25000 }, () => {}), i * 1300); // stagger ~1.3s
+ withLock(() => append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: '▶ RUN NOW — launched iTerm2 Claude session from the board' }));
+ launched.push(id);
+ });
+ json(res, 200, { launched, skipped });
+ });
+ }
+ // Stop Forever — mark selected tickets TicketStopped (status 'stopped'); reversible via /api/reopen.
+ if (req.method === 'POST' && req.url === '/api/stop') {
+ return readJson(req, body => {
+ if (!body) return json(res, 400, { error: 'bad json' });
+ const map = tickets(); const ids = resolveList(body.ids, map);
+ withLock(() => { for (const id of ids) {
+ append({ ts: new Date().toISOString(), type: 'status', id, status: 'stopped', agent: 'board' });
+ append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: '⛔ TicketStopped — stopped forever from the board' });
+ } });
+ json(res, 200, { stopped: ids });
+ });
+ }
+ // Reopen — un-stop (or un-close) selected tickets back to 'open'.
+ if (req.method === 'POST' && req.url === '/api/reopen') {
+ return readJson(req, body => {
+ if (!body) return json(res, 400, { error: 'bad json' });
+ const map = tickets(); const ids = resolveList(body.ids, map);
+ withLock(() => { for (const id of ids) {
+ append({ ts: new Date().toISOString(), type: 'status', id, status: 'open', agent: 'board' });
+ append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board', text: '↩ reopened from the board' });
+ } });
+ json(res, 200, { reopened: ids });
+ });
+ }
+ // DTD — trigger a batched run-now sweep (POST) / read the latest verdicts + running state (GET).
+ if (req.method === 'POST' && req.url === '/api/dtd') {
+ return readJson(req, body => {
+ if (fs.existsSync(DTD_RUNNING)) return json(res, 200, { started: false, already: true });
+ const args = [DTD_RUN];
+ const explicit = body && Array.isArray(body.ids) && body.ids.length ? resolveList(body.ids, tickets()) : [];
+ if (explicit.length) args.push(...explicit); else args.push('--recent');
+ try { const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', cwd: __dirname }); child.unref(); }
+ catch (e) { return json(res, 500, { started: false, error: e.message }); }
+ json(res, 200, { started: true, ids: explicit.length ? explicit : 'recent' });
+ });
+ }
+ if (req.method === 'GET' && req.url === '/api/dtd') {
+ let verdicts = null, running = false, runningInfo = null;
+ try { verdicts = JSON.parse(fs.readFileSync(VERDICTS, 'utf8')); } catch {}
+ try { runningInfo = JSON.parse(fs.readFileSync(DTD_RUNNING, 'utf8')); running = true; } catch {}
+ return json(res, 200, { running, runningInfo, verdicts });
+ }
+
if (req.url === '/office' || req.url === '/office.html') {
return fs.readFile(OFFICE_HTML, (e, buf) => {
if (e) { res.writeHead(500); return res.end('office view missing'); }
← dfef161b snapshot before ticket run/group/dtd/stop feature
·
back to Ticket System
·
fix(board): DTD chip stuck on 'analyzing…' — start poll on l 10915c3e →