[object Object]

← back to Gated Morning Review

viewer v2: Approvals+Tasks tabs, plain-English WHY column, keep-vs-close pros/cons, Run-in-iTerm2 + Close buttons

71f930082845c430ee4f3f17fe1e7bcad21bc3dd · 2026-08-20 07:53:11 -0700 · steve

Files touched

Diff

commit 71f930082845c430ee4f3f17fe1e7bcad21bc3dd
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Aug 20 07:53:11 2026 -0700

    viewer v2: Approvals+Tasks tabs, plain-English WHY column, keep-vs-close pros/cons, Run-in-iTerm2 + Close buttons
---
 lib.mjs    | 111 ++++++++++++++++++++++++-------------
 server.mjs | 181 ++++++++++++++++++++++++++++++++++++++-----------------------
 2 files changed, 184 insertions(+), 108 deletions(-)

diff --git a/lib.mjs b/lib.mjs
index e8a4acf..4cd044c 100644
--- a/lib.mjs
+++ b/lib.mjs
@@ -2,10 +2,11 @@
 import fs from 'fs';
 import path from 'path';
 import os from 'os';
+import { execFileSync } from 'child_process';
 
 export const QDIR = path.join(os.homedir(), '.claude/yolo-queue/pending-approval');
-export const STALE_DAYS = 25;   // never let a gated item get older than this without a decision
-export const AGING_DAYS = 10;   // older than this = surfaced first ("getting old")
+export const STALE_DAYS = 25;
+export const AGING_DAYS = 10;
 
 const readHead = (p, n = 45) => {
   try { return fs.readFileSync(p, 'utf8').split('\n').slice(0, n).join('\n'); }
@@ -20,44 +21,57 @@ function firstTitle(head, file) {
   return file.replace(/[-_]/g, ' ').replace(/\.(md|csv|json|patch)$/, '');
 }
 
-// Map a gated item to a PLAIN-LANGUAGE bucket a non-technical person understands.
+// Per-category plain-language templates: what it is, why it's needed (12-yr-old),
+// and the honest good/bad reasons to keep-and-run vs. close.
+const CAT = {
+  golive: { big: '🌐 Put something LIVE on the internet',
+    why: "Right now this thing is NOT online where people can see it. This puts it live on the web.",
+    good: "More people can find and use it — could bring visitors or sales.",
+    bad: "Going live is hard to undo (it changes DNS + servers). If it's not ready, or it's already live, skip it." },
+  money: { big: '💳 Money / payments',
+    why: "This is about REAL money — turning on charging, or fixing a price that's wrong.",
+    good: "Stops us losing money or lets us start earning.",
+    bad: "Real money = real risk. If the numbers are old, it could charge or price the wrong amount." },
+  store: { big: '🏷️ Change the online store (shoppers see it)',
+    why: "This changes what shoppers see in the store — a product, a price, a picture, or a link.",
+    good: "Makes the store correct so shoppers see the right thing (and Google is happy).",
+    bad: "It's LIVE to customers. If the info inside is old, it could change the wrong products." },
+  system: { big: '🔧 System / security setup',
+    why: "This sets up something behind the scenes — a server, a timer/schedule, or a password.",
+    good: "Keeps the machines healthy, safe, and running on their own.",
+    bad: "Touches servers or passwords. Done at the wrong time it can break things or make a duplicate job." },
+  social: { big: '📣 Social-media posting',
+    why: "This posts something to a PUBLIC social account (like Instagram or TikTok).",
+    good: "Gets our stuff seen by lots more people for free.",
+    bad: "It's public and hard to unsend. A wrong or repeated post looks bad." },
+  send: { big: '✉️ Send a message to someone',
+    why: "This sends an email or message OUT to a person (a customer or a vendor).",
+    good: "Reaches someone we actually need to talk to.",
+    bad: "Once it's sent you can't unsend it — make sure it's right and wanted first." },
+  other: { big: '📋 A change that needs your OK',
+    why: "This is a change someone set up that's waiting for your yes-or-no.",
+    good: "Ticks a to-do off the list.",
+    bad: "If it's old or already handled, it's just clutter — safe to toss." },
+};
+
 export function classify(title, head, file) {
   const s = (title + ' ' + head + ' ' + file).toLowerCase();
   const has = (re) => re.test(s);
-
-  const staleSig = has(/declined|✅|\bresolved\b|already (remediated|done|fixed|live|resolved)|superseded|no action needed|no-?op|nothing to do|verification (result|artifact)|done in the databases|complete\b.*no-?op|stale-mirror/);
-
-  let category, big, why;
-  if (has(/go-?live|deploy|\bdns\b|publish (it|to)|ship it|expose .* at|kamatera deploy|golive/)) {
-    category = 'golive'; big = '🌐 Put something LIVE on the internet';
-    why = 'Changes what the public sees — DNS or a real deploy. Hard to undo.';
-  } else if (has(/stripe|adsense|admob|payout|\bspend\b|charge|billing|invoice|sk_live|real-sales|money/)) {
-    category = 'money'; big = '💳 Money / payments';
-    why = 'Involves real money or turning on charging.';
-  } else if (has(/publish|shopify|activate|reprice|catalog|metafield|archive .* product|collection|sku/)) {
-    category = 'store'; big = '🏷️ Change the online store (shoppers see it)';
-    why = 'A customer-facing change to products, prices, or the catalog.';
-  } else if (has(/launchd|\bcron\b|rotation|rotate|sudo|console|password|credential|identity|firewall|\bssh\b|volume-resize|migration/)) {
-    category = 'system'; big = '🔧 System / security setup';
-    why = 'Touches servers, schedules, disks, or credentials.';
-  } else if (has(/instagram|\big\b|tiktok|youtube|social|post batch|roster|@\w/)) {
-    category = 'social'; big = '📣 Social-media posting';
-    why = 'Posts to a public social account.';
-  } else if (has(/\bemail\b|\bsend\b|blast|letter|nudge|mailer/)) {
-    category = 'send'; big = '✉️ Send a message to someone';
-    why = 'Sends an email or message out.';
-  } else {
-    category = 'other'; big = '📋 A change that needs your OK';
-    why = 'A gated action waiting for approval.';
-  }
-  return { category, big, why, staleSig };
+  const staleSig = has(/declined|✅|\bresolved\b|already (remediated|done|fixed|live|resolved)|superseded|no action needed|no-?op|nothing to do|verification (result|artifact)|done in the databases|stale-mirror/);
+  let key = 'other';
+  if (has(/go-?live|deploy|\bdns\b|publish (it|to)|ship it|expose .* at|kamatera deploy|golive/)) key = 'golive';
+  else if (has(/stripe|adsense|admob|payout|\bspend\b|charge|billing|invoice|sk_live|real-sales|money|reprice/)) key = 'money';
+  else if (has(/publish|shopify|activate|catalog|metafield|archive .* product|collection|\bsku\b|redirect|image/)) key = 'store';
+  else if (has(/launchd|\bcron\b|rotation|rotate|sudo|console|password|credential|identity|firewall|\bssh\b|volume-resize|migration|backup/)) key = 'system';
+  else if (has(/instagram|\big\b|tiktok|youtube|social|post batch|roster|@\w/)) key = 'social';
+  else if (has(/\bemail\b|\bsend\b|blast|letter|nudge|mailer/)) key = 'send';
+  const c = CAT[key];
+  return { category: key, big: c.big, why: c.why, good: c.good, bad: c.bad, staleSig };
 }
 
 export function scanQueue() {
   let files = [];
-  try {
-    files = fs.readdirSync(QDIR).filter(f => /\.(md|csv|json|patch)$/.test(f) && !f.startsWith('DONE-'));
-  } catch {}
+  try { files = fs.readdirSync(QDIR).filter(f => /\.(md|csv|json|patch)$/.test(f) && !f.startsWith('DONE-')); } catch {}
   const now = Date.now();
   return files.map(f => {
     const full = path.join(QDIR, f);
@@ -67,10 +81,29 @@ export function scanQueue() {
     const title = firstTitle(head, f);
     const ticket = (head.match(/TK-\d+/) || [])[0] || '';
     const c = classify(title, head, f);
-    let disposition;
-    if (c.staleSig || age > STALE_DAYS) disposition = 'STALE';        // auto-close candidate
-    else disposition = 'DECIDE';                                       // needs Steve
-    const ageWord = age <= 0 ? 'today' : age === 1 ? '1 day old' : `${age} days old`;
-    return { file: f, title, ticket, age, ageWord, ...c, disposition, aging: age >= AGING_DAYS };
-  }).filter(Boolean).sort((a, b) => b.age - a.age); // oldest first — never let gated get too old
+    const aging = age >= AGING_DAYS;
+    // aging items get an extra "stale-data" warning appended to the bad reason
+    const bad = aging ? c.bad + " ⚠️ And it's OLD — the info inside may be out of date, so re-check before running." : c.bad;
+    return { kind: 'gated', id: f, file: f, title, ticket, age,
+      ageWord: age <= 0 ? 'today' : age === 1 ? '1 day old' : `${age} days old`,
+      ...c, bad, aging, disposition: (c.staleSig || age > STALE_DAYS) ? 'STALE' : 'DECIDE' };
+  }).filter(Boolean).sort((a, b) => b.age - a.age);
+}
+
+// tk task backlog — parsed from `tk list`.
+export function scanTasks(limit = 60) {
+  let out = '';
+  try { out = execFileSync('tk', ['list'], { encoding: 'utf8', timeout: 8000, env: { ...process.env, TK_AGENT: 'morning-viewer' } }); }
+  catch { return []; }
+  const items = [];
+  for (const line of out.split('\n')) {
+    const m = line.match(/^(TK-[\w-]+)\s+\[(\w+)\]\s+\(([^)]*)\)\s+\{([^}]*)\}\s+(.*)$/);
+    if (!m) continue;
+    const [, id, status, owner, project, title] = m;
+    const c = classify(title, '', id);
+    items.push({ kind: 'task', id, status, owner, project, title: title.slice(0, 140),
+      ...c, blocked: status === 'blocked', doing: status === 'doing' });
+    if (items.length >= limit) break;
+  }
+  return items;
 }
