← back to Gated Morning Review
sweep.mjs
74 lines
// 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 held = items.filter(i => i.disposition === 'HELD'); // TK-11801: age>25d, NOT approved
const decide = items.filter(i => i.disposition === 'DECIDE');
// AUTO-CLOSE the stale ones (reversible file moves — recoverable from _done/_never).
// TK-11801: HELD (merely OLD) goes to _held/, which the approval-invisibility canary
// and /ungate still read as unresolved — holding is not approving. Every move is
// logged to data/decisions.jsonl with the ticket status so a mis-close is visible.
const doneDir = path.join(QDIR, '_done'); const neverDir = path.join(QDIR, '_never');
const heldDir = path.join(QDIR, '_held');
fs.mkdirSync(doneDir, { recursive: true }); fs.mkdirSync(neverDir, { recursive: true }); fs.mkdirSync(heldDir, { recursive: true });
const decisionsLog = path.join(DATA, 'decisions.jsonl');
const logMove = (it, dest, reason) => {
try { fs.appendFileSync(decisionsLog, JSON.stringify({ ts, file: it.file, ticket: it.ticket, ticket_status: it.ticketStatus || '', disposition: it.disposition, dest: path.basename(dest), reason }) + '\n'); } catch {}
};
let closed = 0, heldCount = 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++; logMove(it, dest, declined ? 'declined' : 'staleSig + ticket not open'); } catch {}
}
for (const it of held) {
try { fs.renameSync(path.join(QDIR, it.file), path.join(heldDir, it.file)); heldCount++; logMove(it, heldDir, `age ${it.age}d > ${STALE_DAYS}d (held, not approved)`); } catch {}
}
const oldest = decide[0];
const digest = {
ts, viewer: VIEWER,
total_before: items.length, auto_closed: closed, held: heldCount,
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 })),
// TK-11801: surface WHAT got auto-moved so a mis-close is visible the same morning
auto_closed_list: stale.map(d => ({ file: d.file, ticket: d.ticket, ticket_status: d.ticketStatus || '', reason: 'staleSig' })),
held_list: held.map(d => ({ file: d.file, ticket: d.ticket, ticket_status: d.ticketStatus || '', age: d.age })),
};
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, held: heldCount, aging: digest.aging_count, oldest_days: digest.oldest_days,
}, null, 2));
console.log(`[gated-morning-sweep] ${ts}: auto-closed ${closed} stale, held ${heldCount} old, ${decide.length} await decision (${digest.aging_count} aging >${AGING_DAYS}d, oldest ${digest.oldest_days}d).`);
// Email Steve the morning link via the shared George helper — the SINGLE correct path
// (George is behind Tailscale-HTTPS + Basic auth; a raw http://127.0.0.1:9850 POST 401s).
try {
const { execFileSync } = await import('child_process');
const subject = `☀️ ${decide.length} approvals waiting (${digest.aging_count} aging)`;
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!)`;
const script = '. "$HOME/.claude/skills/_shared/george-send.sh"; george_send steve-office "steve@designerwallcoverings.com" "$1" "$2"';
const out = execFileSync('bash', ['-c', script, 'bash', subject, body], { encoding: 'utf8', timeout: 30000 });
console.log('[email]', out.includes('"success":true') ? 'sent ✓' : out.slice(0,140));
} catch (e) { console.log('[email] failed:', String(e.message).slice(0,140)); }