[object Object]

← back to Ticket Triage Page

auto-save: 2026-07-30T20:51:09 (2 files) — server.js tickets.js

2dc79d9a9b70981fcdfb07eb6f68fdd2ab38bbff · 2026-07-30 20:51:16 -0700 · Steve Abrams

Files touched

Diff

commit 2dc79d9a9b70981fcdfb07eb6f68fdd2ab38bbff
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 20:51:16 2026 -0700

    auto-save: 2026-07-30T20:51:09 (2 files) — server.js tickets.js
---
 server.js  | 204 ++++++++++++++++++++++++++++++++++++++-----------------------
 tickets.js |  88 ++++++++++++++++----------
 2 files changed, 184 insertions(+), 108 deletions(-)

diff --git a/server.js b/server.js
index de2c3a4..930c57a 100644
--- a/server.js
+++ b/server.js
@@ -1,137 +1,189 @@
 #!/usr/bin/env node
-// Ticket Triage page — select 1..all of the 10 Steve-gated blocked tickets and record a decision.
-// Zero deps (built-in http). Basic-auth admin/DW2024!. Open /healthz. Free port 9759.
-// HARD RAIL: this page NEVER executes a gated action (no deploy/DNS/paste/prod-write).
-// It only RECORDS Steve's decision as a `tk comment` on each ticket — the actual gated
-// command stays a manual paste per each ticket's runbook memo.
+// Ticket Triage page — select + APPROVE + FIRE the 10 Steve-gated blocked tickets.
+// Zero deps (built-in http). Basic-auth admin/DW2024!. Open /healthz. Port 9759, bound 127.0.0.1 ONLY.
+// Buttons execute the runbook command verbatim. danger:'live' requires typing RUN to confirm.
+// Every fire is logged to the ticket (tk comment) with the command + exit code. Steve is the
+// human gate: nothing fires without his click (+ typed RUN on live ones). TK-13 is hands-off.
 const http = require('http');
 const fs = require('fs');
-const { execFileSync } = require('child_process');
+const { execFileSync, spawn } = require('child_process');
 const path = require('path');
 const os = require('os');
 
 const PORT = process.env.PORT || 9759;
+const HOST = '127.0.0.1'; // localhost-only — these buttons can hit prod; never bind 0.0.0.0
 const USER = process.env.BASIC_AUTH_USER || 'admin';
 const PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
 const TK = path.join(os.homedir(), 'Projects/ticket-system/tk');
 const TICKETS = require('./tickets');
-// Whitelist memo roots so /api/memo can't be walked outside the queue/tools scope.
 const MEMO_ROOTS = [ path.join(os.homedir(), '.claude/yolo-queue'), path.join(os.homedir(), 'Projects/tools-dw-hub') ];
+const byRef = r => TICKETS.find(t => t.ref === r);
 
 function tk(args) { try { return execFileSync(TK, args, { encoding: 'utf8', env: { ...process.env, TK_AGENT: 'steve' } }); } catch (e) { return (e.stdout || '') + (e.stderr || ''); } }