diff --git a/server.mjs b/server.mjs
index 78163ce..e8a446e 100644
--- a/server.mjs
+++ b/server.mjs
@@ -1,96 +1,139 @@
-// Big-font, plain-language morning approval viewer for the gated queue.
-// Zero-dependency (pure node http). Basic Auth admin/DW2024!.
+// Big-font morning viewer: Approvals/Gated + Tasks, with a plain-English WHY column,
+// keep-vs-close pros/cons, and Run-in-iTerm2 / Close buttons. Zero-dependency. Basic Auth.
 import http from 'http';
 import fs from 'fs';
 import path from 'path';
+import os from 'os';
+import { execFile } from 'child_process';
 import { fileURLToPath } from 'url';
-import { scanQueue, QDIR } from './lib.mjs';
+import { scanQueue, scanTasks, QDIR } from './lib.mjs';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const PORT = process.env.PORT || 9793;
+const PORT = process.env.PORT || 9440;
 const USER = 'admin', PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
-const DATA = path.join(__dirname, 'data');
-fs.mkdirSync(DATA, { recursive: true });
+const DATA = path.join(__dirname, 'data'); fs.mkdirSync(DATA, { recursive: true });
 const DECIS = path.join(DATA, 'decisions.jsonl');
+const RUNREQ = path.join(DATA, 'run-requests.jsonl');
+const FANOUT = path.join(os.homedir(), '.claude/skills/iterm/iterm-fanout.sh');
+for (const d of ['_done', '_never', '_approved']) fs.mkdirSync(path.join(QDIR, d), { recursive: true });
 
