← back to Stack Map Viewer

server.js

663 lines

#!/usr/bin/env node
// Stack Interconnection Map — live web viewer
// Basic-auth admin/DW2024!  ·  live-scans ~/.claude, ~/Library/LaunchAgents, yolo-queue, ~/Projects
const http = require('http');
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');

const HOME = os.homedir();
const SKILLS = path.join(HOME, '.claude/skills');
const AGENTS = path.join(HOME, '.claude/agents');
const PROJECTS = path.join(HOME, 'Projects');
const LA = path.join(HOME, 'Library/LaunchAgents');
const PENDING = path.join(HOME, '.claude/yolo-queue/pending-approval');
const LEDGER = path.join(HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
const TICKETS = path.join(HOME, '.claude/tickets/events.jsonl');
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');

const sh = (c) => { try { return execSync(c, { encoding: 'utf8', timeout: 15000 }); } catch { return ''; } };
const lsdirs = (p, re) => { try { return fs.readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory() && !d.name.startsWith('.') && (!re || re.test(d.name))).map(d => d.name); } catch { return []; } };
const bucket = (names, re) => names.filter(n => re.test(n)).sort();

// ── Folder taxonomy (name-regex buckets) ────────────────────────────────────
// Each entry: [drill-kind, display label, regex]. Drill-kind must be unique.
// Adding a row here creates a live folder + drill-down with zero other edits.
const SKILL_CATS = [
  ['sk_canary',       'canaries / monitors',      /canary/],
  ['sk_scraper',      'vendor scraper-managers',  /scraper-manager$|-scraper$/],
  ['sk_dots',         'terminal dots / color',    /dot|^color$|^flash$|green|orange|purple|yellow|pink|lightblue/],
  ['sk_video',        'hyperframes / video',      /hyperframes|video|reel|avatar|heygen|clip|caption|motion|film|slideshow/],
  ['sk_social',       'social / marketing',       /instagram|tiktok|linkedin|facebook|pinterest|youtube|social|marketing|^dw-x|kartiseira|threads|promo/],
  ['sk_pricing',      'pricing / cost',           /price|cost|-map-|margin|kravet|discount|pricing/],
  ['sk_settlement',   'settlement gate',          /settlement/],
  ['sk_storefront',   'storefront / site UI',     /site|storefront|landing|hero|grid|viewer|rotator|bento|flipbook|gallery|page-flip|kv-list|modal|nav|scroll/],
  ['sk_research',     'research / web',           /research|web-|websearch|last30|deep|exa|knowledge|scout|competitor|peer-survey/],
  ['sk_orchestration','loops / orchestration',    /yolo|loop|officer|dtd|contrarian|council|plannator|ralph|masterdot|ticketmaster|agent-team|coordinat|abramstasks|iterm/],
  ['sk_shopify',      'shopify / catalog',        /shopify|catalog|sku|metafield|gmc|merchant|activator|inventory|vendor-|reconcile/],
  ['sk_imagedesign',  'image / design',           /image|design|room|mockup|logo|seam|edges|luxe|four-horsemen|graphic|interior|elements|fix-|regen/],
  ['sk_health',       'health / fleet guards',    /health|rollup|fleet|drift|watchdog|sentinel|uptime|-guard|liveness/],
  ['sk_email',        'email / comms',            /email|george|mailer|gmail|purelymail|comms|newsletter|inbox|reply/],
  ['sk_infra',        'infra / deploy',           /domain|dns|cloudflare|nginx|kamatera|deploy|firewall|secret|backup|disk|cron|pm2|egress/],
];

const AGENT_CATS = [
  ['ag_officers',     'cabinet officers (vp-*)',  /^vp-/],
  ['ag_vendor',       'DW commerce / vendor',     /^dw-(?!x|instagram|tiktok|facebook|linkedin|pinterest|youtube|websites|promo|marketing)|scraper|vendor|shopify|catalog|greenland|fentucci|momentum|wallquest|google-merchant|showroom|fineartamerica|sister-parish/],
  ['ag_marketing',    'marketing / social',       /marketing|instagram|tiktok|linkedin|facebook|pinterest|youtube|dw-x|dw-promo|dw-websites|front-page|graphic|ui-ux|seo|reels|logo/],
  ['ag_engineering',  'engineering',              /engineer|review|architect|debug|error|test|security|database|backend|frontend|fullstack|python|typescript|javascript|mobile|ios|performance|code|prompt|mcp|api-doc|refactor/],
  ['ag_directory',    'directories',              /directory|doctor|lawyer|la-research|adsense|domain|professional/],
  ['ag_research',     'research / data',           /research|web-researcher|search|technical|data-|product-strategist|analyst|scientist|seo-analy|task-decomp|context-manager/],
  ['ag_special',      'special projects',         /wallpapersback|apartment|seam|consulting|abramsego|bounce|wpb|garcia|elevator|avatar/],
  ['ag_ops',          'ops / approval / fleet',   /approval|cncp|operations|fleet|kamatera|pm2|masterdot|contrarian|deployment|devops|statusline/],
];

const PROJECT_CATS = [
  ['pj_dw',           'DW / designerwallcoverings',/^dw-|designerwallcoverings|-internal$|shopify|catalog|vendor|filemaker/i],
  ['pj_scraper',      'scrapers / crawlers',      /scraper|crawl|refresh|-catalog|reprice|onboard|exec$/i],
  ['pj_website',      'websites / storefronts',   /wallpaper|wallcovering|grasscloth|flocked|novasuede|silk|linen|cork|storefront|microsite|\.com|website|site$/i],
  ['pj_canary',       'canaries / monitors',      /canary|monitor|watchdog|guard|health|sentinel/i],
  ['pj_viewer',       'viewers / dashboards',     /viewer|dashboard|-board|map$|panel|console|admin/i],
  ['pj_directory',    'directories / real-estate',/directory|lawyer|doctor|calbar|animals|ventura|crcp|realestate|homesonspec|commercialreal|lacounty/i],
  ['pj_media',        'media / video / photo',    /video|reel|hyperframes|photo|image|render|comfy|design|mockup/i],
  ['pj_infra',        'infra / agents / tools',   /agent|ticket|secret|domain|george|gmail|nas|cluster|exo|mcp|cncp|tool/i],
];