-function liveBlocked() { // set of ticket refs still in blocked, so the page shows honest current state
-  const out = tk(['list', '--status', 'blocked', '--all']);
-  const s = new Set();
-  out.split('\n').forEach(l => { const m = l.match(/^(TK-[0-9]+)/); if (m) s.add(m[1]); });
-  return s;
-}
-function esc(x) { return String(x).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
+function liveBlocked() { const s = new Set(); tk(['list', '--status', 'blocked', '--all']).split('\n').forEach(l => { const m = l.match(/^(TK-[0-9]+)/); if (m) s.add(m[1]); }); return s; }
+function esc(x) { return String(x == null ? '' : x).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
+function last4(v) { v = String(v || ''); return v.length <= 4 ? '****' : '…' + v.slice(-4); }
+
+const BUCKET = { approval: ['#16a34a', '1-word approval'], deploy: ['#2563eb', 'deploy'], paste: ['#7c3aed', 'command'], task: ['#ea580c', 'hands-on task'], auto: ['#64748b', 'no action'] };
 
-const BUCKET = { approval: ['#16a34a', '1-word approval'], deploy: ['#2563eb', 'deploy — paste'], paste: ['#7c3aed', 'paste a command'], task: ['#ea580c', 'hands-on task'], auto: ['#64748b', 'no action needed'] };
+// Run a shell command, capture combined output, cap runtime. Callback(code, output).
+function runCmd(cmd, cb) {
+  const child = spawn('bash', ['-lc', cmd], { env: process.env });
+  let out = '', done = false;
+  const finish = (code) => { if (done) return; done = true; cb(code, out.slice(0, 60000)); };
+  const to = setTimeout(() => { out += '\n[triage] TIMEOUT after 300s — killed.\n'; child.kill('SIGKILL'); finish(124); }, 300000);
+  child.stdout.on('data', d => out += d);
+  child.stderr.on('data', d => out += d);
+  child.on('close', code => { clearTimeout(to); finish(code); });
+  child.on('error', e => { clearTimeout(to); out += '\n[triage] spawn error: ' + e.message + '\n'; finish(1); });
+}
 
 function page() {
   const blocked = liveBlocked();
-  const cards = TICKETS.map((t, i) => {
+  const cards = TICKETS.map(t => {
     const [color, blabel] = BUCKET[t.bucket];
     const still = blocked.has(t.ref.match(/TK-[0-9]+/)[0]);
-    const cleared = still ? '' : ' cleared';
-    return `<label class="card${cleared}" data-bucket="${t.bucket}">
-      <input type="checkbox" class="pick" value="${esc(t.ref)}" ${still ? '' : 'disabled'}>
-      <div class="body">
-        <div class="top"><span class="ref">${esc(t.ref)}</span>
-          <span class="badge" style="background:${color}">${blabel}</span>
-          <span class="rec">recommend: <b>${esc(t.recommend)}</b></span>
-          ${still ? '' : '<span class="done-pill">✓ no longer blocked</span>'}</div>
-        <div class="title">${esc(t.title)}</div>
-        <div class="action">${esc(t.action)}</div>
-        <div class="foot">${t.memo ? `<button type="button" class="memo" data-file="${esc(t.memo)}">📄 open runbook</button>` : '<span class="nomemo">no memo — see ticket</span>'}</div>
-      </div></label>`;
+    const e = t.exec, live = e.danger === 'live';
+    let btn;
+    if (e.mode === 'handsoff') btn = `<span class="handsoff">⬜ hands-off</span>`;
+    else if (e.mode === 'input') btn = `<button type="button" class="act input" data-ref="${esc(t.ref)}">${esc(e.btn)}</button>`;
+    else btn = `<button type="button" class="act ${live ? 'live' : 'safe'}" data-ref="${esc(t.ref)}">${live ? '🔴 ' : ''}${esc(e.btn)}</button>`;
+    return `<div class="card${still ? '' : ' cleared'}" data-bucket="${t.bucket}" id="card-${esc(t.ref)}">
+      <div class="top"><span class="ref">${esc(t.ref)}</span>
+        <span class="badge" style="background:${color}">${blabel}</span>
+        <span class="rec">rec: <b>${esc(t.recommend)}</b></span>
+        ${still ? '' : '<span class="done-pill">✓ cleared</span>'}</div>
+      <div class="title">${esc(t.title)}</div>
+      <div class="action">${esc(t.action)}</div>
+      <div class="foot">${btn}
+        ${t.memo ? `<button type="button" class="memo" data-file="${esc(t.memo)}">📄 runbook</button>` : ''}</div>
+      <pre class="output" id="out-${esc(t.ref)}" hidden></pre>
+    </div>`;
   }).join('\n');
 
   return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
-<title>Blocked-Ticket Triage · select 1–all</title><style>
+<title>Blocked-Ticket Triage · approve &amp; fire</title><style>
 :root{--bg:#0f172a;--card:#1e293b;--ink:#e2e8f0;--mut:#94a3b8;--line:#334155}
 *{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,system-ui,sans-serif;background:var(--bg);color:var(--ink)}
 header{position:sticky;top:0;background:#0b1220;border-bottom:1px solid var(--line);padding:14px 20px;z-index:5}
 h1{margin:0;font-size:18px}.sub{color:var(--mut);font-size:13px;margin-top:2px}
 .bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px}
-button,select{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 12px;cursor:pointer}
-button.primary{background:#2563eb;border-color:#2563eb;font-weight:600}button.primary:disabled{opacity:.4;cursor:not-allowed}
-.count{color:var(--mut);font-size:13px}
+button,select,input{font:inherit;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:8px 12px}
+button{cursor:pointer}.act{font-weight:600}.act.safe{background:#16a34a;border-color:#16a34a}
+.act.live{background:#dc2626;border-color:#dc2626}.act.input{background:#7c3aed;border-color:#7c3aed}
+.act:disabled{opacity:.5;cursor:progress}
 main{padding:18px 20px;max-width:900px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
-.card{display:flex;gap:14px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px;cursor:pointer;transition:.12s}
-.card:hover{border-color:#475569}.card:has(.pick:checked){border-color:#2563eb;box-shadow:0 0 0 1px #2563eb inset}
-.card.cleared{opacity:.5}.pick{margin-top:3px;width:18px;height:18px;accent-color:#2563eb}
+.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
+.card.cleared{opacity:.5}
 .top{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
 .ref{font-weight:700;font-family:ui-monospace,monospace}.badge{color:#fff;font-size:11px;padding:2px 8px;border-radius:20px}
 .rec{color:var(--mut);font-size:12px}.rec b{color:var(--ink)}.done-pill{color:#22c55e;font-size:12px}
 .title{font-weight:600;margin:2px 0}.action{color:var(--mut);font-size:13.5px}
-.foot{margin-top:8px}.memo{padding:5px 10px;font-size:12.5px}.nomemo{color:var(--mut);font-size:12px}
-#modal{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;padding:20px;z-index:10}
-#modal .box{background:var(--card);border:1px solid var(--line);border-radius:12px;max-width:820px;width:100%;max-height:85vh;display:flex;flex-direction:column}
+.foot{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap}.memo{padding:6px 10px;font-size:12.5px}
+.handsoff{color:var(--mut);font-size:13px;padding:6px 0}
+.output{margin:10px 0 0;padding:12px;background:#0b1220;border:1px solid var(--line);border-radius:8px;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap;max-height:340px;overflow:auto}
+.output.ok{border-color:#16a34a}.output.err{border-color:#dc2626}
+#modal{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center;padding:20px;z-index:10}
+#modal .box{background:var(--card);border:1px solid var(--line);border-radius:12px;max-width:760px;width:100%;max-height:88vh;display:flex;flex-direction:column}
 #modal .h{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}
-#modal pre{margin:0;padding:16px;overflow:auto;white-space:pre-wrap;font:12.5px/1.5 ui-monospace,monospace}
-.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:10px 18px;border-radius:8px;display:none}
+#modal .c{padding:16px;overflow:auto}
+#modal pre{margin:0 0 12px;padding:12px;background:#0b1220;border-radius:8px;white-space:pre-wrap;font:12.5px/1.5 ui-monospace,monospace}
+.warn{color:#fca5a5;font-size:13px;margin:8px 0}.fld{display:block;margin:8px 0}.fld label{display:block;font-size:12px;color:var(--mut);margin-bottom:3px}.fld input{width:100%}
+.confirm-row{display:flex;gap:8px;align-items:center;margin-top:10px}
 </style></head><body>
-<header><h1>Blocked-Ticket Triage</h1><div class="sub">The 10 Steve-gated tickets · select 1 to all, then record a decision. Recording writes a <code>tk comment</code> — it does <b>not</b> fire any gated command (deploy/DNS/paste stay your manual step).</div>
-<div class="bar">
-  <button type="button" id="all">Select all</button>
-  <button type="button" id="none">Clear</button>
-  <select id="filter"><option value="">All buckets</option><option value="approval">1-word approvals</option><option value="deploy">deploys</option><option value="paste">paste-a-command</option><option value="task">hands-on tasks</option><option value="auto">no action</option></select>
-  <span style="flex:1"></span>
-  <select id="decision"><option value="APPROVE">APPROVE</option><option value="REVISE">REVISE</option><option value="BLOCK">BLOCK / defer</option></select>
-  <button type="button" class="primary" id="record" disabled>Record decision (<span id="n">0</span>)</button>
-</div></header>
+<header><h1>Blocked-Ticket Triage — approve &amp; fire</h1>
+<div class="sub">Each button executes its ticket's runbook command. 🔴 live actions need you to type <b>RUN</b>. Every fire is logged to the ticket. You are the gate — nothing runs without your click.</div>
+<div class="bar"><select id="filter"><option value="">All buckets</option><option value="approval">approvals</option><option value="deploy">deploys</option><option value="paste">commands</option><option value="task">tasks</option><option value="auto">no action</option></select></div></header>
 <main>${cards}</main>
-<div id="modal"><div class="box"><div class="h"><b id="mtitle">runbook</b><button type="button" id="mclose">close</button></div><pre id="mbody">loading…</pre></div></div>
-<div class="toast" id="toast"></div>
+<div id="modal"><div class="box"><div class="h"><b id="mtitle"></b><button type="button" id="mclose">close</button></div><div class="c" id="mbody"></div></div></div>
 <script>
 const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)];
-function picks(){return $$('.pick:checked')}
-function sync(){const n=picks().length;$('#n').textContent=n;$('#record').disabled=n===0}
-$$('.pick').forEach(c=>c.addEventListener('change',sync));
-$('#all').onclick=()=>{$$('.pick:not(:disabled)').forEach(c=>{const card=c.closest('.card');if(card.style.display!=='none')c.checked=true});sync()};
-$('#none').onclick=()=>{$$('.pick').forEach(c=>c.checked=false);sync()};
+const T=${JSON.stringify(TICKETS.map(t => ({ ref: t.ref, exec: t.exec })))};
 $('#filter').onchange=e=>{const v=e.target.value;$$('.card').forEach(c=>c.style.display=(!v||c.dataset.bucket===v)?'':'none')};
-$$('.memo').forEach(b=>b.onclick=async e=>{e.preventDefault();e.stopPropagation();$('#mtitle').textContent=b.dataset.file.split('/').pop();$('#mbody').textContent='loading…';$('#modal').style.display='flex';const r=await fetch('/api/memo?file='+encodeURIComponent(b.dataset.file));$('#mbody').textContent=await r.text()});
+$$('.memo').forEach(b=>b.onclick=async()=>{openModal(b.dataset.file.split('/').pop(),'<pre>loading…</pre>');const r=await fetch('/api/memo?file='+encodeURIComponent(b.dataset.file));$('#mbody').innerHTML='<pre></pre>';$('#mbody pre').textContent=await r.text()});
+function openModal(title,html){$('#mtitle').textContent=title;$('#mbody').innerHTML=html;$('#modal').style.display='flex'}
 $('#mclose').onclick=()=>$('#modal').style.display='none';$('#modal').onclick=e=>{if(e.target.id==='modal')$('#modal').style.display='none'};
-$('#record').onclick=async()=>{const ids=picks().map(c=>c.value),decision=$('#decision').value;if(!ids.length)return;$('#record').disabled=true;const r=await fetch('/api/decision',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,decision})});const j=await r.json();const t=$('#toast');t.textContent=j.ok?('Recorded '+decision+' on '+j.done+' ticket(s)'):(j.error||'failed');t.style.background=j.ok?'#16a34a':'#dc2626';t.style.display='block';setTimeout(()=>t.style.display='none',3500);sync()};
-sync();
+
+$$('.act').forEach(b=>b.onclick=()=>{const ref=b.dataset.ref;const t=T.find(x=>x.ref===ref);const e=t.exec;
+  const live=e.danger==='live';
+  let body='<div>'+esc(e.detail||'')+'</div>';
+  if(e.mode==='fire'){body+='<div style="margin-top:10px;color:#94a3b8;font-size:12px">Command that will run:</div><pre>'+esc(e.cmd)+'</pre>';}
+  if(e.mode==='input'){body+=e.fields.map(f=>'<div class="fld"><label>'+esc(f.label)+'</label><input data-f="'+f.name+'" type="'+(f.type||'text')+'" autocomplete="off"></div>').join('');}
+  if(live){body+='<div class="warn">⚠ LIVE action. Type <b>RUN</b> to confirm.</div>';}
+  body+='<div class="confirm-row">'+(live?'<input id="cw" placeholder="type RUN" style="width:130px" autocomplete="off">':'')+'<button id="go" class="act '+(live?'live':(e.mode==='input'?'input':'safe'))+'">'+(live?'🔴 ':'')+'Confirm &amp; fire</button></div>';
+  openModal(ref+' · '+esc(e.btn),body);
+  $('#go').onclick=async()=>{
+    const payload={ref};
+    if(live){const cw=$('#cw').value.trim();if(cw!=='RUN'){$('#cw').style.borderColor='#dc2626';return;}payload.confirm='RUN';}
+    if(e.mode==='input'){payload.inputs={};$$('#mbody input[data-f]').forEach(i=>payload.inputs[i.dataset.f]=i.value);}
+    $('#go').disabled=true;$('#go').textContent='running…';
+    const r=await fetch('/api/exec',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});
+    const j=await r.json();$('#modal').style.display='none';
+    const out=$('#out-'+ref);out.hidden=false;out.className='output '+(j.ok?'ok':'err');
+    out.textContent=(j.ok?'✓ ':'✗ ')+(j.msg||'')+(j.output?('\\n\\n'+j.output):'');
+    out.scrollIntoView({behavior:'smooth',block:'center'});
+    b.textContent=j.ok?'✓ fired':'✗ failed — retry';b.disabled=false;
+  };
+});
+function esc(x){return String(x==null?'':x).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
 </script></body></html>`;
 }
 
 const server = http.createServer((req, res) => {
   const url = new URL(req.url, 'http://x');
-  // Open health check BEFORE auth so the fleet keepalive reads 200, not the healthy 401.
   if (url.pathname === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain' }); return res.end('ok'); }
-  // Basic auth on everything else.
   const hdr = req.headers.authorization || '';
   const [u, p] = Buffer.from(hdr.split(' ')[1] || '', 'base64').toString().split(':');
   if (u !== USER || p !== PASS) { res.writeHead(401, { 'www-authenticate': 'Basic realm="triage"' }); return res.end('auth required'); }
 
-  if (url.pathname === '/' ) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page()); }
+  if (url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page()); }
 
   if (url.pathname === '/api/memo') {
-    const file = url.searchParams.get('file') || '';
-    const real = path.resolve(file);
+    const real = path.resolve(url.searchParams.get('file') || '');
     if (!MEMO_ROOTS.some(r => real.startsWith(r)) || !TICKETS.some(t => t.memo && path.resolve(t.memo) === real)) { res.writeHead(403); return res.end('not allowed'); }
-    fs.readFile(real, 'utf8', (e, d) => { res.writeHead(e ? 404 : 200, { 'content-type': 'text/plain' }); res.end(e ? 'memo not found: ' + file : d); });
-    return;
+    return fs.readFile(real, 'utf8', (e, d) => { res.writeHead(e ? 404 : 200, { 'content-type': 'text/plain' }); res.end(e ? 'not found' : d); });
   }
 
-  if (url.pathname === '/api/decision' && req.method === 'POST') {
+  if (url.pathname === '/api/exec' && req.method === 'POST') {
     let b = ''; req.on('data', c => b += c); req.on('end', () => {
-      let ids, decision;
-      try { ({ ids, decision } = JSON.parse(b)); } catch { res.writeHead(400); return res.end('{"ok":false,"error":"bad json"}'); }
-      if (!Array.isArray(ids) || !['APPROVE', 'REVISE', 'BLOCK'].includes(decision)) { res.writeHead(400); return res.end('{"ok":false,"error":"bad input"}'); }
-      const valid = ids.filter(id => TICKETS.some(t => t.ref === id));
-      let done = 0;
-      for (const id of valid) { tk(['comment', id, `Steve decision via triage page (${PORT}): ${decision}. (Recorded only — gated command stays a manual paste per the runbook.)`]); done++; }
-      res.writeHead(200, { 'content-type': 'application/json' });
-      res.end(JSON.stringify({ ok: true, done }));
+      let ref, confirm, inputs;
+      try { ({ ref, confirm, inputs } = JSON.parse(b)); } catch { res.writeHead(400); return res.end('{"ok":false,"msg":"bad json"}'); }
+      const t = byRef(ref); if (!t) { res.writeHead(404); return res.end('{"ok":false,"msg":"unknown ticket"}'); }
+      const e = t.exec;
+      const reply = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
+
+      if (e.mode === 'handsoff') return reply({ ok: false, msg: 'Hands-off — owned by the armed idle-watch; not fireable here.' });
+
+      if (e.mode === 'decision') {
+        tk(['comment', ref, `Steve APPROVED via triage page: ${e.detail}`]);
+        tk(['status', ref, 'doing']);
+        return reply({ ok: true, msg: 'Decision recorded (APPROVE) + ticket → doing.' });
+      }
+
+      if (e.mode === 'input') {
+        const digest = (e.fields || []).map(f => `${f.name}=${last4(inputs && inputs[f.name])}`).join(', ');
+        // Persist the provided secrets to a gitignored local file the terminal step reads — never echoed back.
+        try { fs.writeFileSync(path.join(__dirname, `.secret-${ref}.json`), JSON.stringify(inputs || {}), { mode: 0o600 }); } catch {}
+        tk(['comment', ref, `Steve provided inputs via triage page (${digest}); saved to gitignored .secret-${ref}.json. nginx auth-drop is the remaining terminal step (prod SSH, irreversible-ish).`]);
+        tk(['status', ref, 'doing']);
+        return reply({ ok: true, msg: `Recorded ${digest}. Saved for the terminal step (nginx drop stays a manual prod SSH).` });
+      }
+
+      if (e.mode === 'fire') {
+        if (e.danger === 'live' && confirm !== 'RUN') return reply({ ok: false, msg: 'Live action needs typed RUN confirmation.' });
+        tk(['status', ref, 'doing']);
+        tk(['log', ref, `FIRING via triage page: ${e.cmd}`]);
+        runCmd(e.cmd, (code, output) => {
+          const ok = code === 0;
+          tk(['comment', ref, `Triage-page fire ${ok ? 'SUCCESS' : 'exit=' + code}: \`${e.cmd}\``]);
+          reply({ ok, msg: `exit ${code}` + (ok ? '' : ' — see output'), output });
+        });
+        return;
+      }
+      return reply({ ok: false, msg: 'no exec mode' });
     });
     return;
   }
   res.writeHead(404); res.end('not found');
 });
-server.listen(PORT, () => console.log(`ticket-triage on http://127.0.0.1:${PORT}  (admin/DW2024!, /healthz open)`));
+server.listen(PORT, HOST, () => console.log(`ticket-triage on http://${HOST}:${PORT}  (admin/DW2024!, /healthz open, buttons FIRE)`));
diff --git a/tickets.js b/tickets.js
index a6dc778..54415e7 100644
--- a/tickets.js
+++ b/tickets.js
@@ -1,56 +1,80 @@
-// The 10 Steve-gated blocked tickets, curated from the triage pass (2026-07-31).
-// Each item = the one action + its runbook file. `ref` is any form tk resolves.
-// Nothing here executes a gated action — the page only RECORDS decisions as tk comments.
+// The 10 Steve-gated blocked tickets — HYBRID model (2026-07-31).
+// The server can only ever spawn a `fire.cmd` (local + reversible). Prod/destructive
+// actions carry `pasteCmd` (copy-to-clipboard → you run it in a terminal, where each
+// command is still gated) and a Mark-fired that only records to the ticket.
+//   fire     — server runs cmd (local, reversible). danger:'safe'.
+//   decision — records APPROVE + → doing. No shell.
+//   input    — captures Steve-only secrets to a gitignored file (last-4 to ticket). No prod.
+//   copy     — server does NOT run it; page copies pasteCmd, Mark-fired records you ran it.
+//   handsoff — no button (another owner fires it).
 const HOME = require('os').homedir();
 const Q = HOME + '/.claude/yolo-queue';
 
 module.exports = [
   { ref: 'TK-00058', bucket: 'approval', title: 'Vendor-onboard bucket approvals (Sanderson / Muralsource / Fentucci)',
-    action: 'Confirm the verdicts: Fentucci = APPROVE, Muralsource = REJECT, Sanderson = HOLD.',
-    recommend: 'APPROVE', effort: '1-word',
-    memo: Q + '/pending-approval/TK-00058-vendor-bucket-decision-memo-2026-07-28.md' },
+    action: 'Confirm verdicts: Fentucci = APPROVE, Muralsource = REJECT, Sanderson = HOLD.',
+    recommend: 'APPROVE', memo: Q + '/pending-approval/TK-00058-vendor-bucket-decision-memo-2026-07-28.md',
+    exec: { mode: 'decision', btn: 'Approve verdicts',
+            detail: 'Fentucci=APPROVE (armed cadence continues), Muralsource=REJECT/closed, Sanderson=HOLD. FENT-1 metafield cleanup has no script yet — noted for the terminal.' } },
 
   { ref: 'TK-00136', bucket: 'approval', title: 'Stroheim onboard #8 — go-live activation',
-    action: 'Give the go to activate the settlement-OK Stroheim drafts (agent runs go-live.mjs). Eyeball the ~30% block-rate first.',
-    recommend: 'REVIEW', effort: '1-word', memo: null },
+    action: 'Activate settlement-OK Stroheim drafts — bounded canary of 5 (channels exclude Google/YouTube, 5-field gated).',
+    recommend: 'CANARY 5', memo: null,
+    exec: { mode: 'copy', btn: 'Activate 5', danger: 'live',
+            pasteCmd: 'cd ~/Projects/designerwallcoverings/scripts/stroheim-onboard && STROHEIM_ALLOW_GOLIVE=1 node go-live.mjs --apply --limit=5',
+            detail: 'Customer-facing: publishes 5 Stroheim products live. Paste in a terminal (per-command gated). Reversible: draft them back.' } },
 
   { ref: 'TK-10016', bucket: 'approval', title: 'Make individual DW tools independently public',
-    action: 'Approve Tier-1 publish only (recolor / color-wheel / 150dpi). Tier-2 with-care, Tier-3 stays tailnet (they write prod Shopify).',
-    recommend: 'APPROVE Tier-1', effort: '1-word',
-    memo: HOME + '/Projects/tools-dw-hub/SCOPE-tools-public.md' },
+    action: 'Approve Tier-1 publish only (recolor / color-wheel / 150dpi). Tier-3 stays tailnet (write prod Shopify).',
+    recommend: 'APPROVE Tier-1', memo: HOME + '/Projects/tools-dw-hub/SCOPE-tools-public.md',
+    exec: { mode: 'decision', btn: 'Approve Tier-1',
+            detail: 'Records Tier-1-only publish approval. Each tool deploy is a separate per-tool action.' } },
 
   { ref: 'TK-21', bucket: 'deploy', title: 'Consulting portal P2 polish — deploy',
-    action: 'Paste the deploy to dw.agentabrams.com. Officer-signed (5/5 APPROVE). This one paste ALSO clears TK-00069.',
-    recommend: 'APPROVE', effort: 'paste',
-    memo: Q + '/approved/TK-21-consulting-dw-portal-P2-polish-deploy.md' },
+    action: 'Build + rsync + pm2 reload + smoke-test to dw.agentabrams.com. This deploy ALSO ships TK-00069.',
+    recommend: 'DEPLOY', memo: Q + '/approved/TK-21-consulting-dw-portal-P2-polish-deploy.md',
+    exec: { mode: 'copy', btn: 'Deploy', danger: 'live',
+            pasteCmd: 'cd ~/Projects/consulting-designerwallcoverings-com && git status --short && node build.mjs && bash ~/Projects/_shared/scripts/deploy.sh',
+            detail: 'Customer-facing prod deploy to dw.agentabrams.com (pm2 :9703). rsync excludes live intakes.json. Reversible: git revert + redeploy.' } },
 
   { ref: 'TK-00069', bucket: 'deploy', title: 'Red-team DW consulting portal (bundled with TK-21)',
-    action: 'Ships with TK-21 — the XFF/security fixes + cred rotation ride the same deploy.',
-    recommend: 'APPROVE (w/ TK-21)', effort: 'paste',
-    memo: Q + '/pending-approval/TK-22-consulting-portal-deploy-consolidated.md' },
+    action: 'The XFF/security fixes ride the SAME deploy as TK-21 — fire TK-21 to ship both.',
+    recommend: 'via TK-21', memo: Q + '/pending-approval/TK-22-consulting-portal-deploy-consolidated.md',
+    exec: { mode: 'decision', btn: 'Mark (ships w/ TK-21)',
+            detail: 'No separate deploy — records that the security fixes ship on the TK-21 deploy.' } },
 
   { ref: 'TK-00071', bucket: 'paste', title: 'ideas.agentabrams.com → DNS-01 conversion',
-    action: 'Open memo, pick Option A (retire redundant origin cert — recommended), paste its block, run the Step-V canary.',
-    recommend: 'Option A', effort: 'paste',
-    memo: Q + '/pending-approval/TK-00071-ideas-agentabrams-dns-01-conversion.md' },
+    action: 'Copy the runbook Step-0 read-only check (does an ideas cert even exist?). Run it; if none, close as done.',
+    recommend: 'Option A', memo: Q + '/pending-approval/TK-00071-ideas-agentabrams-dns-01-conversion.md',
+    exec: { mode: 'copy', btn: 'Step-0 check', danger: 'safe',
+            pasteCmd: "ssh -o ConnectTimeout=8 root@45.61.58.125 'echo \"== certbot certs for ideas ==\"; certbot certificates 2>/dev/null | grep -A6 -iE \"ideas.agentabrams\" || echo \"(no ideas cert in certbot)\"; echo \"== cloudflared ==\"; systemctl is-active cloudflared'",
+            detail: 'Read-only prod SSH — kept in your terminal (the server never SSHes prod). No cert → nothing to retire, close as done. Cert exists → Option-A retire is the follow-up.' } },
 
   { ref: 'TK-11', bucket: 'paste', title: 'astek pw rotation + sales-to-Slack scope',
-    action: 'Paste the new astek password / CF-Access token into the secrets step, then nginx auth-drop + Shopify read_orders grant.',
-    recommend: 'APPROVE', effort: 'paste',
-    memo: Q + '/pending-approval/astek-pw-rotation-slack-scope.md' },
+    action: 'Provide the new astek password + CF-Access token — captured (last-4 to ticket) and readied for the nginx auth-drop terminal step.',
+    recommend: 'APPROVE', memo: Q + '/pending-approval/astek-pw-rotation-slack-scope.md',
+    exec: { mode: 'input', btn: 'Provide + prep',
+            fields: [ { name: 'astek_password', label: 'New astek password', type: 'password' },
+                      { name: 'cf_access_id', label: 'CF-Access Client ID', type: 'text' },
+                      { name: 'cf_access_secret', label: 'CF-Access Client Secret', type: 'password' } ],
+            detail: 'The nginx auth-drop is an irreversible prod SSH step that needs a real terminal — this captures your secrets to a gitignored file and records last-4 to the ticket.' } },
 
   { ref: 'TK-12', bucket: 'task', title: 'nineoh Unofficial 90210 → App Store / TestFlight',
-    action: 'Browser: appstoreconnect.apple.com → Users & Access → Integrations → generate a Team API key (App Manager), download .p8, copy Key ID + Issuer ID → then run finish-tk12.sh.',
-    recommend: 'ACTION', effort: 'task',
-    memo: Q + '/pending-approval/TK-12-nineoh-appstore-testflight.md' },
+    action: 'Copy the finish-tk12.sh command — installs the ASC key, wires eas.json, kicks the production iOS build. Needs your Apple .p8 downloaded first (script self-guards).',
+    recommend: 'ACTION', memo: Q + '/pending-approval/TK-12-nineoh-appstore-testflight.md',
+    exec: { mode: 'copy', btn: 'finish-tk12.sh', danger: 'live',
+            pasteCmd: 'bash ~/Projects/nineoh-guide/finish-tk12.sh',
+            detail: 'Kicks a real EAS production build (1 free slot); STOPS before submit. Do the browser ASC key-gen first (see runbook), then run this in a terminal.' } },
 
   { ref: 'TK-00128', bucket: 'task', title: 'DW reels TikTok uploader → Content Posting API',
-    action: 'Approve a live-creds DRY-RUN first (proves wiring, posts nothing). The real gate is YOU submitting the DW app for TikTok audit (~1-2 wks). Public posting is impossible until it clears.',
-    recommend: 'DRY-RUN first', effort: 'task',
-    memo: Q + '/pending-approval/dw-reels-tiktok-golive.md' },
+    action: 'Fire the live-creds DRY-RUN — refreshes the token, validates auth with TikTok, assembles the init body. Publishes NOTHING.',
+    recommend: 'DRY-RUN', memo: Q + '/pending-approval/dw-reels-tiktok-golive.md',
+    exec: { mode: 'fire', danger: 'safe', btn: 'Run dry-run',
+            cmd: 'cd ~/Projects/dw-marketing-reels && TIKTOK_DRY_RUN=1 node scripts/tiktok-post.mjs',
+            detail: 'Reversible: no post created. ⚠ Fires the live oauth/token refresh (rotates the shared TikTok refresh token) — do not run while MCC is mid-refresh. Public posting still needs the audit (your manual submit).' } },
 
   { ref: 'TK-13', bucket: 'auto', title: 'Git-history purge — 3 no-remote repos',
-    action: 'NOTHING to paste. vp-ops co-signed; an armed idle-watch auto-fires it when the DW repo goes write-idle. Just waiting on the apply jobs you already set.',
-    recommend: 'NO ACTION', effort: 'none',
-    memo: Q + '/pending-approval/2026-07-27-git-history-purge-TK-13-refresh.md' },
+    action: 'HANDS-OFF. An armed idle-watch in another session owns this destructive purge; a second fire would corrupt it.',
+    recommend: 'NO ACTION', memo: Q + '/pending-approval/2026-07-27-git-history-purge-TK-13-refresh.md',
+    exec: { mode: 'handsoff', detail: 'Owned by an armed idle-watch (auto-fires when the DW repo goes write-idle). Firing here would race/corrupt the filter-repo run.' } },
 ];

← 565f6b4 Ticket triage page: select 1-all of the 10 Steve-gated block  ·  back to Ticket Triage Page  ·  Hybrid execute: fire safe/reversible actions locally, copy-t 446e2be →