-const closedDirs = { toss: '_done', never: '_never', approve: '_approved' };
-for (const d of Object.values(closedDirs)) fs.mkdirSync(path.join(QDIR, d), { recursive: true });
-
-function authed(req) {
-  const h = req.headers.authorization || '';
-  const [, b64] = h.split(' ');
+const authed = (req) => {
+  const [, b64] = (req.headers.authorization || '').split(' ');
   if (!b64) return false;
   const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
   return u === USER && p === PASS;
-}
+};
 
-function decide(file, action) {
+function closeItem(file, action) {
   const src = path.join(QDIR, file);
   if (!fs.existsSync(src)) return { ok: false, err: 'gone' };
-  const destDir = action === 'later' ? null : path.join(QDIR, closedDirs[action] || '_done');
-  if (destDir) { fs.mkdirSync(destDir, { recursive: true }); fs.renameSync(src, path.join(destDir, file)); }
-  fs.appendFileSync(DECIS, JSON.stringify({ ts: new Date().toISOString(), file, action }) + '\n');
+  const dir = action === 'never' ? '_never' : '_done';
+  fs.renameSync(src, path.join(QDIR, dir, file));
+  fs.appendFileSync(DECIS, JSON.stringify({ ts: new Date().toISOString(), file, action: 'close-' + dir }) + '\n');
   return { ok: true };
 }
 
-const PAGE = (items) => `<!doctype html><html><head><meta charset=utf8>
-<meta name=viewport content="width=device-width,initial-scale=1">
-<title>Morning Approvals</title><style>
+// Run: spawn a dedicated iTerm2 window with a Claude session to execute this item (gates intact).
+function runItem(item) {
+  const isTask = item.kind === 'task';
+  const header = (isTask ? item.id : item.file).slice(0, 40);
+  const target = isTask ? `tk ticket ${item.id}` : `the gated memo at ${path.join(QDIR, item.file)}`;
+  const prompt = `Steve APPROVED this in the morning viewer (${new Date().toISOString()}). Execute ${target} per ~/.claude/CLAUDE.md rules: run reversible/internal steps yourself (canary-first, restore-map, log to executed-reversible/ledger.jsonl); VERIFY-BEFORE-ACTING (this queue has many stale-mirror false alarms — re-check live state before believing a defect); draft any HARD-gated sub-step (customer-facing/destructive/spend/DNS/publish/send/identity/canonical write) back to pending-approval and hand Steve a paste-line. Ride a ticket (tk log). Show $ cost.`;
+  fs.appendFileSync(RUNREQ, JSON.stringify({ ts: new Date().toISOString(), id: item.id, kind: item.kind }) + '\n');
+  if (!isTask) { // move the memo to _approved so the queue reflects it's being run
+    const src = path.join(QDIR, item.file);
+    if (fs.existsSync(src)) fs.renameSync(src, path.join(QDIR, '_approved', item.file));
+  }
+  return new Promise((resolve) => {
+    execFile('bash', [FANOUT, `Approved-run ${header}`, '--cwd', os.homedir(), `${header} :: ${prompt}`],
+      { timeout: 20000 }, (err) => resolve({ ok: true, spawned: !err, note: err ? 'queued (iTerm spawn unavailable from server; the loop will run it)' : 'iTerm2 window opened' }));
+  });
+}
+
+const card = (it) => {
+  const runId = (it.id || it.file).replace(/'/g, "\\'");
+  const ageBadge = it.kind === 'gated'
+    ? `<span class="age ${it.aging ? 'old' : ''}">${it.aging ? '🔴 ' : '🕒 '}${it.ageWord}</span>`
+    : `<span class="age ${it.blocked ? 'blk' : it.doing ? 'go' : ''}">${it.blocked ? '⛔ blocked' : it.doing ? '⚙️ in progress' : '○ open'}</span>`;
+  return `<div class=card>
+    <div class=top>${ageBadge}${it.ticket || (it.kind==='task'?it.id:'') ? `<span class=tk>${it.ticket||it.id}</span>` : ''}${it.kind==='task'?`<span class=proj>${it.project||''}</span>`:''}</div>
+    <div class=big>${it.big}</div>
+    <div class=cols>
+      <div class=col><div class=lbl>❓ Why it's needed</div><div class=txt>${it.why}</div>
+        <div class=raw>${(it.title||'').replace(/</g,'&lt;')}</div></div>
+      <div class=col><div class=lbl>👍 Good reason to keep &amp; run</div><div class="txt gd">${it.good}</div>
+        <div class=lbl style=margin-top:10px>👎 Reason it's OK to close</div><div class="txt bd">${it.bad}</div></div>
+    </div>
+    <div class=btns>
+      <button class=run onclick="run('${runId}','${it.kind}',this)">▶️ Run in iTerm2</button>
+      <button class=close onclick="close_('${runId}','${it.kind}',this)">🗑️ Close</button>
+    </div></div>`;
+};
+
+const PAGE = (gated, tasks) => `<!doctype html><html><head><meta charset=utf8>
+<meta name=viewport content="width=device-width,initial-scale=1"><title>Morning Review</title><style>
 :root{--bg:#faf8f5;--ink:#1a1a1a;--card:#fff;--old:#b3261e;--line:#e5ded3}
-*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);
-font:400 20px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;padding:24px 16px 120px}
-h1{font-size:40px;margin:8px 0 4px}.sub{font-size:22px;color:#6b6257;margin-bottom:24px}
-.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 24px;
-margin:0 auto 18px;max-width:760px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
-.big{font-size:30px;font-weight:700;margin:0 0 6px}
-.why{font-size:21px;color:#555;margin:2px 0 10px}
-.ttl{font-size:16px;color:#8a8175;word-break:break-word;margin-bottom:16px}
-.age{display:inline-block;font-size:17px;font-weight:700;padding:3px 12px;border-radius:99px;background:#efe9df;color:#5b5346}
-.age.old{background:#fde7e5;color:var(--old)}
-.btns{display:flex;gap:12px;margin-top:16px;flex-wrap:wrap}
-button{font-size:22px;font-weight:700;padding:16px 22px;border:0;border-radius:14px;cursor:pointer;flex:1;min-width:150px}
-.yes{background:#1f8f4e;color:#fff}.toss{background:#efe9df;color:#3a352d}.later{background:#fff;border:2px solid var(--line);color:#7a7266}
-.done{opacity:.4}.empty{text-align:center;font-size:26px;color:#6b6257;margin-top:60px}
-.bar{position:fixed;bottom:0;left:0;right:0;background:#fff;border-top:1px solid var(--line);
-padding:14px;text-align:center;font-size:19px}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:400 19px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;padding:22px 14px 90px}
+h1{font-size:38px;margin:6px 0}.sub{font-size:20px;color:#6b6257;margin-bottom:18px}
+.tabs{display:flex;gap:10px;margin:0 0 20px;flex-wrap:wrap}
+.tab{font-size:20px;font-weight:700;padding:12px 20px;border-radius:12px;border:2px solid var(--line);background:#fff;cursor:pointer}
+.tab.on{background:#1a1a1a;color:#fff;border-color:#1a1a1a}
+.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:18px 20px;margin:0 auto 16px;max-width:900px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
+.top{display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap}
+.age{font-size:15px;font-weight:700;padding:3px 11px;border-radius:99px;background:#efe9df;color:#5b5346}
+.age.old{background:#fde7e5;color:var(--old)}.age.blk{background:#f3e8ff;color:#7c3aed}.age.go{background:#e6f4ea;color:#1f8f4e}
+.tk{font-size:14px;font-weight:700;color:#8a6d3b;background:#fbf3e2;padding:2px 9px;border-radius:6px}
+.proj{font-size:13px;color:#9a8f80}
+.big{font-size:26px;font-weight:700;margin:2px 0 12px}
+.cols{display:flex;gap:18px;flex-wrap:wrap}.col{flex:1;min-width:260px}
+.lbl{font-size:15px;font-weight:800;color:#6b6257;text-transform:uppercase;letter-spacing:.3px}
+.txt{font-size:18px;margin:3px 0 0}.gd{color:#1f6b3a}.bd{color:#8a4b12}
+.raw{font-size:13px;color:#9a8f80;margin-top:8px;word-break:break-word}
+.btns{display:flex;gap:12px;margin-top:16px}
+button{font-size:20px;font-weight:700;padding:14px 20px;border:0;border-radius:13px;cursor:pointer;flex:1}
+.run{background:#1f8f4e;color:#fff}.close{background:#efe9df;color:#3a352d}
+.done{opacity:.35}.empty{text-align:center;font-size:24px;color:#6b6257;margin-top:50px}
+.bar{position:fixed;bottom:0;left:0;right:0;background:#fff;border-top:1px solid var(--line);padding:12px;text-align:center;font-size:17px}
 </style></head><body>
-<h1>☀️ Good morning, Steve</h1>
-<div class=sub>${items.length} thing${items.length===1?'':'s'} waiting for your OK. Oldest first. Tap a big button.</div>
-<div id=list>${items.map(cardHTML).join('')}</div>
-${items.length===0?'<div class=empty>🎉 Nothing waiting. The queue is clear.</div>':''}
-<div class=bar>✅ = do it &nbsp; 🗑️ = toss it &nbsp; 💤 = later &nbsp;·&nbsp; refreshes as you tap</div>
+<h1>☀️ Morning Review</h1>
+<div class=sub>Each card: <b>why it's needed</b> · <b>good reason to keep</b> vs <b>OK to close</b>. Then ▶️ run it or 🗑️ toss it.</div>
+<div class=tabs>
+  <button class="tab on" onclick="show('gated',this)">🟠 Approvals / Gated (${gated.length})</button>
+  <button class="tab" onclick="show('tasks',this)">📋 Tasks (${tasks.length})</button>
+</div>
+<div id=gated>${gated.map(card).join('') || '<div class=empty>🎉 No approvals waiting.</div>'}</div>
+<div id=tasks style=display:none>${tasks.map(card).join('') || '<div class=empty>No tasks.</div>'}</div>
+<div class=bar>▶️ opens an iTerm2 window that runs it (gates stay on) &nbsp;·&nbsp; 🗑️ moves it out of the queue</div>
 <script>
-async function act(file,action,el){el.closest('.card').classList.add('done');
- await fetch('/api/decide',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({file,action})});
- setTimeout(()=>el.closest('.card').remove(),350);}
+function show(id,el){for(const t of document.querySelectorAll('.tab'))t.classList.remove('on');el.classList.add('on');
+ document.getElementById('gated').style.display=id==='gated'?'block':'none';
+ document.getElementById('tasks').style.display=id==='tasks'?'block':'none';}
+async function run(id,kind,el){el.textContent='▶️ opening…';el.closest('.card').classList.add('done');
+ const r=await(await fetch('/api/run',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,kind})})).json();
+ el.textContent=r.note||'started';}
+async function close_(id,kind,el){el.closest('.card').classList.add('done');
+ await fetch('/api/decide',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,kind,action:'toss'})});
+ setTimeout(()=>el.closest('.card').remove(),300);}
 </script></body></html>`;
 
-function cardHTML(it) {
-  const f = it.file.replace(/'/g, "\\'");
-  return `<div class=card>
-   <div class="age ${it.aging ? 'old' : ''}">${it.aging ? '🔴 ' : '🕒 '}${it.ageWord}</div>
-   <div class=big>${it.big}</div>
-   <div class=why>${it.why}</div>
-   <div class=ttl>${it.ticket ? '['+it.ticket+'] ' : ''}${it.title.replace(/</g,'&lt;')}</div>
-   <div class=btns>
-     <button class=yes onclick="act('${f}','approve',this)">✅ Yes, do it</button>
-     <button class=toss onclick="act('${f}','toss',this)">🗑️ Toss it</button>
-     <button class=later onclick="act('${f}','later',this)">💤 Later</button>
-   </div></div>`;
-}
-
 http.createServer((req, res) => {
-  if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Morning Approvals"' }); return res.end('auth'); }
+  if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Morning Review"' }); return res.end('auth'); }
   if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
-  if (req.url === '/api/items') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(scanQueue())); }
-  if (req.url === '/api/decide' && req.method === 'POST') {
-    let b = ''; req.on('data', c => b += c); req.on('end', () => {
-      try { const { file, action } = JSON.parse(b); const r = decide(file, action);
-        res.writeHead(r.ok ? 200 : 404, { 'content-type': 'application/json' }); res.end(JSON.stringify(r)); }
-      catch (e) { res.writeHead(400); res.end('bad'); }
+  if (req.url === '/api/items') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ gated: scanQueue(), tasks: scanTasks() })); }
+  if ((req.url === '/api/run' || req.url === '/api/decide') && req.method === 'POST') {
+    let b = ''; req.on('data', c => b += c); req.on('end', async () => {
+      try {
+        const { id, kind, action } = JSON.parse(b);
+        if (req.url === '/api/decide') { const r = closeItem(id, action === 'never' ? 'never' : 'toss'); res.writeHead(r.ok ? 200 : 404, { 'content-type': 'application/json' }); return res.end(JSON.stringify(r)); }
+        // run: rebuild the item (gated from queue scan, task from tk)
+        const all = kind === 'task' ? scanTasks() : scanQueue();
+        const item = all.find(x => (x.id || x.file) === id) || { kind, id, file: id, title: id, big: '', why: '', good: '', bad: '' };
+        const r = await runItem(item);
+        res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(r));
+      } catch (e) { res.writeHead(400); res.end('bad'); }
     }); return;
   }
-  // main page — only the items needing a decision (stale ones are auto-closed by the 3am sweep)
-  const items = scanQueue().filter(i => i.disposition === 'DECIDE');
-  res.writeHead(200, { 'content-type': 'text/html' }); res.end(PAGE(items));
-}).listen(PORT, () => console.log(`[gated-morning-review] http://127.0.0.1:${PORT} (admin/DW2024!)`));
+  const gated = scanQueue().filter(i => i.disposition === 'DECIDE');
+  const tasks = scanTasks();
+  res.writeHead(200, { 'content-type': 'text/html' }); res.end(PAGE(gated, tasks));
+}).listen(PORT, () => console.log(`[morning-review] http://127.0.0.1:${PORT} (admin/DW2024!)`));

← c46c5fb fix: morning digest email via shared george-send helper (Tai  ·  back to Gated Morning Review  ·  viewer: honest Run-button note — queues to _approved + loop bc59465 →