← back to Gated Queue Runner

server.js

258 lines

#!/usr/bin/env node
// Gated-queue viewer — reads ~/.claude/yolo-queue/pending-approval, explains each gate "like a 6-year-old".
// Zero-dependency (built-in http/fs only). Basic auth admin/DW2024!.
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');

const DIR = path.join(os.homedir(), '.claude/yolo-queue/pending-approval');
const PORT = parseInt(fs.readFileSync(path.join(__dirname, '.port'), 'utf8').trim() || '9801', 10);
const USER = 'admin', PASS = 'DW2024!';

// ---- category + kid-note logic ----
// order matters — first match wins; specific categories before the broad "spend" catch.
const CATS = [
  { key: 'blocked', emoji: '🔒', label: 'Waiting on you',  rx: /blocked on (steve|scope|grant|write_themes|iam)|needs your (grant|one-time|login)|iam grant|only you can/i,
    yes: 'This one is WAITING for you to unlock something first (a password or a permission).' },
  { key: 'send',    emoji: '✉️', label: 'Sends messages', rx: /send-to-list|mailer|email blast|\bsend\b .*(list|vendor|customer)|george.*send|\bsms\b|push notif|draft.*email/i,
    yes: 'If you say YES, it sends a real email or message to real people.' },
  { key: 'dns',     emoji: '🌐', label: 'Website address', rx: /\bdns\b|a-?record|cloudflare zone|nameserver|repoint|subdomain|expose.*\.com/i,
    yes: 'If you say YES, it changes where a website address sends people.' },
  { key: 'catalog', emoji: '📦', label: 'Product list',    rx: /catalog stage|_catalog\b|dw_unified.*(write|stage)|price (list|refresh|change)|repric|pricing|MAP\b/i,
    yes: 'If you say YES, it changes our big list of products (like prices or names).' },
  { key: 'shopify', emoji: '🛍️', label: 'Store change',    rx: /shopify|storefront|online store|collection|activate|draft-down|\bsku\b|variant|metafield|product (write|create|publish)/i,
    yes: 'If you say YES, it changes what shoppers see in the store.' },
  { key: 'ga4',     emoji: '📊', label: 'Visitor counter', rx: /\bga4\b|analytics|gtag|pixel|\bgtm\b/i,
    yes: 'If you say YES, it turns on the little counter that shows how many people visit.' },
  { key: 'deploy',  emoji: '🚀', label: 'Put on internet', rx: /deploy|kamatera|pm2 (restart|reload)|go-?live|rebuild|ship it|publish to|nginx/i,
    yes: 'If you say YES, it puts a website change out on the internet for everyone to see.' },
  { key: 'spend',   emoji: '💵', label: 'Spends money',   rx: /spend (real )?money|budget cap|will cost \$[1-9]|\$[1-9][0-9.]*\s*\/\s*mo|monthly \$|subscription|paid (api|plan|tier)|purchase order/i,
    yes: 'If you say YES, it spends real money.' },
];
const OTHER = { key: 'other', emoji: '📄', label: 'Needs your OK', yes: 'If you say YES, it makes a change that needs your OK first.' };

