[object Object]

← back to Gated Morning Review

gated-morning-review: 3am stale-sweep steward + big-font plain-language morning approval viewer

727354d7470327e2b8578dfd062fbbd7d99d69b8 · 2026-08-19 23:36:20 -0700 · steve

Files touched

Diff

commit 727354d7470327e2b8578dfd062fbbd7d99d69b8
Author: steve <steve@designerwallcoverings.com>
Date:   Wed Aug 19 23:36:20 2026 -0700

    gated-morning-review: 3am stale-sweep steward + big-font plain-language morning approval viewer
---
 .gitignore   |  8 +++++
 lib.mjs      | 76 +++++++++++++++++++++++++++++++++++++++++++++++
 package.json | 11 +++++++
 server.mjs   | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 sweep.mjs    | 56 +++++++++++++++++++++++++++++++++++
 5 files changed, 247 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..40cdc6d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+data/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
diff --git a/lib.mjs b/lib.mjs
new file mode 100644
index 0000000..e8a4acf
--- /dev/null
+++ b/lib.mjs
@@ -0,0 +1,76 @@
+// Shared scan + classify + plain-language logic for the gated-queue morning steward.
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+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")
+
+const readHead = (p, n = 45) => {
+  try { return fs.readFileSync(p, 'utf8').split('\n').slice(0, n).join('\n'); }
+  catch { return ''; }
+};
+
+function firstTitle(head, file) {
+  for (const line of head.split('\n')) {
+    const t = line.replace(/^#+\s*/, '').replace(/^>+\s*/, '').replace(/^\*+\s*/, '').trim();
+    if (t && !/^[-=]{3,}/.test(t) && t !== '{') return t.slice(0, 150);
+  }
+  return file.replace(/[-_]/g, ' ').replace(/\.(md|csv|json|patch)$/, '');
+}
+
+// Map a gated item to a PLAIN-LANGUAGE bucket a non-technical person understands.
+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 };
+}
+
+export function scanQueue() {
+  let files = [];
+  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);
+    let st; try { st = fs.statSync(full); } catch { return null; }
+    const age = Math.floor((now - st.mtimeMs) / 86400000);
+    const head = readHead(full);
+    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
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3f9cdec
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "gated-morning-review",
+  "version": "1.0.0",
+  "description": "3am steward that keeps the gated-approval queue young + a big-font plain-language morning approval viewer",
+  "type": "module",
+  "scripts": {
+    "start": "node server.mjs",
+    "sweep": "node sweep.mjs"
+  },
+  "private": true
+}
diff --git a/server.mjs b/server.mjs
new file mode 100644
index 0000000..78163ce
--- /dev/null
+++ b/server.mjs
@@ -0,0 +1,96 @@
+// Big-font, plain-language morning approval viewer for the gated queue.
+// Zero-dependency (pure node http). Basic Auth admin/DW2024!.
+import http from 'http';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { scanQueue, QDIR } from './lib.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PORT = process.env.PORT || 9793;
+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 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(' ');
+  if (!b64) return false;
+  const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
+  return u === USER && p === PASS;
+}
+
+function decide(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');
+  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>
+: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}
+</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>
+<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);}
+</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 (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'); }
+    }); 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!)`));
diff --git a/sweep.mjs b/sweep.mjs
new file mode 100644
index 0000000..3ab84fa
--- /dev/null
+++ b/sweep.mjs
@@ -0,0 +1,56 @@
+// 3am daily steward: auto-close stale gated items, keep the queue young, write the morning digest.
+// Never lets a gated item rot silently: stale/superseded -> closed; the rest surfaced in the viewer.
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import { fileURLToPath } from 'url';
+import { scanQueue, QDIR, STALE_DAYS, AGING_DAYS } from './lib.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const DATA = path.join(__dirname, 'data'); fs.mkdirSync(DATA, { recursive: true });
+const VIEWER = process.env.VIEWER_URL || `http://127.0.0.1:${process.env.PORT || 9793}`;
+const ts = new Date().toISOString();
+
+const items = scanQueue();
+const stale = items.filter(i => i.disposition === 'STALE');
+const decide = items.filter(i => i.disposition === 'DECIDE');
+
+// AUTO-CLOSE the stale ones (reversible file moves — recoverable from _done/_never).
+const doneDir = path.join(QDIR, '_done'); const neverDir = path.join(QDIR, '_never');
+fs.mkdirSync(doneDir, { recursive: true }); fs.mkdirSync(neverDir, { recursive: true });
+let closed = 0;
+for (const it of stale) {
+  const declined = /declined|no action needed|blocked-kill|do not/i.test(it.title);
+  const dest = declined ? neverDir : doneDir;
+  try { fs.renameSync(path.join(QDIR, it.file), path.join(dest, it.file)); closed++; } catch {}
+}
+
+const oldest = decide[0];
+const digest = {
+  ts, viewer: VIEWER,
+  total_before: items.length, auto_closed: closed,
+  awaiting_decision: decide.length,
+  aging_count: decide.filter(d => d.aging).length,
+  oldest_days: oldest ? oldest.age : 0,
+  by_category: decide.reduce((m, d) => (m[d.category] = (m[d.category] || 0) + 1, m), {}),
+  items: decide.map(d => ({ file: d.file, ticket: d.ticket, age: d.age, big: d.big, title: d.title })),
+};
+fs.writeFileSync(path.join(DATA, 'digest.json'), JSON.stringify(digest, null, 2));
+fs.writeFileSync(path.join(DATA, 'latest.json'), JSON.stringify({
+  verdict: digest.aging_count > 0 ? 'WARN' : 'PASS', status: digest.aging_count > 0 ? 'WARN' : 'PASS',
+  ts, awaiting: decide.length, auto_closed: closed, aging: digest.aging_count, oldest_days: digest.oldest_days,
+}, null, 2));
+
+console.log(`[gated-morning-sweep] ${ts}: auto-closed ${closed} stale, ${decide.length} await decision (${digest.aging_count} aging >${AGING_DAYS}d, oldest ${digest.oldest_days}d).`);
+
+// Optional: email Steve the morning link via George (best-effort; skips silently if unreachable).
+try {
+  const body = `Good morning Steve — your gated-approval queue for ${ts.slice(0,10)}:\n\n` +
+    `• ${decide.length} waiting for your OK  (${digest.aging_count} getting old, oldest ${digest.oldest_days} days)\n` +
+    `• ${closed} stale ones auto-closed overnight\n\n` +
+    `Approve them in big buttons here when you reach your desk:\n${VIEWER}\n\n(admin / DW2024!)`;
+  await fetch('http://127.0.0.1:9850/api/send', {
+    method: 'POST', headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ to: 'steve@designerwallcoverings.com', subject: `☀️ ${decide.length} approvals waiting (${digest.aging_count} aging)`, text: body }),
+  }).then(r => console.log('[email]', r.status)).catch(() => {});
+} catch {}

(oldest)  ·  back to Gated Morning Review  ·  move viewer off clashing 9793 to free port + bake PORT into 95b1ae5 →