← back to Sample Followup Sweep

scripts/coverage-reconcile.mjs

131 lines

#!/usr/bin/env node
// TK-12117 — COVERAGE-RECONCILIATION CANARY (READ-ONLY).
// Steve's false-green doctrine: never PASS on "it ran" — assert coverage against an INDEPENDENT
// source. This queries LIVE FileMaker for the true outstanding population in the active window and
// reconciles it against the sweep's own bucketing (lib/sweep.js reconcileCoverage). A row that is
// outstanding in FileMaker but would appear in NO sweep bucket = LEAKED (silent skip) = FAIL.
//   PASS  = population fully accounted AND every outstanding row in an ACTIONED bucket + run fresh
//   WARN  = uncovered-but-accounted rows (no-vendor-map / needs-confirm) > 0, OR the Tue-Fri run missed
//   FAIL  = FM unreadable / reconciliation broke / leaked rows
// Writes data/latest.json every run (fleet-health-rollup vocab: top-level verdict AND status).
// Usage:
//   node scripts/coverage-reconcile.mjs           # live run
//   node scripts/coverage-reconcile.mjs --self-test   # injected-leak fixture must go FAIL (never in the plist)
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from 'node:fs';
import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module'; import http from 'node:http';
const require = createRequire(import.meta.url);
const __dir = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dir, '..');
const { reconcileCoverage, windowCoversActiveBand } = require(join(ROOT, 'lib', 'sweep.js'));

