[object Object]

← back to Ticket Action Viewer

Ticket Action Viewer — ranked board with next-steps, gated-needs, and per-ticket action keys (TK-10374)

bec41b1d39e1b460764f4e661f5f54c147bc978c · 2026-08-10 07:43:46 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit bec41b1d39e1b460764f4e661f5f54c147bc978c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 07:43:46 2026 -0700

    Ticket Action Viewer — ranked board with next-steps, gated-needs, and per-ticket action keys (TK-10374)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore         |   5 +++
 data/analysis.json |  37 +++++++++++++++++++
 public/index.html  | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js          |  87 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 232 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff2422c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
diff --git a/data/analysis.json b/data/analysis.json
new file mode 100644
index 0000000..b4761d8
--- /dev/null
+++ b/data/analysis.json
@@ -0,0 +1,37 @@
+{
+  "TK-10262": {
+    "priority": 92,
+    "nextStep": "APPROVE the reuse-build-8 memo → I run `fastlane deliver` (fresh screenshots already committed) + Submit build 8 for Review. One click from the App Store.",
+    "gated": true,
+    "needs": "PROCESS: Approve pending-approval/TK-10262-celebsignatures-ios-reuse-build8-submit.md (accepts iPad+tvOS). OR, for a true iPhone-only binary, INFO/PROCESS: downgrade this Mac's Xcode 26.6 → 26.5 (26.6 has an exportArchive regression that blocks the rebuild).",
+    "note": "iPhone-only rebuild is toolchain-blocked; screenshots regenerated + committed (986ff07). Everything else staged."
+  },
+  "TK-10258": {
+    "priority": 90,
+    "nextStep": "Same as TK-10262 (its sibling) — approve the iOS submit memo and build 8 ships.",
+    "gated": true,
+    "needs": "PROCESS: your APPROVE/REVISE/BLOCK on the iOS submit memo. Binary + listing + App Privacy already on the ASC draft.",
+    "note": "Owned jointly with the parallel TK-10262 build ticket; do not double-submit."
+  },
+  "TK-10286": {
+    "priority": 85,
+    "nextStep": "Paste the USPTO ODP API key → I route it via /secrets, pull the register, build the clearance index, and the /wear store flips from empty to a trademark-cleared apparel line automatically.",
+    "gated": true,
+    "needs": "INFO: the USPTO Open Data Portal API key. PROCESS to get it: complete ID.me identity verification (photo ID + selfie) at data.uspto.gov → Manage API Key (human-only, ~15 min). ID.me is the sole blocker.",
+    "note": "/wear feature is built, tested, safely-gated (0 sellable until the key). Finish path in WEAR-READY-FOR-KEY.md."
+  },
+  "TK-10374": {
+    "priority": 70,
+    "nextStep": "Crash-recovery supervisor ticket. CS reversible lane drained (murals+game fixed+live, Artists verified, this viewer built). Parked on the 3 gated items above.",
+    "gated": false,
+    "needs": "",
+    "note": "cre-agent crash-recovery mission."
+  },
+  "TK-10240": {
+    "priority": 62,
+    "nextStep": "Approve the homesonspec deploy memo — code is complete + tested (23/23), only the Kamatera deploy remains.",
+    "gated": true,
+    "needs": "PROCESS: approve the deploy, using the .env-SAFE rsync in the memo's SAFETY ADDENDUM (a bare `rsync --delete` would clobber prod .env + prod-only dirs). Owned by homesonspec-agent — coordinate before touching.",
+    "note": "Cross-repo; not in the CelebritySignatures lane."
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..7085279
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,103 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Ticket Action Viewer</title>
+<style>
+  :root{--bg:#0f1115;--card:#171a21;--line:#262b36;--ink:#e8eaed;--mut:#8b93a1;--acc:#4f8cff;--warn:#f2b23a;--gate:#ff6b5a;--ok:#3ecf8e}
+  *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
+  header{position:sticky;top:0;z-index:5;background:#0f1115ee;backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:12px 18px}
+  h1{margin:0 0 8px;font-size:17px;font-weight:700;letter-spacing:.2px}
+  h1 small{color:var(--mut);font-weight:400;font-size:12px;margin-left:8px}
+  .bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
+  .bar input,.bar select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 10px;font:inherit}
+  .bar input[type=search]{min-width:240px}
+  .chip{font-size:11px;padding:3px 9px;border-radius:999px;border:1px solid var(--line);color:var(--mut)}
+  .chip b{color:var(--ink)}
+  label.tog{display:flex;gap:6px;align-items:center;color:var(--mut);font-size:12px;cursor:pointer}
+  main{padding:14px 18px 60px}
+  table{width:100%;border-collapse:collapse;font-size:13px}
+  th{position:sticky;top:96px;background:#12151c;text-align:left;color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.6px;padding:8px 10px;border-bottom:1px solid var(--line)}
+  td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
+  tr:hover td{background:#12151c}
+  .rank{font-weight:700;color:var(--acc);font-variant-numeric:tabular-nums}
+  .tid{font-weight:700}.tid a{color:var(--acc);text-decoration:none}
+  .ttl{color:var(--ink)}.proj{color:var(--mut);font-size:11px}
+  .st{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;text-transform:uppercase;letter-spacing:.5px}
+  .st.open{background:#173a2a;color:var(--ok)}.st.doing{background:#3a3417;color:var(--warn)}.st.blocked{background:#3a1e1a;color:var(--gate)}
+  .owner{color:var(--mut);font-size:12px}
+  .next{color:#cfd6e2;max-width:340px}
+  .needs{max-width:320px;color:var(--mut);font-size:12px}
+  .needs.gated{color:#ffd0c9;background:#2a1512;border:1px solid #4a2620;border-radius:8px;padding:7px 9px}
+  .acts{white-space:nowrap;display:flex;gap:5px;flex-wrap:wrap}
+  button.k{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:5px 9px;font:600 12px/1 inherit;cursor:pointer}
+  button.k:hover{border-color:var(--acc)}
+  button.k.done{color:var(--ok)}button.k.take{color:var(--acc)}button.k.block{color:var(--gate)}
+  #modal{position:fixed;inset:0;background:#000a;display:none;align-items:center;justify-content:center;z-index:20;padding:24px}
+  #modal .box{background:var(--card);border:1px solid var(--line);border-radius:12px;max-width:820px;width:100%;max-height:82vh;overflow:auto;padding:18px}
+  #modal pre{white-space:pre-wrap;font:12px/1.55 ui-monospace,Menlo,monospace;color:#cfd6e2}
+  #toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#222834;border:1px solid var(--line);border-radius:9px;padding:9px 16px;display:none;z-index:30}
+  .x{float:right;cursor:pointer;color:var(--mut);font-size:20px;line-height:1}
+</style></head><body>
+<header>
+  <h1>🎫 Ticket Action Viewer <small id="sub">loading…</small></h1>
+  <div class="bar">
+    <input type="search" id="q" placeholder="Search title / id / owner / project…">
+    <select id="fstatus"><option value="">All statuses</option><option>open</option><option>doing</option><option>blocked</option></select>
+    <select id="fproject"><option value="">All projects</option></select>
+    <label class="tog"><input type="checkbox" id="fgated"> Gated only (needs your input)</label>
+    <span class="chip">blocked <b id="cB">0</b></span><span class="chip">doing <b id="cD">0</b></span><span class="chip">open <b id="cO">0</b></span>
+    <button class="k" onclick="load()">↻ Refresh</button>
+    <a class="k" href="http://127.0.0.1:9794" target="_blank" style="text-decoration:none">Board ↗</a>
+  </div>
+</header>
+<main><table><thead><tr>
+  <th>#</th><th>Ticket</th><th>Project</th><th>Status</th><th>Owner</th><th>Next Step</th><th>Gated · what's needed to start</th><th>Actions</th>
+</tr></thead><tbody id="rows"></tbody></table></main>
+<div id="modal"><div class="box"><span class="x" onclick="closeM()">×</span><h3 id="mtitle"></h3><pre id="mbody"></pre></div></div>
+<div id="toast"></div>
+<script>
+let ALL=[];
+const el=id=>document.getElementById(id);
+async function load(){
+  const r=await fetch('/api/tickets'); const d=await r.json(); ALL=d.tickets;
+  el('sub').textContent=`${ALL.length} tickets · agent ${d.agent} · ranked by priority`;
+  const projs=[...new Set(ALL.map(t=>t.project))].sort();
+  el('fproject').innerHTML='<option value="">All projects</option>'+projs.map(p=>`<option>${p}</option>`).join('');
+  render();
+}
+function counts(list){el('cB').textContent=list.filter(t=>t.status==='blocked').length;el('cD').textContent=list.filter(t=>t.status==='doing').length;el('cO').textContent=list.filter(t=>t.status==='open').length;}
+function render(){
+  const q=el('q').value.toLowerCase(),fs=el('fstatus').value,fp=el('fproject').value,fg=el('fgated').checked;
+  const list=ALL.filter(t=>(!fs||t.status===fs)&&(!fp||t.project===fp)&&(!fg||t.gated)&&
+    (!q||(t.shortId+t.title+t.owner+t.project).toLowerCase().includes(q)));
+  counts(list);
+  el('rows').innerHTML=list.map((t,i)=>`<tr>
+    <td class="rank">${i+1}</td>
+    <td><div class="tid">${t.shortId}</div><div class="ttl">${esc(t.title)}</div></td>
+    <td class="proj">${esc(t.project)}</td>
+    <td><span class="st ${t.status}">${t.status}</span></td>
+    <td class="owner">${esc(t.owner)}</td>
+    <td class="next">${esc(t.nextStep)}${t.note?`<div class="proj" style="margin-top:4px">↳ ${esc(t.note)}</div>`:''}</td>
+    <td><div class="needs ${t.gated?'gated':''}">${t.gated?'🔒 ':''}${esc(t.needs||'—')}</div></td>
+    <td><div class="acts">
+      <button class="k" onclick="detail('${t.fullId}','${t.shortId}')">Detail</button>
+      <button class="k take" onclick="act('${t.fullId}','take')">Take</button>
+      <button class="k" onclick="logIt('${t.fullId}')">+Log</button>
+      <button class="k done" onclick="act('${t.fullId}','done')">Done</button>
+      <button class="k block" onclick="act('${t.fullId}','block')">Block</button>
+    </div></td></tr>`).join('');
+}
+function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+async function detail(id,sid){el('mtitle').textContent=sid;el('mbody').textContent='loading…';el('modal').style.display='flex';
+  const r=await fetch('/api/ticket?id='+encodeURIComponent(id));const d=await r.json();el('mbody').textContent=d.out||'(no output)';}
+function closeM(){el('modal').style.display='none';}
+async function act(id,action){if((action==='done'||action==='block')&&!confirm(`${action} ${id.match(/^TK-\\d+/)[0]}?`))return;
+  const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,action})});
+  const d=await r.json();toast(d.ok?`✓ ${action} ok`:`✗ ${d.out.slice(0,80)}`);load();}
+async function logIt(id){const text=prompt('Log note for '+id.match(/^TK-\d+/)[0]+':');if(!text)return;
+  const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,action:'log',text})});
+  const d=await r.json();toast(d.ok?'✓ logged':'✗ '+d.out.slice(0,80));}
+function toast(m){const t=el('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2200);}
+['q','fstatus','fproject','fgated'].forEach(id=>el(id).addEventListener('input',render));
+el('modal').addEventListener('click',e=>{if(e.target.id==='modal')closeM()});
+load();
+</script></body></html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..5e52095
--- /dev/null
+++ b/server.js
@@ -0,0 +1,87 @@
+// Ticket Action Viewer — zero-dependency board over `tk` with action keys,
+// next-steps analysis, priority ranking, and gated-item requirements.
+// Basic-auth admin/DW2024!. Reads live `tk list`/`tk show`; writes via tk take/log/done/status.
+const http = require('http');
+const { execFile } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || 9971;
+const AGENT = process.env.TK_AGENT || 'cre-agent';
+const USER = 'admin', PASS = 'DW2024!';
+const ANALYSIS = path.join(__dirname, 'data', 'analysis.json');
+
+function tk(args) {
+  return new Promise((resolve) => {
+    execFile('tk', args, { env: { ...process.env, TK_AGENT: AGENT }, maxBuffer: 8 * 1024 * 1024 },
+      (err, stdout, stderr) => resolve({ ok: !err, out: (stdout || '') + (stderr || '') }));
+  });
+}
+
+const LINE = /^(TK-\S+)\s+\[(\w+)\]\s+\(([^)]*)\)\s+\{([^}]*)\}\s+(.*)$/;
+function loadAnalysis() { try { return JSON.parse(fs.readFileSync(ANALYSIS, 'utf8')); } catch { return {}; } }
+
+// Derive a sensible default when we have no curated analysis for a ticket.
+function derive(t) {
+  if (t.status === 'open') return { priority: 55, nextStep: 'Take it and start the first increment.', gated: false, needs: '' };
+  if (t.status === 'doing') return { priority: 50, nextStep: 'Continue — resume where the owner parked (see Detail).', gated: false, needs: '' };
+  // blocked
+  return { priority: 30, nextStep: 'Unblock — read the owner’s last note for the specific blocker (Detail).', gated: true, needs: 'Open Detail; the blocker is in the last action line. Usually a Steve-only step (approval, key, console click, or a machine/toolchain fix).' };
+}
+
+async function getTickets() {
+  const { out } = await tk(['list']);
+  const overlay = loadAnalysis();
+  const tickets = [];
+  for (const raw of out.split('\n')) {
+    const m = raw.match(LINE);
+    if (!m) continue;
+    const [, fullId, status, owner, project, title] = m;
+    const shortId = (fullId.match(/^TK-\d+/) || [fullId])[0];
+    const a = overlay[shortId] || derive({ status });
+    tickets.push({ fullId, shortId, status, owner, project, title,
+      priority: a.priority ?? derive({ status }).priority,
+      nextStep: a.nextStep ?? derive({ status }).nextStep,
+      gated: a.gated ?? derive({ status }).gated,
+      needs: a.needs ?? derive({ status }).needs,
+      note: a.note || '' });
+  }
+  tickets.sort((x, y) => y.priority - x.priority || x.shortId.localeCompare(y.shortId));
+  return tickets;
+}
+
+function auth(req) {
+  const h = req.headers.authorization || '';
+  const [, b64] = h.split(' ');
+  if (!b64) return false;
+  const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
+  return u === USER && p === PASS;
+}
+function body(req) { return new Promise(r => { let d = ''; req.on('data', c => d += c); req.on('end', () => r(d)); }); }
+function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
+
+const server = http.createServer(async (req, res) => {
+  if (!auth(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="tickets"' }); return res.end('auth'); }
+  const u = new URL(req.url, `http://x`);
+
+  if (u.pathname === '/' ) {
+    res.writeHead(200, { 'Content-Type': 'text/html' });
+    return res.end(fs.readFileSync(path.join(__dirname, 'public', 'index.html')));
+  }
+  if (u.pathname === '/api/tickets') { return json(res, 200, { agent: AGENT, tickets: await getTickets() }); }
+  if (u.pathname === '/api/ticket') { const r = await tk(['show', u.searchParams.get('id') || '']); return json(res, 200, r); }
+  if (u.pathname === '/api/action' && req.method === 'POST') {
+    let p; try { p = JSON.parse(await body(req)); } catch { return json(res, 400, { ok: false, out: 'bad json' }); }
+    const { id, action, text } = p;
+    let args;
+    if (action === 'take') args = ['take', id];
+    else if (action === 'done') args = ['done', id];
+    else if (action === 'block') args = ['status', id, 'blocked'];
+    else if (action === 'log') args = ['log', id, text || ''];
+    else return json(res, 400, { ok: false, out: 'unknown action' });
+    const r = await tk(args);
+    return json(res, 200, r);
+  }
+  res.writeHead(404); res.end('nf');
+});
+server.listen(PORT, () => console.log(`Ticket Action Viewer → http://127.0.0.1:${PORT} (admin/DW2024!)`));

(oldest)  ·  back to Ticket Action Viewer  ·  viewer: Take now stamps start date+time into the ticket log 292eb42 →