let CACHE = null, CACHE_TS = 0;
function scan() {
  if (CACHE && Date.now() - CACHE_TS < 20000) return CACHE;
  const skillDirs = lsdirs(SKILLS);
  const canaries = bucket(skillDirs, /canary/);
  const scrapers = bucket(skillDirs, /scraper-manager$/);
  const dots = bucket(skillDirs, /dot|^color$|green|orange|purple|yellow|pink|lightblue/);
  const video = bucket(skillDirs, /hyperframes|video/);
  const health = (sh(`ls ${SKILLS}/*/data/latest.json 2>/dev/null | wc -l`).trim() || '0');

  const agentFiles = (() => { try { return fs.readdirSync(AGENTS).filter(f => f.endsWith('.md')); } catch { return []; } })();
  const agentNames = agentFiles.map(f => f.replace('.md', ''));
  const officers = agentNames.filter(n => n.startsWith('vp-')).sort();
  const workers = agentNames.filter(n => !n.startsWith('vp-')).sort();

  const projectDirs = lsdirs(PROJECTS).sort();
  const projectRepos = projectDirs.filter(d => { try { return fs.existsSync(path.join(PROJECTS, d, '.git')); } catch { return false; } });

  const cronLoaded = (sh(`launchctl list 2>/dev/null | grep -c com.steve`).trim() || '0');
  const cronPlists = (() => { try { return fs.readdirSync(LA).filter(f => /^com\.steve\..*\.plist$/.test(f)); } catch { return []; } })();
  let cronToSkill = 0;
  for (const f of cronPlists) { const t = sh(`grep -oE '/skills/[^/< ]+' '${path.join(LA, f)}' 2>/dev/null | head -1`).trim(); if (t) cronToSkill++; }

  let parked = [], p3 = 0, p24 = 0, p7d = 0; const now = Date.now();
  try { parked = fs.readdirSync(PENDING).filter(f => f.endsWith('.md')); } catch {}
  for (const f of parked) { try { const m = fs.statSync(path.join(PENDING, f)).mtimeMs; const h = (now - m) / 3.6e6; if (h >= 3) p3++; if (h >= 24) p24++; if (h >= 168) p7d++; } catch {} }
  const ledger = (sh(`wc -l < ${LEDGER} 2>/dev/null`).trim() || '0');

  let verdict = 'UNKNOWN';
  try { const j = JSON.parse(fs.readFileSync(path.join(SKILLS, 'fleet-health-rollup/data/latest.json'), 'utf8')); verdict = j.verdict || j.status || 'UNKNOWN'; } catch {}

  // Build category lists + a live "folders" descriptor array grouped for the UI.
  const _lists = {
    canaries, scrapers, dots, video,
    officers, workers,
    sk_all: skillDirs.slice().sort(),
    ag_all: agentNames.slice().sort(),
    ag_workers: workers,
    pj_all: projectDirs,
    pj_repos: projectRepos.slice().sort(),
    parked: parked.slice().sort(),
    cronPlists: cronPlists.slice().sort(),
  };
  const folders = [];
  const addCats = (group, names, cats) => {
    for (const [kind, label, re] of cats) {
      const items = bucket(names, re);
      _lists[kind] = items;
      if (items.length) folders.push({ group, kind, label, n: items.length });
    }
  };
  addCats('SKILLS', skillDirs, SKILL_CATS);
  addCats('AGENTS', agentNames, AGENT_CATS);
  addCats('PROJECTS', projectDirs, PROJECT_CATS);

  CACHE = {
    ts: new Date().toISOString(),
    skills: skillDirs.length, canaries: canaries.length, scrapers: scrapers.length,
    dots: dots.length, video: video.length, health: +health,
    agents: agentFiles.length, officers: officers.length, workers: workers.length,
    projects: projectDirs.length, projectRepos: projectRepos.length,
    cronLoaded: +cronLoaded, cronPlists: cronPlists.length, cronToSkill,
    parked: parked.length, p3, p24, p7d, ledger: +ledger, verdict,
    folders,
    _lists,
  };
  CACHE_TS = Date.now();
  return CACHE;
}

// ── Recursive drill graph ────────────────────────────────────────────────────
// Generic typed-children endpoint: /api/drill?type=<t>&id=<id>
// Every function returns { title, breadcrumb, children:[{type,id,label,meta}] }.
// Children whose type has a handler are drillable; leaf types (kv/file-leaf) just
// return empty children → the UI shows "(no deeper detail)" gracefully.
const CABINET = path.join(PROJECTS, 'agent-cabinet/cabinet.yaml');
const readText = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
const existsP = (p) => { try { return fs.existsSync(p); } catch { return false; } };
const isDirP = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
const mtimeP = (p) => { try { return fs.statSync(p).mtimeMs; } catch { return 0; } };
const sizeP = (p) => { try { return fs.statSync(p).size; } catch { return 0; } };
const fmtSize = (n) => n < 1024 ? n + 'B' : n < 1048576 ? (n / 1024).toFixed(1) + 'K' : (n / 1048576).toFixed(1) + 'M';
const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; // single-quote for shell
const lines = (s) => s.split('\n').map(x => x.trim()).filter(Boolean);
const base = (p) => path.basename(p);

const DRILL = new Map();
function dcache(key, fn) {
  const hit = DRILL.get(key);
  if (hit && Date.now() - hit.ts < 15000) return hit.v;
  const v = fn();
  DRILL.set(key, { ts: Date.now(), v });
  return v;
}

function heartbeatKids(dir) {
  const hb = path.join(dir, 'data/latest.json');
  if (!existsP(hb)) return [];
  const out = [{ type: 'kv', id: hb, label: '♥ heartbeat: data/latest.json', meta: new Date(mtimeP(hb)).toLocaleString() }];
  try {
    const j = JSON.parse(readText(hb));
    for (const k of Object.keys(j)) {
      const v = j[k];
      if (v === null || typeof v === 'object') continue;
      out.push({ type: 'kv', id: dir + '#' + k, label: k, meta: String(v) });
    }
  } catch { out.push({ type: 'kv', id: hb + '#err', label: '(unparseable latest.json)', meta: '' }); }
  return out;
}

// ── skill "5W" info block: description + what/why/where/who/when ─────────────
// Robust to BOTH SKILL.md shapes: YAML frontmatter (name:/description:) AND the
// bare "# Heading + prose" style. Always returns a filled card — never blank.
const firstSentence = (s) => { const m = String(s || '').match(/^[\s\S]*?[.!?](\s|$)/); return (m ? m[0] : String(s || '')).trim(); };
const sentences = (s) => String(s || '').split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean);