function categorize(body) {
  for (const c of CATS) if (c.rx.test(body)) return c;
  return OTHER;
}
function cleanTitle(firstLine, fname) {
  // filename-based is the most predictable source; normalize then strip noise tokens.
  let t = fname.replace(/\.(md|csv)$/, '').replace(/[-_]/g, ' ');
  t = t.replace(/\b20\d\d\s+\d\d\s+\d\d\b/g, '')          // 2026 08 18
       .replace(/\b20\d\d-\d\d-\d\d\b/g, '')
       .replace(/\b\d{8}\b/g, '').replace(/\b\d{6}\b/g, '') // 20260818 / 070926-style stamps
       .replace(/\bDRYRUN\b/gi, '')
       .replace(/\bTK\s*-?\s*\d+\b/gi, '')
       .replace(/\b(GATED|READY|DRAFT|DONE|PENDING|APPROVAL|VERIFIED|GO ?LIVE|GATE|memo|publish held|held)\b/gi, '')
       .replace(/\s{2,}/g, ' ').trim();
  if (!t) t = fname.replace(/\.(md|csv)$/, '').replace(/[-_]/g, ' ');
  // simplify a few jargon words
  const swap = { 'reconcile':'match up', 'remediation':'cleanup', 'canary':'watcher', 'metafield':'label',
    'orphan':'lost', 'backfill':'fill in', 'onboard':'add', 'scrape':'collect', 'reprice':'change prices',
    'redirect':'forward', 'provision':'set up', 'miswire':'wrong wiring', 'dedup':'remove doubles' };
  for (const [k, v] of Object.entries(swap)) t = t.replace(new RegExp('\\b' + k + '\\w*', 'gi'), v);
  return t.charAt(0).toUpperCase() + t.slice(1);
}
const JARGON = { 'reconcile':'match up','remediation':'cleanup','canary':'watcher','metafield':'label',
  'orphan':'lost','backfill':'fill in','onboard':'add','scrape':'collect','reprice':'change the prices of',
  'redirect':'forward','provision':'set up','miswire':'wrong wiring','dedup':'remove doubles',
  'deploy':'put online','rsync':'copy up','pm2':'the site','nginx':'the web server','sku':'product code',
  'catalog':'product list','variant':'version','storefront':'store','gtag':'visitor counter',
  'ga4':'visitor counter','shopify':'the store','kamatera':'the server','dns':'web address',
  'private-label':'our own brand name','leak':'a hidden name showing','activation':'turning on',
  'gated':'needs your OK','wholesale':'our cost','retail':'the price shoppers pay' };
