← 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;
}