const SELF_TEST = process.argv.includes('--self-test');
const MIN_AGE = 10, MAX_AGE = Number(process.env.MAX_AGE) || 60;
const DATA_DIR = process.env.COVERAGE_DATA_DIR || join(ROOT, 'data', 'coverage-reconcile');
const pad = (n) => String(n).padStart(2, '0');
const fmt = (d) => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`;
const readJSON = (f, d) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return d; } };

function writeLatest(obj) {
  mkdirSync(DATA_DIR, { recursive: true });
  writeFileSync(join(DATA_DIR, 'latest.json'), JSON.stringify({ ...obj, ts: new Date().toISOString(), skill: 'sample-followup-coverage-reconcile' }, null, 2));
}

// --- SELF-TEST seam (guarded; the launchd plist must NEVER pass --self-test) ---
if (SELF_TEST) {
  const contacts = { fabr: { sample_email: 'customers@fabricut.com', account_number: 'On File' } };
  const row = (vid, sku) => ({ fieldData: { vid, 'combo sku': sku, 'today for client': '09/10/2026', 'Date WP Sample Sent': '', 'Date Sample Request Letter Sent': '', 'Mfr Pattern': 'P' } });
  const r = reconcileCoverage([row('FABR', 'A'), row('', 'LEAK')], { contacts });
  console.log(`[SELF-TEST] injected empty-vid leak -> verdict=${r.verdict} leaked=${r.leaked.length} (expect FAIL/>=1)`);
  process.exit(r.verdict === 'FAIL' && r.leaked.length >= 1 ? 0 : 1);
}

// --- recently-emailed set: recently-contacted.json (<=14d) + best-effort live Gmail (read-only) ---
function recentSet() {
  const set = new Set();
  const raw = readJSON(join(ROOT, 'data', 'recently-contacted.json'), {});
  const cutoff = new Date(Date.now() - 14 * 864e5);
  if (!Array.isArray(raw)) for (const [addr, when] of Object.entries(raw)) { const d = new Date(when); if (!isNaN(d) && d >= cutoff) set.add(String(addr).toLowerCase().trim()); }
  return set;
}
async function mergeGmailRecent(set) {
  try {
    const g = (k) => { for (const f of ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-MCP/.env', '/Users/macstudio3/Projects/george-gmail/.env']) { try { const m = readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {} } return ''; };
    let auth = g('GEORGE_BASIC_AUTH'); if (!auth.includes(':')) auth = 'admin:' + g('GEORGE_BASIC_AUTH_PASS');
    auth = 'Basic ' + Buffer.from(auth).toString('base64');
    const q = encodeURIComponent('in:sent subject:"Sample Follow-Up — Outstanding Memos" -subject:"Re:" newer_than:14d');
    await new Promise((res) => { const req = http.request({ host: '127.0.0.1', port: 9850, path: `/api/messages?account=info&maxResults=100&q=${q}`, method: 'GET', headers: { Authorization: auth } }, (r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => { try { const j = JSON.parse(b); for (const m of (j.messages || [])) for (const e of String(m.to || '').match(/[\w.+-]+@[\w.-]+\.\w+/g) || []) set.add(e.toLowerCase()); } catch {} res(); }); }); req.on('error', () => res()); req.end(); });
  } catch {}
  return set;
}

// --- run-marker freshness: on a Tue-Fri, a REAL scheduled run (mode DRAFT/SEND) must have a marker today ---
function runStaleToday() {
  const now = new Date(); const dow = now.getDay();
  if (![2, 3, 4, 5].includes(dow)) return false;             // not a scheduled run-day -> not stale
  const marker = join(ROOT, 'data', 'runs', `scheduled-${fmt(now).replace(/\//g, '-')}.json`);
  const rep = readJSON(marker, null);
  if (rep && (rep.mode === 'DRAFT' || rep.mode === 'SEND')) return false;   // real run fired today
  // fallback: scheduled-send.log written today
  try { const s = statSync(join(ROOT, 'out', 'scheduled-send.log')); const d = new Date(s.mtime); if (d.toDateString() === now.toDateString()) return false; } catch {}
  return true;
}

async function main() {
  // FileMaker read-only — ALL in-window 'Report for old memo samples' records (independent denominator).
  let records;
  try {
    const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
    const fenv = cfg?.mcpServers?.filemaker?.env || {};
    for (const k of ['FM_CLOUD_HOST', 'FM_CLARIS_EMAIL', 'FM_CLARIS_PASSWORD']) process.env[k] = fenv[k];
    process.env.FM_READONLY = '1';
    const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');
    const hi = new Date(); hi.setDate(hi.getDate() - MIN_AGE);
    const lo = new Date(); lo.setDate(lo.getDate() - MAX_AGE);
    const win = `${fmt(lo)}...${fmt(hi)}`;
    // Two-read join (recordId is stable across layouts, one WALLPAPER2 table): A = 'REPORT ON SAMPLES
    // ORDERED' has vid + combo sku + entered + Date WP Sample Sent; B = 'Report for old memo samples'
    // has Date Sample Request Letter Sent. Neither has BOTH, so join B's letterSent onto A by recordId.
    const A = (await fm.findRecords('WALLPAPER', 'REPORT ON SAMPLES ORDERED', { 'today for client': win }, { limit: 3000 })).records || [];
    const B = (await fm.findRecords('WALLPAPER', 'Report for old memo samples', { 'today for client': win }, { limit: 3000 })).records || [];
    const letterByRid = {}; for (const r of B) letterByRid[String(r.recordId)] = (r.fieldData['Date Sample Request Letter Sent'] || '');
    records = A.map((r) => ({ fieldData: { ...r.fieldData, 'Date Sample Request Letter Sent': letterByRid[String(r.recordId)] || '' } }));
    var window = win;
  } catch (e) {
    writeLatest({ verdict: 'FAIL', status: 'FAIL', error: `FileMaker unreadable: ${e.message}`, population: null, note: 'NOT-MEASURED — cannot verify coverage' });
    console.error(`FAIL — FileMaker unreadable: ${e.message}`);
    process.exit(1);
  }
  const contacts = readJSON(join(ROOT, 'data', 'contacts.json'), {});
  const set = await mergeGmailRecent(recentSet());
  const runStale = runStaleToday();
  const r = reconcileCoverage(records, { contacts, recentSet: set, runStale });

  // TK-12091 REGRESSION TRIPWIRE (Cody's co-required fix, 2026-09-24): the canary and production now
  // share ONE window function (lib/sweep.js followupWindow). Assert production's window still covers the
  // full active [MIN_AGE..MAX_AGE] band — if a future change re-narrows it (like TK-12091 did), rows
  // this canary audits become unreachable in production and this goes FAIL, instead of the audit
  // silently modeling a wider window than production chases (the exact drift that hid 15 stragglers).
  const band = windowCoversActiveBand(new Date(), { minAge: MIN_AGE, maxAge: MAX_AGE });
  let verdict = r.verdict;
  if (!band.covers) verdict = 'FAIL';   // production window narrower than active band = unreachable memos

  const out = { verdict, status: verdict, reconcileVerdict: r.verdict, window, population: r.population,
    accounted: r.accounted, covered: r.covered, uncovered: r.uncovered, leakedCount: r.leaked.length,
    reconciles: r.reconciles, runStale, windowCoversActiveBand: band.covers, windowGapDays: band.gapDays,
    buckets: r.counts, leaked: r.leaked.slice(0, 20) };
  writeLatest(out);

  console.log(`Coverage reconciliation — window ${window}`);
  console.log(`  population (outstanding in FM): ${r.population}`);
  console.log(`  covered/actioned: ${r.covered}  (chaseable ${r.counts.chaseable} + recipientBusy14d ${r.counts.recipientBusy14d} + guardSuppressed ${r.counts.guardSuppressed} + noChase ${r.counts.noChase})`);
  if (r.counts.recipientBusyStale) console.log(`  recipientBusyStale: ${r.counts.recipientBusyStale}  (deferred > ${24}d — vendor recontacted but THIS sku never chased → WARN, was a false-green)`);
  console.log(`  uncovered (accounted, NOT chased): ${r.uncovered}  (noVendorMap ${r.counts.noVendorMap} + needsConfirm ${r.counts.needsConfirm} + recipientBusyStale ${r.counts.recipientBusyStale})`);
  console.log(`  leaked (outstanding, NO bucket): ${r.leaked.length}`);
  if (r.leaked.length) r.leaked.slice(0, 10).forEach((x) => console.log(`     ⚠ LEAK vid=${JSON.stringify(x.vid)} sku=${x.sku} — ${x.why}`));
  console.log(`  reconciles (accounted+leaked==population): ${r.reconciles}   run-stale: ${runStale}`);
  if (!band.covers) console.log(`  ⚠ PRODUCTION WINDOW TOO NARROW: floor is ${band.gapDays}d short of the ${MAX_AGE}d active band — in-band outstanding memos are UNREACHABLE by production (TK-12091-class regression)`);
  console.log(`  VERDICT: ${verdict}${verdict !== r.verdict ? ` (reconcile said ${r.verdict}; window-band tripwire forced ${verdict})` : ''}`);
  process.exit(verdict === 'FAIL' ? 1 : 0);
}
main();