[object Object]

← back to Ticket Triage Page

Ticket triage page: select 1-all of the 10 Steve-gated blocked tickets, record decisions (bookkeeping only, no gated exec)

565f6b41f54030c4355c570b6b77fe1628b07f3e · 2026-07-30 20:37:46 -0700 · Steve Abrams

Files touched

Diff

commit 565f6b41f54030c4355c570b6b77fe1628b07f3e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 20:37:46 2026 -0700

    Ticket triage page: select 1-all of the 10 Steve-gated blocked tickets, record decisions (bookkeeping only, no gated exec)
---
 .gitignore |   7 ++++
 server.js  | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tickets.js |  56 +++++++++++++++++++++++++
 3 files changed, 200 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..08240ae
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..de2c3a4
--- /dev/null
+++ b/server.js
@@ -0,0 +1,137 @@
+#!/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.
+const http = require('http');
+const fs = require('fs');
+const { execFileSync } = require('child_process');
+const path = require('path');
+const os = require('os');
+
+const PORT = process.env.PORT || 9759;
+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') ];
+
+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])); }
+
+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'] };
+
+function page() {
+  const blocked = liveBlocked();
+  const cards = TICKETS.map((t, i) => {
+    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>`;
+  }).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>
+: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}
+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}
+.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}
+#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}
+</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>
+<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>
+<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()};
+$('#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()});
+$('#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();
+</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 === '/api/memo') {
+    const file = url.searchParams.get('file') || '';
+    const real = path.resolve(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;
+  }
+
+  if (url.pathname === '/api/decision' && 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 }));
+    });
+    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)`));
diff --git a/tickets.js b/tickets.js
new file mode 100644
index 0000000..a6dc778
--- /dev/null
+++ b/tickets.js
@@ -0,0 +1,56 @@
+// 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.
+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' },
+
+  { 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 },
+
+  { 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' },
+
+  { 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' },
+
+  { 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' },
+
+  { 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' },
+
+  { 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' },
+
+  { 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' },
+
+  { 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' },
+
+  { 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' },
+];

(oldest)  ·  back to Ticket Triage Page  ·  auto-save: 2026-07-30T20:51:09 (2 files) — server.js tickets 2dc79d9 →