function simplify(s) {
  for (const [k, v] of Object.entries(JARGON)) s = s.replace(new RegExp('\\b' + k + '\\w*', 'gi'), v);
  return s;
}
function extractSummary(body) {
  const lines = body.split('\n');
  for (let raw of lines) {
    const l = raw.trim();
    if (!l) continue;
    if (/^#{1,6}\s/.test(l)) continue;                     // heading
    if (/^[>|\-*]|^```|^\||^\d+\.\s/.test(l)) continue;     // quote/table/list/code
    if (/^\*\*(date|ticket|agent|status|cost|store|api|mode|owner|by|drafted|supersedes|context)\b/i.test(l)) continue;
    const prose = l.replace(/\*\*/g, '').replace(/[`*_#>]/g, '').trim();
    if (prose.length < 40) continue;
    // quality gate: must read like a sentence, not a CSV header / code / path
    const words = prose.split(/\s+/);
    const commas = (prose.match(/,/g) || []).length;
    const nonAlpha = (prose.replace(/[a-z0-9\s.,'"-]/gi, '').length) / prose.length;
    if (words.length < 6) continue;                        // too few words = not prose
    if (commas >= 4 && !/ and | or |, /.test(prose)) continue; // CSV-ish
    if (nonAlpha > 0.18) continue;                         // symbol/path/code heavy
    if (/[\/\\]{1}\w+\.\w|http|=>|\bSELECT\b|\{|\}/.test(prose)) continue; // code/path
    if (!/[a-z]/i.test(prose[0])) continue;
    let s = prose.split(/(?<=[.!?])\s/)[0];                // first sentence
    if (s.length > 165) s = s.slice(0, 162) + '…';
    return simplify(s);
  }
  return null;
}
function aboutText(cat, title, body) {
  const sum = extractSummary(body);
  return sum || simplify(title);
}

// ---- ranking + ratings ----
// Turn a memo's own signals into 4 human ratings (0-5) + a composite priority,
// so the "say YES" queue surfaces what matters instead of a flat date list.
function parseMaxDollars(body) {
  let max = 0;
  const rx = /\$\s?([\d,]+(?:\.\d+)?)\s*(k|thousand|m|mm|million|billion|b)?/gi;
  let m;
  while ((m = rx.exec(body))) {
    let n = parseFloat(m[1].replace(/,/g, '')); if (isNaN(n)) continue;
    const u = (m[2] || '').toLowerCase();
    if (u === 'k' || u === 'thousand') n *= 1e3;
    else if (u.startsWith('m')) n *= 1e6;
    else if (u.startsWith('b')) n *= 1e9;
    if (n > max) max = n;
  }
  return max;
}
function nearestDeadlineDays(body) {
  const now = Date.now();
  let best = null;
  // ISO dates + "Mon DD, YYYY" + explicit "deadline: <date>"
  const iso = body.match(/\b20\d\d-\d\d-\d\d\b/g) || [];
  const named = body.match(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+20\d\d\b/gi) || [];
  for (const s of [...iso, ...named]) {
    const t = Date.parse(s); if (isNaN(t)) continue;
    const d = Math.round((t - now) / 86400000);
    if (d >= -3 && (best === null || d < best)) best = d; // future/just-past deadlines
  }
  if (/\b(today|due today|expires? today)\b/i.test(body)) best = best === null ? 0 : Math.min(best, 0);
  return best;
}
function scoreGate(body, cat, size, title) {
  const b = body || '';
  const money = parseMaxDollars(b);
  const days = nearestDeadlineDays(b);
  const catWeight = { spend: 5, send: 5, dns: 4, deploy: 4, catalog: 4, shopify: 3, ga4: 2, blocked: 3, other: 2 }[cat.key] || 2;

  // 💰 value: dollars (log) OR category stakes, whichever higher
  let value = money >= 1e5 ? 5 : money >= 1e4 ? 4 : money >= 1e3 ? 3 : money >= 100 ? 2 : money > 0 ? 1 : 0;
  value = Math.max(value, catWeight >= 4 ? 3 : 2);
  if (/\burgent|five[- ]figure|revenue|money (owed|left)|lapse|expire/i.test(b)) value = Math.min(5, value + 1);

  // ⏰ urgency: nearest deadline + urgent words
  let urgency = 1;
  if (days !== null) urgency = days <= 1 ? 5 : days <= 3 ? 4 : days <= 7 ? 3 : days <= 30 ? 2 : 1;
  if (/\burgent|lapsing|due today|expires? (today|tomorrow)|deadline/i.test(b)) urgency = Math.min(5, urgency + 1);

  // ⚡ ease (higher = quicker/lower-friction to say yes)
  let ease = 3;
  if (/\b(reversible|one[- ]click|1[- ]click|toggle|paste|30[- ]?sec|quick|small|single|read-only)\b/i.test(b)) ease += 1;
  if (/\b(build|migration|scrape|onboard|rebuild|multi-part|large|backfill|thousands|batch of)\b/i.test(b) || size > 60000) ease -= 1;
  if (size < 2500) ease += 1;
  ease = Math.max(1, Math.min(5, ease));

  // ✅ safety/confidence (higher = safer / more reversible)
  let safety = 3;
  if (/\b(reversible|restore[- ]map|verified|snapshot|dry[- ]?run|git revert|rollback)\b/i.test(b)) safety += 1;
  if (/\b(destructive|irreversible|delete|purge|history rewrite|filter-repo|drop |unpublish|cannot be undone)\b/i.test(b)) safety -= 2;
  if (cat.key === 'send' || cat.key === 'dns' || cat.key === 'spend') safety -= 1;
  safety = Math.max(1, Math.min(5, safety));

  // composite: value + urgency dominate; ease nudges; low safety slightly demotes auto-priority
  const priority = Math.round((value * 2.4 + urgency * 2.4 + ease * 1.0 + safety * 0.4) * 10) / 10;
  const tier = priority >= 26 ? 'high' : priority >= 18 ? 'med' : 'low';
  return { value, urgency, ease, safety, priority, tier, money, days };
}

function listGates() {
  let files = [];
  try { files = fs.readdirSync(DIR).filter(f => !f.startsWith('_') && (f.endsWith('.md') || f.endsWith('.csv'))); }
  catch (e) { return []; }
  return files.map(f => {
    const p = path.join(DIR, f);
    let body = '', first = '';
    try { body = fs.readFileSync(p, 'utf8'); } catch {}
    first = (body.split('\n').find(l => l.trim()) || '').trim();
    const st = fs.statSync(p);
    const cat = categorize(body);
    const title = cleanTitle(first, f);
    const about = aboutText(cat, title, body);
    const s = scoreGate(body, cat, st.size, title);
    return {
      file: f, title, category: cat.key, catLabel: cat.label, emoji: cat.emoji,
      about, effect: cat.yes, note: `This is about: ${about} ${cat.yes}`,
      created: st.mtimeMs, size: st.size,
      // ranking + ratings
      priority: s.priority, tier: s.tier,
      ratings: { value: s.value, urgency: s.urgency, ease: s.ease, safety: s.safety },
      money: s.money, days: s.days,
    };
  }).sort((a, b) => b.priority - a.priority || b.created - a.created)
    .map((g, i) => ({ ...g, rank: i + 1 }));
}

const server = http.createServer((req, res) => {
  // basic auth
  const hdr = req.headers.authorization || '';
  const [u, pw] = Buffer.from(hdr.split(' ')[1] || '', 'base64').toString().split(':');
  if (u !== USER || pw !== PASS) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="gates"' }); return res.end('auth');
  }
  if (req.url.startsWith('/api/gates')) {
    const gates = listGates();
    const counts = {};
    gates.forEach(g => counts[g.category] = (counts[g.category] || 0) + 1);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ total: gates.length, counts, gates }));
  }
  if (req.url === '/api/select' && req.method === 'POST') {
    let b = ''; req.on('data', c => b += c); req.on('end', () => {
      let files = [];
      try { files = JSON.parse(b).files || []; } catch {}
      const out = { savedAt: new Date().toISOString(), count: files.length, files };
      fs.writeFileSync(path.join(__dirname, 'selected.json'), JSON.stringify(out, null, 2));
      res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, count: files.length }));
    });
    return;
  }
  // --- action endpoints: allow (greenlight→_approved), never (archive→_never), run (iTerm2 per item) ---
  if ((req.url === '/api/allow' || req.url === '/api/never' || req.url === '/api/run') && req.method === 'POST') {
    const kind = req.url.split('/')[2];
    let b = ''; req.on('data', c => b += c); req.on('end', () => {
      let files = [];
      try { files = (JSON.parse(b).files || []).map(f => path.basename(f)); } catch {}
      let done = 0;
      if (kind === 'run') {
        for (const f of files) {
          const abs = path.join(DIR, f);
          if (!fs.existsSync(abs)) continue;
          const prompt = ('Steve approved working this gated queue item via the queue viewer. FIRST verify the current state — it may already be done; if so, confirm and stop. Otherwise do the REVERSIBLE parts end-to-end, but DRAFT any hard-gated externality (spend, send-to-list, DNS, publish, prod deploy, remote push) to ~/.claude/yolo-queue/pending-approval/ instead of firing it. Show $ costs, ledger reversible work, verify, and report. Memo: ' + abs).replace(/["\\]/g, '\\$&');
          const osa = ['tell application "iTerm2"','activate','create window with default profile',
            'tell current session of current window','write text "claude \\"' + prompt + '\\""','end tell','end tell'].join('\n');
          try { execFile('osascript', ['-e', osa], () => {}); done++; } catch {}
        }
      } else {
        const sub = kind === 'allow' ? '_approved' : '_never';
        const dest = path.join(DIR, sub); try { fs.mkdirSync(dest, { recursive: true }); } catch {}
        for (const f of files) {
          const src = path.join(DIR, f);
          if (fs.existsSync(src) && src.startsWith(DIR)) { try { fs.renameSync(src, path.join(dest, f)); done++; } catch {} }
        }
      }
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ ok: true, kind, done }));
    });
    return;
  }
  if (req.url.startsWith('/api/memo')) {
    const f = decodeURIComponent((req.url.split('?f=')[1] || ''));
    const p = path.join(DIR, path.basename(f));
    if (!p.startsWith(DIR) || !fs.existsSync(p)) { res.writeHead(404); return res.end('no'); }
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    return res.end(fs.readFileSync(p, 'utf8'));
  }
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end(fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'));
});
server.listen(PORT, () => console.log(`gated-queue viewer on http://127.0.0.1:${PORT} (admin/DW2024!)`));