← back to Gated Morning Review
lib.mjs
182 lines
// Shared scan + classify + plain-language logic for the gated-queue morning steward.
import fs from 'fs';
import path from 'path';
import os from 'os';
import { execFileSync } from 'child_process';
// TESTABILITY SEAM (TK-11801, CLAUDE.md TK-11431 amendment 3): GMR_QDIR points the
// scan at a throwaway fixture dir; GMR_TK_STATUS_JSON supplies ticket statuses instead
// of shelling `tk`. The launchd plist MUST NEVER set these — production uses the reals.
export const QDIR = process.env.GMR_QDIR || path.join(os.homedir(), '.claude/yolo-queue/pending-approval');
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'); }
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)$/, '');
}
// 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.",
sug: "🙋 YOU do this one — it goes LIVE to everyone. Make sure it's really ready first." },
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.",
sug: "🙋 Needs YOU — it's real money. Only run it if the numbers are fresh." },
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.",
sug: "✅ Usually safe to run — it's a small store fix you can undo." },
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.",
sug: "🙋 Your hands — it touches servers or passwords. Safe, but needs you." },
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.",
sug: "📣 Only if you're sure — a public post is hard to un-post." },
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.",
sug: "✉️ Only if it's right — you can't unsend an email." },
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.",
sug: "🤔 Just pick yes or no — it's a choice, not a chore." },
};
// TK-11801 fix: a whole-memo, explicit done-marker (the strict rule prune.py uses).
// This is the ONLY prose that may auto-close a memo whose ticket is still open — a
// deliberate STATUS line, not an incidental word in the body.
export function hasExplicitDoneMarker(head) {
return /^\s*(?:>?\s*\*{0,2})?\s*STATUS:\s*(DONE|APPLIED|COMPLETE|COMPLETED|RESOLVED|EXECUTED)\b/im.test(head || '');
}
// TK-11801 fix: map of numeric TK id -> board status, parsed from `tk list`.
// Tickets absent from the open board are treated as not-open (closed/unknown).
export function openTicketStatuses() {
const map = new Map();
if (process.env.GMR_TK_STATUS_JSON) { // test seam: statuses injected, no `tk` shell-out
try { for (const [k, v] of Object.entries(JSON.parse(process.env.GMR_TK_STATUS_JSON))) map.set(k, v); } catch {}
return map;
}
let out = '';
try { out = execFileSync('tk', ['list'], { encoding: 'utf8', timeout: 8000, env: { ...process.env, TK_AGENT: 'morning-viewer' } }); }
catch { return map; }
for (const line of out.split('\n')) {
const m = line.match(/^(TK-[\w-]+)\s+\[(\w+)\]/);
if (!m) continue;
const n = (m[1].match(/TK-\d+/) || [])[0];
if (n && !map.has(n)) map.set(n, m[2]);
}
return map;
}
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|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, sug: c.sug, 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();
const tkStatus = openTicketStatuses(); // TK-11801: know each memo's ticket status
const out = 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);
const aging = age >= AGING_DAYS;
// TK-11801 fix: never auto-close on prose (staleSig) a memo whose ticket is
// still OPEN ([blocked]/[doing]) — unless it carries an explicit whole-memo
// STATUS: DONE marker. Age>25d no longer means _done/: it means HELD (→_held/,
// still visible to the approval-invisibility canary + /ungate). Holding != approving.
const tstat = ticket ? (tkStatus.get(ticket) || '') : '';
const openTicket = tstat === 'blocked' || tstat === 'doing';
const staleClose = c.staleSig && (hasExplicitDoneMarker(head) || !openTicket);
const disposition = staleClose ? 'STALE' : (age > STALE_DAYS ? 'HELD' : 'DECIDE');
// 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;
// my suggestion, in plain kid words — with an old/stale overlay
const suggest = staleClose ? "🗑️ Probably already done — safe to toss."
: aging ? "⚠️ It's OLD — first make sure the info is still true, THEN " + c.sug.replace(/^..? /, "").toLowerCase()
: c.sug;
return { kind: 'gated', id: f, file: f, title, ticket, ticketStatus: tstat, openTicket, age,
ageWord: age <= 0 ? 'today' : age === 1 ? '1 day old' : `${age} days old`,
...c, bad, suggest, aging, disposition };
}).filter(Boolean).sort((a, b) => b.age - a.age);
// Sibling info: when several memo files share one ticket they are steps of ONE ticket,
// not accidental dups — tag "N of M" so the viewer reads them as intentional.
const tkCount = {};
for (const it of out) if (it.ticket) tkCount[it.ticket] = (tkCount[it.ticket] || 0) + 1;
const tkSeen = {};
for (const it of out) if (it.ticket && tkCount[it.ticket] > 1) {
it.siblingCount = tkCount[it.ticket];
it.siblingIndex = (tkSeen[it.ticket] = (tkSeen[it.ticket] || 0) + 1);
}
return out;
}
// The numeric TK ids (e.g. "TK-10488") that already have a gated memo file in the queue.
// A tk task whose number is here is the SAME work as its memo card — dup, hide it.
export function gatedTicketNums() {
return new Set(scanQueue().map(x => x.ticket).filter(Boolean));
}
// tk task backlog — parsed from `tk list`.
// excludeTkNums: numeric TK ids that already have a gated memo card — those tasks are
// the same work shown twice, so we drop them from this viewer (they stay on the tk board).
export function scanTasks(limit = 60, excludeTkNums = null) {
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;
if (excludeTkNums) { const n = (id.match(/TK-\d+/) || [])[0]; if (n && excludeTkNums.has(n)) continue; }
const c = classify(title, '', id);
const suggest = status === 'blocked' ? "⏳ Stuck waiting on something else — can't run it yet."
: status === 'doing' ? "⚙️ A robot's already working on it — leave it alone."
: c.sug;
items.push({ kind: 'task', id, status, owner, project, title: title.slice(0, 140),
...c, suggest, blocked: status === 'blocked', doing: status === 'doing' });
if (items.length >= limit) break;
}
return items;
}