function parseSkillMd(id) {
  const dir = path.join(SKILLS, id);
  const md = readText(path.join(dir, 'SKILL.md'));
  let name = id, desc = '', body = md;
  const fm = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
  if (fm) {
    body = fm[2] || '';
    const nm = fm[1].match(/^name:\s*(.+)$/m); if (nm) name = nm[1].trim();
    const dm = fm[1].match(/^description:\s*([\s\S]*?)(?:\n[a-zA-Z_-]+:\s|\n*$)/m); if (dm) desc = dm[1].replace(/\s+/g, ' ').trim();
  }
  if (!desc) { // no frontmatter description — take the first real prose paragraph, stopping at any structural line
    const h = body.match(/^#\s+(.+)$/m); if (h) name = h[1].replace(/[*#]/g, '').trim();
    const isStruct = (l) => { const t = l.trim(); return t === '' || /^[#>`|]/.test(t) || /^\*\*[^*]+\*\*\s*:/.test(t) || /^(?:CREATE|SELECT|INSERT|UPDATE|DELETE|ALTER|\{|\}|<|\d+[.)])/i.test(t); };
    const buf = []; let started = false;
    for (const l of body.split('\n')) {
      if (!started) { if (isStruct(l) || l.trim().length < 25) continue; started = true; buf.push(l.trim()); }
      else { if (isStruct(l)) break; buf.push(l.trim()); if (buf.length >= 4) break; }
    }
    desc = buf.join(' ').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim();
  }
  return { dir, name, desc, body };
}

// last time a vendor scraper actually RAN. Scrape signals = a crawl/insert/scrape ts.
// crawled_at + created_at COUNT: a new row's created_at means a scraper genuinely found a
// new SKU (the overnight new-products refresh inserts rows without bumping last_scraped,
// e.g. anna-french +22 rows Aug-31), so treating created_at as activity is correct.
// ONLY updated_at is demoted — it moves on ANY bulk edit of EXISTING rows (the Apr-1
// gemini-tagger migration touched 152 tables), so it alone is the "db-touch" fallback.
const SCRAPE_COLS = ['last_scraped', 'scraped_at', 'crawled_at', 'specs_scraped_at', 'imported_at', 'price_updated_at', 'created_at'];
const FALLBACK_COLS = ['updated_at'];
const TS_COLS = [...SCRAPE_COLS, ...FALLBACK_COLS];
const gexpr = (cols) => cols.length ? (cols.length > 1 ? 'greatest(' + cols.map(c => c + '::timestamptz').join(',') + ')' : cols[0] + '::timestamptz') : 'null::timestamptz';
const qtbl = (t) => '"' + String(t).replace(/"/g, '') + '"'; // quote table names (digit-leading like w1838 are fine, but be safe)

// ── Authoritative skill→table resolution ────────────────────────────────────
// vendor_registry.catalog_table is the source of truth; SKILL.md-named tables can be
// wrong/stale (e.g. 1838's SKILL.md names 1838_catalog, which doesn't exist — the real
// table is w1838_catalog). Resolution: SKILL.md table IF it exists, else registry match.
let _existTbls = null, _existTs = 0;
function existingCatalogTables() {
  if (_existTbls && Date.now() - _existTs < 60000) return _existTbls;
  const rows = lines(sh(`psql -h /tmp -d dw_unified -tAc "select table_name from information_schema.tables where table_schema='public' and table_name ~ '_(catalog|colorways?)$' and table_name !~ '(bak|backup|prebackfill|_old|_tmp|_temp|_copy)'" 2>/dev/null`));
  _existTbls = new Set(rows); _existTs = Date.now();
  return _existTbls;
}
let _reg = null, _regTs = 0;
function vendorRegMap() {
  if (_reg && Date.now() - _regTs < 60000) return _reg;
  const out = [];
  for (const l of lines(sh(`psql -h /tmp -d dw_unified -tAF'|' -c "select lower(vendor_code), lower(coalesce(vendor_name,'')), catalog_table from vendor_registry where catalog_table is not null" 2>/dev/null`))) {
    const [code, name, tbl] = l.split('|'); if (!tbl) continue;
    out.push({ tbl, keys: [code, name, tbl.replace(/_catalog$/, '')].map(s => (s || '').replace(/[^a-z0-9]/g, '')).filter(Boolean) });
  }
  _reg = out; _regTs = Date.now();
  return _reg;
}
const normSkill = (s) => s.replace(/-scraper-manager$|-colorway-scraper$|-catalog-scraper$|-colorway$|-scraper$/, '').replace(/[^a-z0-9]/g, '');
function resolveVendorTable(skillId, smCandidate) {
  const exist = existingCatalogTables();
  if (smCandidate && exist.has(smCandidate)) return smCandidate; // SKILL.md table, verified to exist
  const k = normSkill(skillId), reg = vendorRegMap();
  let m = reg.find(r => r.keys.includes(k) && exist.has(r.tbl));                                   // exact key
  if (!m) { const c = reg.filter(r => r.keys.some(x => x.length >= 5 && (x.startsWith(k) || k.startsWith(x))) && exist.has(r.tbl)); if (c.length === 1) m = c[0]; } // unambiguous prefix only
  if (m) return m.tbl;
  return (smCandidate && exist.has(smCandidate)) ? smCandidate : null; // never return a non-existent table
}

function lastRunFromParts(sLast, fLast, rows, sd, smin) {
  const last = sLast || fLast; if (!last) return null;
  const source = sLast ? 'scrape' : (fLast ? 'db-touch' : '');
  const days = Math.floor((Date.now() - Date.parse(last.replace(' ', 'T'))) / 86400000);
  const age = isNaN(days) ? '' : days === 0 ? 'today' : days === 1 ? '1 day ago' : days + ' days ago';
  const n = (sd === '' || sd == null) ? 0 : +sd;      // distinct scrape-dates recorded in the table
  const cadence = n >= 2 ? 'cadence' : n === 1 ? 'once' : 'never'; // cadence=has re-scraped · once=onboard-only · never=no true scrape
  return { last, rows: +rows, days: isNaN(days) ? null : days, age, source, scrapeDates: n, firstScrape: smin || '', cadence };
}
function tableLastRun(tbl) {
  if (!/^[a-z0-9_]+$/.test(tbl || '')) return null;
  const q1 = `select column_name from information_schema.columns where table_name='${tbl}' and column_name in (${TS_COLS.map(c => `'${c}'`).join(',')})`;
  const cols = lines(sh(`psql -h /tmp -d dw_unified -tAc ${shq(q1)} 2>/dev/null`));
  if (!cols.length) return null;
  const sc = cols.filter(c => SCRAPE_COLS.includes(c)), fc = cols.filter(c => FALLBACK_COLS.includes(c));
  const q2 = `select coalesce(to_char(max(${gexpr(sc)}),'YYYY-MM-DD HH24:MI'),'') || '|' || coalesce(to_char(max(${gexpr(fc)}),'YYYY-MM-DD HH24:MI'),'') || '|' || count(*) || '|' || count(distinct (${gexpr(sc)})::date) || '|' || coalesce(to_char(min(${gexpr(sc)}),'YYYY-MM-DD'),'') from ${qtbl(tbl)}`;
  const out = sh(`psql -h /tmp -d dw_unified -tAc ${shq(q2)} 2>/dev/null`).trim();
  if (!out) return null;
  const [sLast, fLast, rows, sd, smin] = out.split('|');
  const lr = lastRunFromParts(sLast, fLast, rows, sd, smin);
  return lr ? { ...lr, tbl } : null;
}

// ── "date last run" for agents/officers/skills/projects ──────────────────────
// HARD RULE (per CLAUDE.md TK-11431): a last-run must come from a TRUE execution
// signal, never a fabricated one. A skill-DIR mtime moves when the skill is EDITED,
// not when it RUNS — so it's a db-touch lie and is never used here. When no genuine
// run signal exists we say so explicitly ("never recorded") rather than invent a date.
const ageOf = (iso) => { const t = Date.parse(iso); if (isNaN(t)) return ''; const d = Math.floor((Date.now() - t) / 86400000); return d <= 0 ? 'today' : d === 1 ? '1 day ago' : d + ' days ago'; };
const fmtWhen = (iso) => { const d = new Date(iso); return isNaN(d.getTime()) ? String(iso) : d.toLocaleString(); };
// newest matching line in an append-only, chronological JSONL where "agent":"<name>"
// (both events.jsonl and ledger.jsonl are append-ordered, so the LAST match is newest)
function newestAgentEntry(file, name) {
  if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) return null; // names are filenames — guard grep -F against newlines/meta
  const line = sh(`grep -F ${shq('"agent":"' + name + '"')} ${shq(file)} 2>/dev/null | tail -1`).trim();
  if (!line) return null;
  try { const j = JSON.parse(line); return j.ts ? { ts: j.ts, ticket: j.ticket || j.id || '', what: j.action || j.title || j.text || j.type || '' } : null; }
  catch { return null; }
}
// last time an agent/officer actually RAN = newest ticket-event OR reversible-ledger entry under its name
function agentLastRun(name) {
  const cands = [];
  const ev = newestAgentEntry(TICKETS, name); if (ev) cands.push({ src: 'ticket', ...ev });
  const lg = newestAgentEntry(LEDGER, name); if (lg) cands.push({ src: 'ledger', ...lg });
  if (!cands.length) return 'never recorded — no ticket or ledger activity logged under this agent';
  cands.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
  const c = cands[0];
  const snip = String(c.what || '').replace(/\s+/g, ' ').slice(0, 80);
  return fmtWhen(c.ts) + ' (' + ageOf(c.ts) + ') · via ' + c.src + (c.ticket ? ' ' + c.ticket : '') + (snip ? ' — ' + snip : '');
}
function agentMdDesc(id) {
  const md = readText(path.join(AGENTS, id + '.md'));
  const fm = md.match(/^---\n([\s\S]*?)\n---/);
  if (fm) { const dm = fm[1].match(/^description:\s*([\s\S]*?)(?:\n[a-zA-Z_-]+:\s|\n*$)/m); if (dm) return dm[1].replace(/\s+/g, ' ').trim(); }
  return '';
}
function agentInfo(id) {
  const desc = agentMdDesc(id);
  return {
    description: desc || '(' + (id.startsWith('vp-') ? 'cabinet officer' : 'worker agent') + ' ' + id + ')',
    who: id.startsWith('vp-') ? 'cabinet officer (vp-*) — routes + signs off gated work' : 'worker agent — owns & supervises, recruits subagents',
    when: 'on-demand (invoked via Agent tool / officer routing)',
    lastRun: agentLastRun(id),
  };
}

function skillInfo(id) {
  const { dir, name, desc, body } = parseSkillMd(id);
  const hay = desc + ' \n' + body.slice(0, 4000);
  // WHERE — vendor domains, staging tables, SKU prefix, target systems, dir
  const domains = [...new Set([...hay.matchAll(/\b([a-z0-9][a-z0-9-]*\.(?:com|co\.uk|fr|net|org|io|us|design))\b/gi)].map(m => m[1].toLowerCase()))]
    .filter(d => !/\.(?:js|md|json|sh|mjs|py)$/.test(d) && !/^com\.steve/.test(d)).slice(0, 4);
  const tables = [...new Set([...hay.matchAll(/\b([a-z0-9][a-z0-9_]*_(?:catalog|colorways|pricing|registry))\b/g)].map(m => m[1]))].slice(0, 4);
  const prefix = (hay.match(/\bDW[A-Z]{2,4}-/) || [])[0] || '';
  const systems = ['Shopify', 'FileMaker', 'dw_unified', 'Merchant Center', 'Postgres', 'Browserbase'].filter(s => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(hay));
  let scripts = [];
  try { scripts = fs.readdirSync(dir).filter(f => /\.(?:mjs|js|py|sh)$/.test(f)).slice(0, 6); } catch {}
  const whereBits = [];
  if (domains.length) whereBits.push('source ' + domains.join(', '));
  if (tables.length) whereBits.push('tables ' + tables.join(', '));
  if (prefix) whereBits.push('SKU ' + prefix);
  if (systems.length) whereBits.push('writes ' + systems.join(' · '));
  if (scripts.length) whereBits.push('scripts ' + scripts.join(', '));
  whereBits.push('~/.claude/skills/' + id);
  // WHO — referenced-by (cron plists · agents · sibling skills)
  const refP = lines(sh(`grep -lF ${shq(id)} ${LA}/com.steve.*.plist 2>/dev/null`)).map(base);
  const refA = lines(sh(`grep -lF ${shq(id)} ${AGENTS}/*.md 2>/dev/null`)).map(f => base(f).replace(/\.md$/, ''));
  const refS = lines(sh(`grep -rlF ${shq(id)} ${SKILLS}/*/SKILL.md 2>/dev/null`)).map(f => base(path.dirname(f))).filter(n => n !== id);
  const whoBits = [];
  if (refA.length) whoBits.push(refA.length + ' agent' + (refA.length > 1 ? 's' : '') + ' (' + refA.slice(0, 4).join(', ') + (refA.length > 4 ? '…' : '') + ')');
  if (refP.length) whoBits.push(refP.length + ' cron plist' + (refP.length > 1 ? 's' : ''));
  if (refS.length) whoBits.push(refS.length + ' skill' + (refS.length > 1 ? 's' : ''));
  // WHEN — cadence from a driving plist, else on-demand
  let when = 'on-demand (invoked via Skill tool)';
  if (refP.length) {
    const xml = readText(path.join(LA, refP[0]));
    const iv = xml.match(/<key>StartInterval<\/key>\s*<integer>(\d+)/);
    const cal = /StartCalendarInterval/.test(xml);
    if (iv) { const s = +iv[1]; when = s >= 3600 ? 'every ' + (s / 3600) + 'h (cron)' : s >= 60 ? 'every ' + (s / 60) + 'm (cron)' : 'every ' + s + 's (cron)'; }
    else if (cal) { const hrs = [...xml.matchAll(/<key>Hour<\/key>\s*<integer>(\d+)/g)].map(x => x[1]); when = 'daily' + (hrs.length ? ' @ ' + hrs.join(',') + 'h' : '') + ' (cron)'; }
    else when = 'cron (' + refP[0].replace(/\.plist$/, '') + ')';
  }
  // WHY — pull rationale/trigger sentences from the description
  const why = sentences(desc).filter(s => /\b(use when|use proactively|born|catch|guard|because|so that|must never|never customer-facing|enforce|ensure|prevent|keeps?|refuses?|private label)\b/i.test(s)).slice(0, 2).join(' ')
    || sentences(desc).slice(1, 3).find(s => s.length > 25) || firstSentence(desc) || '—';
  // WHAT — one-line summary
  const what = firstSentence(desc) || desc || '—';
  // LAST RUN — newest scrape timestamp in the vendor's staging table (scrapers only)
  const vendorTbl = resolveVendorTable(id, tables.find(t => /_catalog$/.test(t)) || tables[0]);
  const lr = vendorTbl ? tableLastRun(vendorTbl) : null;
  const hist = lr ? (lr.cadence === 'cadence' ? ' · ' + lr.scrapeDates + ' scrapes since ' + lr.firstScrape + ' (cadence lapsed)' : lr.cadence === 'once' ? ' · ⚠ scraped ONCE at onboarding, never refreshed' : ' · ⚠ no true scrape ever (db-touch only)') : '';
  let lastRun;
  if (lr) {
    lastRun = lr.last + (lr.age ? ' (' + lr.age + ')' : '') + ' · ' + Number(lr.rows).toLocaleString() + ' rows · ' + lr.tbl + hist;
  } else {
    // no vendor table → next-strongest TRUE run signal, else say so honestly (never the dir mtime)
    const hb = path.join(dir, 'data/latest.json');
    if (existsP(hb)) {
      // A file mtime is a db-touch lie (git pull / rsync -a / tar -x all rewrite it without the skill running).
      // Prefer a TRUE timestamp field written INSIDE the heartbeat; fall back to mtime ONLY when none exists,
      // and label that fallback as unverified so it is never mistaken for a real run signal. (Cody TK-12015; [[liveness-artifact-must-follow-the-side-effect]])
      let ts = '', hbKey = '';
      try {
        const j = JSON.parse(readText(hb));
        for (const k of ['ts', 'timestamp', 'generated_at', 'generatedAt', 'checked_at', 'checkedAt', 'run_at', 'runAt', 'last_run', 'lastRun', 'ranAt', 'asof', 'date', 'completed_at']) {
          if (j && j[k] != null && !isNaN(Date.parse(String(j[k])))) { ts = new Date(j[k]).toISOString(); hbKey = k; break; }
        }
      } catch {}
      if (ts) { lastRun = 'heartbeat ' + fmtWhen(ts) + ' (' + ageOf(ts) + ') · from "' + hbKey + '" in data/latest.json'; }
      else { const m = new Date(mtimeP(hb)).toISOString(); lastRun = 'data/latest.json present · file mtime ' + fmtWhen(m) + ' (' + ageOf(m) + ') · ⚠ mtime-only, UNVERIFIED — no timestamp field inside the heartbeat (a git/rsync touch would move this)'; }
    }
    else if (refP.length) { lastRun = 'driven by cron ' + refP[0].replace(/\.plist$/, '') + ' — no per-run timestamp recorded'; }
    else { lastRun = 'on-demand — no local run record kept'; }
  }
  return {
    description: desc || '(no description found in SKILL.md)',
    what, why,
    who: whoBits.length ? whoBits.join(' · ') : 'nothing references it (pure on-demand)',
    where: whereBits.join('  ·  '),
    when, lastRun, name,
  };
}

function catChildType(kind) {
  if (kind === 'canaries' || kind === 'sk_canary') return 'canary';
  if (kind === 'officers' || kind === 'ag_officers') return 'officer';
  if (kind === 'cronPlists') return 'plist';
  if (kind === 'parked') return 'parked';
  if (kind.startsWith('ag_') || kind === 'workers' || kind === 'ag_workers') return 'agent';
  if (kind.startsWith('pj_') || kind === 'projectRepos') return 'project';
  return 'skill'; // sk_*, scrapers, dots, video, sk_all + default
}

const DRILLERS = {
  cat(id) {
    const s = scan();
    const items = (s._lists[id] || []).slice().sort();
    const ct = catChildType(id);
    return {
      title: 'folder · ' + id + ' (' + items.length + ')',
      breadcrumb: id,
      children: items.map(it => ({ type: ct, id: it, label: it })),
    };
  },
  skill(id) {
    const dir = path.join(SKILLS, id);
    if (!existsP(dir)) return { title: 'skill · ' + id, breadcrumb: id, children: [{ type: 'kv', id, label: '(no such skill dir)', meta: '' }] };
    const children = [{ type: 'refby', id, label: '⇦ referenced-by (plists · agents · skills)', meta: 'who names this skill' }];
    children.push(...heartbeatKids(dir));
    let entries = [];
    try { entries = fs.readdirSync(dir).filter(f => !f.startsWith('.')).sort(); } catch {}
    for (const e of entries) {
      const abs = path.join(dir, e);
      children.push({ type: 'file', id: abs, label: e + (isDirP(abs) ? '/' : ''), meta: isDirP(abs) ? 'dir' : fmtSize(sizeP(abs)) });
    }
    return { title: 'skill · ' + id, breadcrumb: id, info: skillInfo(id), children };
  },
  refby(id) {
    const children = [];
    const seen = new Set();
    const gp = sh(`grep -lF ${shq(id)} ${LA}/com.steve.*.plist 2>/dev/null`);
    for (const f of lines(gp)) { const b = base(f); if (!seen.has('p' + b)) { seen.add('p' + b); children.push({ type: 'plist', id: b, label: b, meta: 'launchd' }); } }
    const ga = sh(`grep -lF ${shq(id)} ${AGENTS}/*.md 2>/dev/null`);
    for (const f of lines(ga)) { const b = base(f).replace(/\.md$/, ''); if (!seen.has('a' + b)) { seen.add('a' + b); children.push({ type: 'agent', id: b, label: b, meta: 'agent' }); } }
    const gs = sh(`grep -rlF ${shq(id)} ${SKILLS}/*/SKILL.md 2>/dev/null`);
    for (const f of lines(gs)) { const nm = base(path.dirname(f)); if (nm !== id && !seen.has('s' + nm)) { seen.add('s' + nm); children.push({ type: 'skill', id: nm, label: nm, meta: 'skill' }); } }
    return { title: 'referenced-by · ' + id + ' (' + children.length + ')', breadcrumb: 'refby/' + id, children };
  },
  file(id) {
    if (isDirP(id)) {
      let entries = [];
      try { entries = fs.readdirSync(id).filter(f => !f.startsWith('.')).sort(); } catch {}
      return {
        title: 'dir · ' + base(id), breadcrumb: id,
        children: entries.map(e => { const abs = path.join(id, e); return { type: 'file', id: abs, label: e + (isDirP(abs) ? '/' : ''), meta: isDirP(abs) ? 'dir' : fmtSize(sizeP(abs)) }; }),
      };
    }
    const txt = readText(id);
    const nl = txt ? txt.split('\n').length : 0;
    const children = [{ type: 'kv', id: id + '#size', label: 'size', meta: fmtSize(sizeP(id)) }, { type: 'kv', id: id + '#lines', label: 'lines', meta: String(nl) }, { type: 'kv', id: id + '#mtime', label: 'modified', meta: new Date(mtimeP(id)).toLocaleString() }];
    txt.split('\n').slice(0, 12).forEach((ln, i) => { if (ln.trim()) children.push({ type: 'kv', id: id + '#L' + i, label: String(i + 1).padStart(3), meta: ln.slice(0, 120) }); });
    return { title: 'file · ' + base(id), breadcrumb: id, children };
  },
  plist(id) {
    if (!/\.plist$/.test(id)) id += '.plist';
    const p = path.join(LA, id);
    const xml = readText(p);
    const children = [];
    const m = xml.match(/\/skills\/([^\/<"' ]+)/);
    if (m) children.push({ type: 'skill', id: m[1], label: 'skill: ' + m[1], meta: 'invokes' });
    const scripts = [...xml.matchAll(/<string>([^<]*\.(?:sh|mjs|js|py))<\/string>/g)].map(x => x[1]);
    for (const s of scripts) children.push({ type: 'file', id: s, label: '▶ ' + base(s), meta: s });
    const iv = xml.match(/<key>StartInterval<\/key>\s*<integer>(\d+)/);
    if (iv) children.push({ type: 'kv', id: id + '#iv', label: 'StartInterval', meta: iv[1] + 's' });
    if (/StartCalendarInterval/.test(xml)) {
      const hrs = [...xml.matchAll(/<key>Hour<\/key>\s*<integer>(\d+)/g)].map(x => x[1]).join(', ');
      children.push({ type: 'kv', id: id + '#cal', label: 'StartCalendarInterval', meta: hrs ? 'hours ' + hrs : 'calendar' });
    }
    const label = id.replace(/\.plist$/, '');
    const ll = sh(`launchctl list 2>/dev/null | grep -F ${shq(label)}`).trim();
    if (ll) { const parts = ll.split(/\s+/); children.push({ type: 'kv', id: id + '#pid', label: 'launchctl pid', meta: parts[0] }); children.push({ type: 'kv', id: id + '#exit', label: 'last exit status', meta: parts[1] }); }
    else children.push({ type: 'kv', id: id + '#unloaded', label: 'launchctl', meta: 'not loaded' });
    return { title: 'plist · ' + label, breadcrumb: 'plist/' + label, children };
  },
  officer(id) {
    const children = [];
    const seen = new Set();
    // parse cabinet.yaml block for this vp
    const y = readText(CABINET).split('\n');
    let inBlock = false;
    for (const raw of y) {
      const t = raw.trim();
      const vpM = raw.match(/^\s*-\s*vp:\s*(\S+)/);
      if (vpM) { inBlock = vpM[1] === id; continue; }
      if (!inBlock) continue;
      const sub = t.match(/^-?\s*subagent:\s*(\S+)/);
      const sk = t.match(/^-?\s*skill:\s*(\S+)/);
      if (sub && !seen.has('a' + sub[1])) { seen.add('a' + sub[1]); children.push({ type: 'agent', id: sub[1], label: sub[1], meta: 'subagent' }); }
      if (sk && !seen.has('s' + sk[1])) { seen.add('s' + sk[1]); children.push({ type: 'skill', id: sk[1], label: sk[1], meta: 'skill' }); }
    }
    // officer .md "Delegates to" line
    const md = readText(path.join(AGENTS, id + '.md'));
    const del = md.match(/Delegates to ([^.]+)\./i);
    if (del) for (const nm of del[1].split(/,|and/).map(x => x.trim().replace(/\s+subagents?$/, '')).filter(Boolean)) {
      if (existsP(path.join(AGENTS, nm + '.md')) && !seen.has('a' + nm)) { seen.add('a' + nm); children.push({ type: 'agent', id: nm, label: nm, meta: 'delegate' }); }
    }
    return { title: 'officer · ' + id + ' (' + children.length + ')', breadcrumb: 'officer/' + id, info: agentInfo(id), children };
  },
  agent(id) {
    const md = readText(path.join(AGENTS, id + '.md'));
    if (!md) return { title: 'agent · ' + id, breadcrumb: id, info: { description: '(no agent .md found — may be a skill or renamed)', lastRun: agentLastRun(id) }, children: [{ type: 'kv', id, label: '(no such agent .md)', meta: '' }] };
    const children = [];
    const tools = md.match(/^tools:\s*(.+)$/m);
    if (tools) children.push({ type: 'kv', id: id + '#tools', label: 'tools', meta: tools[1].trim() });
    const names = new Set([...md.matchAll(/skills\/([a-z0-9][a-z0-9-]+)/gi)].map(x => x[1]));
    for (const nm of names) if (existsP(path.join(SKILLS, nm))) children.push({ type: 'skill', id: nm, label: 'skill: ' + nm, meta: 'invokes' });
    if (id.startsWith('vp-')) { const off = DRILLERS.officer(id); for (const c of off.children) children.push(c); }
    return { title: 'agent · ' + id + ' (' + children.length + ')', breadcrumb: 'agent/' + id, info: agentInfo(id), children };
  },
  canary(id) {
    const dir = path.join(SKILLS, id);
    const children = heartbeatKids(dir);
    const gp = sh(`grep -lF ${shq(id)} ${LA}/com.steve.*.plist 2>/dev/null`);
    for (const f of lines(gp)) children.push({ type: 'plist', id: base(f), label: '⏰ ' + base(f), meta: 'cron driver' });
    if (!children.length) children.push({ type: 'kv', id, label: '(no heartbeat / no cron found)', meta: '' });
    return { title: 'canary · ' + id, breadcrumb: 'canary/' + id, info: skillInfo(id), children };
  },
  project(id) {
    const dir = path.join(PROJECTS, id);
    const children = [];
    const isRepo = existsP(path.join(dir, '.git'));
    children.push({ type: 'kv', id: id + '#git', label: 'git repo', meta: isRepo ? 'yes' : 'no' });
    // LAST RUN — for a project the true "last activity" signal is the newest git commit (never dir mtime)
    let headSubj = '', lastRun;
    if (isRepo) {
      const ci = sh(`cd ${shq(dir)} && git log -1 --format='%cI' 2>/dev/null`).trim();
      headSubj = sh(`cd ${shq(dir)} && git log -1 --format='%h %s' 2>/dev/null`).trim();
      if (headSubj) children.push({ type: 'kv', id: id + '#head', label: 'HEAD', meta: headSubj });
      lastRun = ci ? 'git commit ' + fmtWhen(ci) + ' (' + ageOf(ci) + ')' + (headSubj ? ' · ' + headSubj : '') : 'git repo — no commits yet';
    } else { lastRun = 'not a git repo — no commit history'; }
    let entries = [];
    try { entries = fs.readdirSync(dir).filter(f => !f.startsWith('.') && f !== 'node_modules').sort().slice(0, 60); } catch {}
    for (const e of entries) { const abs = path.join(dir, e); children.push({ type: 'file', id: abs, label: e + (isDirP(abs) ? '/' : ''), meta: isDirP(abs) ? 'dir' : fmtSize(sizeP(abs)) }); }
    return { title: 'project · ' + id, breadcrumb: 'project/' + id, info: { description: headSubj || ('project ' + id), who: '~/Projects/' + id, lastRun }, children };
  },
  parked(id) {
    const p = path.join(PENDING, id);
    const txt = readText(p);
    const children = [{ type: 'kv', id: id + '#age', label: 'modified', meta: new Date(mtimeP(p)).toLocaleString() }];
    txt.split('\n').slice(0, 40).forEach((ln, i) => { if (ln.trim()) children.push({ type: 'kv', id: id + '#L' + i, label: String(i + 1).padStart(3), meta: ln.slice(0, 140) }); });
    if (children.length === 1) children.push({ type: 'kv', id, label: '(empty memo)', meta: '' });
    return { title: 'parked · ' + id, breadcrumb: 'parked/' + id, children };
  },
  kv(id) { return { title: id, breadcrumb: id, children: [] }; },
};

// ── Scraper staleness ranking: every scraper → newest timestamp in its staging table ──
// Skill→table resolved from SKILL.md (no DB); then just 2 batched psql calls for all tables.
const prettyTbl = (t) => t.replace(/_catalog$|_colorways?$/, '').replace(/_/g, ' ');
const prettySkill = (s) => s.replace(/-scraper-manager$|-scraper$/, '');
// ── Scraper healthcheck status (verified / broken + failure reason) ─────────
const STATUS_JSON = path.join(PROJECTS, 'Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/data/scraper-audit/scraper-status.json');
const nkey = (s) => (s || '').toLowerCase().replace(/-scraper-manager$|-scraper$|_catalog$|_colorways?$/, '').replace(/[^a-z0-9]/g, '');
let _hc = null, _hcTs = 0;
function healthMap() {
  if (_hc && Date.now() - _hcTs < 60000) return _hc;
  const out = [];
  try {
    const S = (JSON.parse(readText(STATUS_JSON)).scrapers) || {};
    for (const k of Object.keys(S)) { const e = S[k] || {}; out.push([k.toLowerCase().replace(/[^a-z0-9]/g, ''), { status: e.status || '', note: (e.notes || e.lastError || e.error || '').toString().replace(/^healthcheck[^—-]*[—-]\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 140) }]); }
  } catch {}
  _hc = out; _hcTs = Date.now(); return _hc;
}
function healthOf(keys) {
  const hm = healthMap(); const ks = keys.filter(Boolean);
  const fz = (k, x) => k === x || (k.length >= 5 && x.length >= 5 && (x.startsWith(k) || k.startsWith(x) || x.includes(k) || k.includes(x)));
  for (const [hk, v] of hm) if (ks.some(k => fz(k, hk))) return v;
  return null;
}
// freshness stats for a set of tables, CHUNKED so 200+ tables stay under the 15s shell timeout
function statsForTables(tbls) {
  const stats = {}; const CH = 60;
  for (let i = 0; i < tbls.length; i += CH) {
    const chunk = tbls.slice(i, i + CH);
    const q1 = `select table_name, string_agg(column_name, ',') from information_schema.columns where table_name in (${chunk.map(t => `'${t}'`).join(',')}) and column_name in (${TS_COLS.map(c => `'${c}'`).join(',')}) group by table_name`;
    const colsByTbl = {};
    for (const r of lines(sh(`psql -h /tmp -d dw_unified -tAF'|' -c ${shq(q1)} 2>/dev/null`))) { const [t, cs] = r.split('|'); if (t && cs) colsByTbl[t] = cs.split(','); }
    const parts = [];
    for (const t of chunk) {
      const cs = colsByTbl[t]; if (!cs || !cs.length) continue;
      const sc = cs.filter(c => SCRAPE_COLS.includes(c)), fc = cs.filter(c => FALLBACK_COLS.includes(c));
      parts.push(`select '${t}' t, coalesce(to_char(max(${gexpr(sc)}),'YYYY-MM-DD HH24:MI'),'') s, coalesce(to_char(max(${gexpr(fc)}),'YYYY-MM-DD HH24:MI'),'') f, count(*) n, count(distinct (${gexpr(sc)})::date) sd, coalesce(to_char(min(${gexpr(sc)}),'YYYY-MM-DD'),'') smin from ${qtbl(t)}`);
    }
    if (parts.length) for (const r of lines(sh(`psql -h /tmp -d dw_unified -tAF'|' -c ${shq(parts.join(' union all '))} 2>/dev/null`))) {
      const [t, s, f, n, sd, smin] = r.split('|'); stats[t] = lastRunFromParts(s, f, n, sd, smin);
    }
  }
  return stats;
}
function scraperStaleness() {
  return dcache('scraper-staleness', () => {
    const exist = existingCatalogTables();                 // every catalog/colorway table (the full vendor universe)
    const nameByTbl = {};                                   // authoritative vendor name per table
    for (const l of lines(sh(`psql -h /tmp -d dw_unified -tAF'|' -c "select catalog_table, coalesce(nullif(vendor_name,''),vendor_code) from vendor_registry where catalog_table is not null" 2>/dev/null`))) { const [t, nm] = l.split('|'); if (t && nm && !nameByTbl[t]) nameByTbl[t] = nm; }
    const entries = {};                                     // keyed by table
    for (const t of exist) entries[t] = { tbl: t, name: nameByTbl[t] || prettyTbl(t), skill: null };
    // attach a dedicated skill (for click-to-card) where one maps to the table
    const skillOnly = [];
    for (const s of bucket(lsdirs(SKILLS), /scraper-manager$|-scraper$/)) {
      const { body, desc } = parseSkillMd(s);
      const m = (desc + ' ' + body.slice(0, 4000)).match(/\b([a-z0-9][a-z0-9_]*_(?:catalog|colorways))\b/);
      const tbl = resolveVendorTable(s, m ? m[1] : null);
      if (tbl && entries[tbl]) entries[tbl].skill = entries[tbl].skill || s;
      else if (tbl) entries[tbl] = { tbl, name: nameByTbl[tbl] || prettyTbl(tbl), skill: s };
      else skillOnly.push({ name: prettySkill(s), skill: s });      // non-vendor scraper skill (feed-first, fineartamerica…)
    }
    const stats = statsForTables(Object.keys(entries));
    const mk = (e, st) => { const hv = healthOf([nkey(e.tbl), nkey(e.skill), nkey(e.name)]); return { name: e.name, skill: e.skill || '', tbl: e.tbl || '', last: st ? st.last : '', rows: st ? st.rows : null, days: st && st.days != null ? st.days : null, source: st ? st.source : '', cadence: st ? st.cadence : '', scrapeDates: st ? st.scrapeDates : 0, firstScrape: st ? st.firstScrape : '', health: hv ? (hv.status || '') : '', healthNote: hv ? hv.note : '' }; };
    const rows = Object.values(entries).map(e => mk(e, stats[e.tbl]));
    for (const so of skillOnly) rows.push(mk(so, null));
    rows.sort((a, b) => a.days == null && b.days == null ? a.name.localeCompare(b.name) : a.days == null ? 1 : b.days == null ? -1 : b.days - a.days);
    const stale30 = rows.filter(r => r.days != null && r.days > 30).length;
    const nodata = rows.filter(r => r.days == null).length;
    const onceOnly = rows.filter(r => r.cadence === 'once').length;
    const neverScraped = rows.filter(r => r.cadence === 'never').length;
    const withSkill = rows.filter(r => r.skill).length;
    const broken = rows.filter(r => r.health === 'broken').length;
    return { ts: new Date().toISOString(), count: rows.length, stale30, nodata, onceOnly, neverScraped, withSkill, broken, rows };
  });
}

function drill(type, id) {
  const fn = DRILLERS[type];
  if (!fn) return { title: type + ' · ' + id, breadcrumb: type + '/' + id, children: [], error: 'unknown type ' + type };
  return fn(id);
}

const PAGE = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');

const server = http.createServer((req, res) => {
  if (req.headers.authorization !== AUTH) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="stack-map"' }); return res.end('auth required');
  }
  const u = new URL(req.url, 'http://x');
  if (u.pathname === '/api/stats') {
    const s = scan();
    const { _lists, ...pub } = s; // don't ship every list on every poll; drill fetches lists on demand
    res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(pub));
  }
  if (u.pathname === '/api/list') {
    const kind = u.searchParams.get('kind'); const s = scan();
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify({ kind, items: (s._lists[kind] || []).slice().sort() }));
  }
  if (u.pathname === '/api/scrapers') {
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify(scraperStaleness()));
  }
  if (u.pathname === '/api/drill') {
    const type = u.searchParams.get('type') || '';
    const id = u.searchParams.get('id') || '';
    let out;
    try { out = dcache(type + '|' + id, () => drill(type, id)); }
    catch (e) { out = { title: type + ' · ' + id, breadcrumb: type + '/' + id, children: [], error: String(e && e.message || e) }; }
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify(out));
  }
  res.writeHead(200, { 'content-type': 'text/html' }); res.end(PAGE);
});

const PORT = process.env.PORT || 9768;
server.listen(PORT, () => console.log(`stack-map viewer → http://127.0.0.1:${PORT}  (admin/DW2024!)`));