← back to Gated Morning Review

server.mjs

156 lines

// 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, execFileSync } from 'child_process';
import { fileURLToPath } from 'url';
import { scanQueue, scanTasks, gatedTicketNums, QDIR } from './lib.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
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 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 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 closeItem(id, action, kind) {
  // A TASK is a tk ticket, not a file in the queue — close it by marking the ticket done
  // (this is why the Close button did nothing on the Tasks tab: there was no file to move).
  if (kind === 'task') {
    try {
      execFileSync('tk', ['done', id], { env: { ...process.env, TK_AGENT: 'morning-viewer' }, timeout: 8000, stdio: 'ignore' });
      fs.appendFileSync(DECIS, JSON.stringify({ ts: new Date().toISOString(), file: id, action: 'tk-done' }) + '\n');
      return { ok: true };
    } catch (e) { return { ok: false, err: String(e.message).slice(0, 80) }; }
  }
  const src = path.join(QDIR, id);
  if (!fs.existsSync(src)) return { ok: false, err: 'gone' };
  const dir = action === 'never' ? '_never' : '_done';
  fs.renameSync(src, path.join(QDIR, dir, id));
  fs.appendFileSync(DECIS, JSON.stringify({ ts: new Date().toISOString(), file: id, action: 'close-' + dir }) + '\n');
  return { ok: true };
}

// 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) => {
    // Best-effort iTerm2 spawn (unreliable from a background server). Either way the item is
    // now in _approved/ + logged, and the yoloforever loop executes _approved items on its
    // heartbeat via a gated agent — so the run happens reliably even if no window pops.
    execFile('bash', [FANOUT, `Approved-run ${header}`, '--cwd', os.homedir(), `${header} :: ${prompt}`],
      { timeout: 20000 }, () => resolve({ ok: true, note: '✅ Approved — running shortly (the loop picks it up; a window may also open)' }));
  });
}

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.siblingCount ? `<span class=sib>· step ${it.siblingIndex} of ${it.siblingCount}</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 class="col sugcol"><div class=lbl>🤔 What I'd do</div><div class="txt sg">${it.suggest||''}</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 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}
.sib{font-size:13px;font-weight:700;color:#8a6d3b}
.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}
.sugcol{background:#fff8e6;border:2px solid #f0d98a;border-radius:12px;padding:12px 14px;min-width:230px}
.sg{font-size:19px;font-weight:700;color:#5b4a12}
.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>☀️ 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>
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>`;

http.createServer((req, res) => {
  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({ gated: scanQueue(), tasks: scanTasks(60, gatedTicketNums()) })); }
  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', kind); 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;
  }
  const gated = scanQueue().filter(i => i.disposition === 'DECIDE');
  const tasks = scanTasks(60, gatedTicketNums());
  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!)`));