← 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!)`));