← back to build-pages

server.js

1541 lines

// build-pages — a live build-journal site. Every project Steve ships gets a
// page surfacing the full git history with full diffs, file tree, message
// search, and auto-extracted "ideas / agents / skills" parsed from commit
// bodies. No DB — git is the source of truth, queried on demand.

const express = require('express');
const compression = require('compression');
const path = require('path');
const fs = require('fs');
const { execFile } = require('child_process');
const { promisify } = require('util');
const exec = promisify(execFile);

const PORT = parseInt(process.env.PORT || '9700', 10);
const HOME = process.env.HOME;

// Boot-cached version metadata so /api/version stays sub-1ms. git SHA is
// best-effort — falls back to 'unknown' if git isn't on PATH at startup.
const PKG_VERSION = (() => {
  try { return require('./package.json').version || '0.0.0'; }
  catch (_) { return '0.0.0'; }
})();
const GIT_SHA = (() => {
  try {
    return require('child_process')
      .execSync('git rev-parse --short HEAD', { cwd: __dirname, stdio: ['ignore', 'pipe', 'ignore'] })
      .toString().trim() || 'unknown';
  } catch (_) { return 'unknown'; }
})();
const STARTED_AT_ISO = new Date().toISOString();

// CURATED registry: kebab-slug → {name, repo, blurb}. These get a hand-written
// name + blurb and sort first (featured). Everything else is AUTO-DISCOVERED from
// ~/Projects (any top-level dir with a .git). Curated entries override the
// auto-discovered one for the same repo, so the nice copy wins.
const CURATED = {
  butlr: {
    name: 'Butlr',
    repo: path.join(HOME, 'Projects/holdforme'),
    blurb: 'We wait on hold so you don\'t have to. Twilio Voice + Whisper transcripts + optional AI agent.',
    aliases: ['holdforme'],
  },
  asseeninmovies: {
    name: 'AsSeenInMovies',
    repo: path.join(HOME, 'Projects/asseeninmovies'),
    blurb: 'IMDb-class catalog with the "SPOTTED" community layer — what fictional decor / fashion / cars are real, and where to buy them.',
    aliases: ['asim'],
  },
  starsofdesign: {
    name: 'Stars of Design',
    repo: path.join(HOME, 'Projects/starsofdesign'),
    blurb: 'A curated directory of the most influential interior designers + their firms + their real DW clients.',
    aliases: ['sod'],
  },
  'build-pages': {
    name: 'build-pages',
    repo: __dirname,
    blurb: 'Live git-as-DB build journal for every shipped project. The page you\'re reading.',
    aliases: [],
  },
};

// "small-business-builder" → "Small Business Builder" (keeps ALLCAPS acronyms).
function prettyName(slug) {
  return String(slug).replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

// Build the live registry: every top-level ~/Projects dir with a .git, plus the
// curated overrides. Curated repos are keyed by path so an entry whose slug ≠
// dirname (e.g. butlr → holdforme) isn't also listed under its raw dirname.
function discoverProjects() {
  const base = path.join(HOME, 'Projects');
  const out = {};
  const curatedRepos = new Set(Object.values(CURATED).map((p) => p.repo));
  let dirents = [];
  try { dirents = fs.readdirSync(base, { withFileTypes: true }); } catch (_) { /* fall through to curated-only */ }
  for (const d of dirents) {
    if (!d.isDirectory()) continue;
    if (d.name.startsWith('_')) continue;                 // skip scratch/archive dirs (_overnight, _shared, _archive…)
    const repo = path.join(base, d.name);
    if (curatedRepos.has(repo)) continue;                 // a curated entry already covers this repo
    if (!fs.existsSync(path.join(repo, '.git'))) continue; // only real git repos
    out[d.name] = { name: prettyName(d.name), repo, blurb: '', aliases: [], discovered: true };
  }
  for (const [slug, p] of Object.entries(CURATED)) out[slug] = { ...p, featured: true };
  return out;
}
const PROJECTS = discoverProjects();

// ─────────────────────────────────────────────────────────────────────────────
// git helpers — narrow, single-purpose, all run with cwd=repo. Each returns a
// JSON-friendly shape so the routes are just glue.
// ─────────────────────────────────────────────────────────────────────────────
async function gitRun(repo, args) {
  const { stdout } = await exec('git', args, { cwd: repo, maxBuffer: 50 * 1024 * 1024 });
  return stdout;
}

// Per-repo cache keyed on HEAD SHA. Probing HEAD is ~5ms; rebuilding the full
// commits+files+facets bundle is 50-80ms. A page hit on an unchanged repo
// drops from ~80ms to ~10ms once warm. The HEAD probe still runs every hit
// so a `git commit` in the source repo shows up on the next refresh.
const _projectCache = new Map();
// With ~540 auto-discovered repos, probing HEAD on every hit would spawn 540 git
// processes per request. So the REQUEST path serves the warm cache untouched for
// BUNDLE_TTL; a background warmer (force=true) is the only thing that re-checks
// HEAD and rebuilds changed repos. Keep BUNDLE_TTL > the warm interval so a
// request never falls through to git.
const BUNDLE_TTL = 100_000;
async function getProjectBundle(slug, p, force = false) {
  const cached = _projectCache.get(slug);
  if (!force && cached && Date.now() - cached.at < BUNDLE_TTL) return cached;
  let head;
  try { head = (await gitRun(p.repo, ['rev-parse', 'HEAD'])).trim(); }
  catch (_) { return cached || null; } // empty / broken repo — keep any prior bundle
  if (cached && cached.head === head) { cached.at = Date.now(); return cached; }
  const commits = await listCommits(p.repo);
  const facets  = extractFacets(commits);
  const files   = await listFiles(p.repo);
  const bundle  = { head, commits, facets, files, at: Date.now() };
  _projectCache.set(slug, bundle);
  return bundle;
}

// Bounded-concurrency fan-out so a fleet-wide sweep never launches 540 git
// processes at once.
async function mapLimit(items, limit, fn) {
  const out = new Array(items.length);
  let i = 0;
  const run = async () => { while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); } };
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
  return out;
}

// Background warmer — keeps every bundle warm OFF the request path. Runs at boot
// and on an interval (< BUNDLE_TTL) so user requests only ever read cache.
async function warmAll() {
  await mapLimit(Object.entries(PROJECTS), 12, ([slug, p]) => getProjectBundle(slug, p, true).catch(() => null));
}

async function listCommits(repo) {
  const fmt = '%H%x01%h%x01%ai%x01%an%x01%s%x01%b%x02';
  const raw = await gitRun(repo, ['log', '--no-merges', `--pretty=format:${fmt}`]);
  return raw
    .split('\x02\n')
    .map(s => s.trim())
    .filter(Boolean)
    .map(line => {
      const [hash, shortHash, dateISO, author, subject, body = ''] = line.split('\x01');
      return { hash, shortHash, dateISO, author, subject, body: body.replace(/\x02$/, '') };
    });
}

async function commitDetail(repo, hash) {
  const log = await gitRun(repo, ['log', '-1', '--pretty=format:%H%n%ai%n%an%n%s%n%n%b', hash]);
  const lines = log.split('\n');
  const out = {
    hash:    lines[0],
    dateISO: lines[1],
    author:  lines[2],
    subject: lines[3],
    body:    lines.slice(5).join('\n').trim(),
  };
  out.diff  = await gitRun(repo, ['show', '--no-color', '--stat', '--patch', hash]);
  out.files = (await gitRun(repo, ['show', '--name-status', '--pretty=format:', hash])).trim().split('\n').filter(Boolean);
  return out;
}

async function listFiles(repo) {
  const raw = await gitRun(repo, ['ls-files']);
  return raw.split('\n').filter(Boolean);
}

async function readBlob(repo, ref, file) {
  return gitRun(repo, ['show', `${ref}:${file}`]);
}

// ─────────────────────────────────────────────────────────────────────────────
// Idea / agent / skill extraction. Long commit bodies on Steve's projects often
// embed the design rationale + which sub-agent or skill was used. Parse those
// out so the doc page surfaces them as first-class facets.
// ─────────────────────────────────────────────────────────────────────────────
function extractFacets(commits) {
  const agentRe = /\b(claude-code-guide|domain-name-agent|lawyer-build-agent|doctor-agent|secrets-manager|exa-agent|search-specialist|technical-researcher|website-analysis|debate-team-fast|abramstasks|prompt-engineer|backend-architect|frontend-developer|code-reviewer|security-auditor|debugger|database-architect|test-engineer|ai-engineer|architect-reviewer|deployment-engineer|comms-compliance|vp-engineering|vp-operations|vp-dw-commerce|vp-compliance-policy|vp-directories|vp-research-content)\b/gi;
  const skillRe = /\/([a-z][a-z0-9-]{2,40})\b/g;
  const agents = new Map();
  const skills = new Map();
  const ideas = [];
  for (const c of commits) {
    const blob = `${c.subject}\n${c.body}`;
    for (const m of blob.matchAll(agentRe)) {
      const a = m[1].toLowerCase();
      agents.set(a, (agents.get(a) || 0) + 1);
    }
    for (const m of blob.matchAll(skillRe)) {
      const s = m[1];
      // Filter out path-y false positives — we want skill names like /loop /secrets, not /tmp /api /lib
      if (/^(tmp|api|var|usr|etc|root|home|opt|public|src|lib|node|bin|dist|build|test|tests|css|js|img|files|email|sms|admin)$/.test(s)) continue;
      skills.set(s, (skills.get(s) || 0) + 1);
    }
    // "creative idea" = body lines beginning with a numbered list, dash, or
    // containing words like "idea/perfect/polish/scaffold" — surface them.
    if (c.body.length > 120) {
      ideas.push({ hash: c.shortHash, dateISO: c.dateISO, subject: c.subject, body: c.body });
    }
  }
  const sort = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ({ name: k, count: v }));
  return { agents: sort(agents), skills: sort(skills), ideas };
}

// ─────────────────────────────────────────────────────────────────────────────
// HTML shell + escaping. The page is static-rendered server-side, then a small
// inline script wires up the client-side search filter (no build step).
// ─────────────────────────────────────────────────────────────────────────────
function esc(s) {
  return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

// Wrap each line of an escaped diff with a class based on its first char so
// CSS can color +/- lines distinctively. `+++/---/@@` are file/hunk headers,
// not change lines. Operates on already-escaped output to avoid double-escape.
function highlightDiff(escDiff) {
  return escDiff.split('\n').map(line => {
    if (line.startsWith('+++') || line.startsWith('---')) return `<span class="bp-d-h">${line}</span>`;
    if (line.startsWith('@@'))                              return `<span class="bp-d-h2">${line}</span>`;
    if (line.startsWith('+'))                               return `<span class="bp-d-add">${line}</span>`;
    if (line.startsWith('-'))                               return `<span class="bp-d-rm">${line}</span>`;
    return line;
  }).join('\n');
}

function shell(title, body, headExtra = '', meta = {}) {
  const canonical = meta.canonical || 'https://projects.agentabrams.com/';
  const desc      = meta.desc      || 'Live build journals for every project — every commit, every line of design rationale, searchable.';
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${esc(title)}</title>
  <meta name="description" content="${esc(desc)}">
  <meta name="color-scheme" content="light dark">
  <meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)">
  <meta name="theme-color" content="#0d0c0a" media="(prefers-color-scheme: dark)">
  <link rel="canonical" href="${esc(canonical)}">
  <link rel="search" type="application/opensearchdescription+xml" title="build-pages" href="/opensearch.xml">
  <meta property="og:title" content="${esc(title)}">
  <meta property="og:description" content="${esc(desc)}">
  <meta property="og:type" content="website">
  <meta property="og:url" content="${esc(canonical)}">
  <meta name="twitter:card" content="summary">
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23111'/><text x='16' y='22' font-family='Georgia,serif' font-size='20' fill='%23ffd633' text-anchor='middle'>BP</text></svg>">
  <link rel="stylesheet" href="/css/site.css">
  ${headExtra}
</head>
<body>
  <header class="bp-hdr">
    <a class="bp-logo" href="/">build-pages</a>
    <nav class="bp-nav">
      <a href="/">all projects</a>
    </nav>
  </header>
  <main class="bp-main">
    ${body}
  </main>
  <footer class="bp-ftr">
    <span>git is the source of truth — this page is rendered live from <code>git log</code>.</span>
    <span class="bp-ftr-sep">·</span>
    <a href="/about">about</a>
    <span class="bp-ftr-sep">·</span>
    <a href="/privacy">privacy</a>
    <span class="bp-ftr-sep">·</span>
    <a href="/terms">terms</a>
    <span class="bp-ftr-sep">·</span>
    <a href="/api">JSON API</a>
    <span class="bp-ftr-sep">·</span>
    <a href="/sitemap.xml">sitemap</a>
    <span class="bp-ftr-sep">·</span>
    <a href="/fleet.csv">fleet.csv</a>
  </footer>
</body>
</html>`;
}

// ─────────────────────────────────────────────────────────────────────────────
// Routes
// ─────────────────────────────────────────────────────────────────────────────
const app = express();
app.disable('x-powered-by');
app.use(compression());
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
  // X-Response-Time — machine-readable companion to the home-page footer.
  // Surfaces in DevTools and any reverse proxy / log pipeline.
  const t0 = process.hrtime.bigint();
  res.on('finish', () => { /* finish-only; setHeader after-flush would no-op */ });
  const _setHeader = res.setHeader.bind(res);
  res.setHeader = function (...args) { return _setHeader(...args); };
  res.on('close', () => {});
  // Compute on the way out via res.writeHead override — simpler than tracking
  // every send path. ms with 1 decimal so sub-millisecond responses show up.
  const _writeHead = res.writeHead.bind(res);
  res.writeHead = function (...args) {
    const ms = Number(process.hrtime.bigint() - t0) / 1e6;
    try { res.setHeader('X-Response-Time', ms.toFixed(1) + 'ms'); } catch (_) {}
    return _writeHead(...args);
  };
  next();
});
// Snapshot-path guard — runs before express.static so editor backup files
// (.bak, .bak.<n>, .pre-<tag>, .orig, .rej, ~) can never be served from any
// public mount, even if one accidentally lands in public/. Returns 404 with
// no body so probes don't even learn the path shape.
app.use((req, res, next) => {
  if (/(\.bak(\.|$)|\.pre-|\.orig$|\.rej$|~$)/i.test(req.path)) {
    return res.status(404).end();
  }
  next();
});
app.use('/css', express.static(path.join(__dirname, 'public/css'), { maxAge: '1h' }));

// favicon.svg — same brand mark as the data: URI in <head>, but served as
// a real URL so OpenSearch <Image>, Slack/Discord link unfurling, and any
// "where's the icon file" tool can find it.
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#111"/><text x="16" y="22" font-family="Georgia,serif" font-size="20" fill="#ffd633" text-anchor="middle">BP</text></svg>`;
app.get('/favicon.svg', (_req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=86400');
  res.type('image/svg+xml').send(FAVICON_SVG);
});
app.get('/favicon.ico', (_req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=86400');
  res.type('image/svg+xml').send(FAVICON_SVG);
});

// robots.txt — let everything be crawled. /api/* is JSON discovery, public.
app.get('/robots.txt', (_req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=86400');
  res.type('text/plain').send('User-agent: *\nAllow: /\nSitemap: https://projects.agentabrams.com/sitemap.xml\n');
});

// sitemap.xml — home + every /p/:slug. Commit-detail pages are excluded; with
// 200+ commits across the fleet they'd dwarf the discoverable surface without
// adding crawler value. Last-Modified mirrors the most recent commit per repo.
app.get('/sitemap.xml', async (_req, res, next) => {
  try {
    const base = 'https://projects.agentabrams.com';
    const today = new Date().toISOString().slice(0, 10);
    const urls = [
      `<url><loc>${base}/</loc><lastmod>${today}</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url>`,
      `<url><loc>${base}/about</loc><lastmod>${today}</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>`,
      `<url><loc>${base}/privacy</loc><lastmod>${today}</lastmod><changefreq>yearly</changefreq><priority>0.3</priority></url>`,
      `<url><loc>${base}/terms</loc><lastmod>${today}</lastmod><changefreq>yearly</changefreq><priority>0.3</priority></url>`,
    ];
    for (const slug of Object.keys(PROJECTS)) {
      const bundle = await getProjectBundle(slug, PROJECTS[slug]).catch(() => null);
      const lastmod = (bundle && bundle.commits[0]) ? bundle.commits[0].dateISO.slice(0, 10) : today;
      urls.push(`<url><loc>${base}/p/${slug}</loc><lastmod>${lastmod}</lastmod><changefreq>daily</changefreq><priority>0.8</priority></url>`);
    }
    const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n  ${urls.join('\n  ')}\n</urlset>\n`;
    res.setHeader('Cache-Control', 'public, max-age=21600, stale-while-revalidate=86400');
    res.setHeader('Last-Modified', new Date().toUTCString());
    res.type('application/xml').send(xml);
  } catch (e) { next(e); }
});

app.get('/healthz', (_req, res) => {
  res.setHeader('Cache-Control', 'no-store');
  res.json({ ok: true, ts: Date.now() });
});

// /api/health — liveness + project count snapshot. Mirrors the asim/sod shape
// so fleet-status.sh can render every site through one digest path. Includes
// system loadavg (1m/5m/15m) so external monitors can catch host-overload
// scenarios (we hit load=71 earlier in this session before noticing manually).
app.get('/api/health', (_req, res) => {
  res.setHeader('Cache-Control', 'no-store');
  const [l1, l5, l15] = require('os').loadavg();
  res.json({
    ok:        true,
    counts:    { projects: Object.keys(PROJECTS).length },
    uptime_s:  Math.floor(process.uptime()),
    loadavg:   { '1m': +l1.toFixed(2), '5m': +l5.toFixed(2), '15m': +l15.toFixed(2) },
    ts:        Date.now(),
    as_of:     new Date().toISOString(),
  });
});

// /api/version — deploy fingerprint, sub-1ms (all resolved at boot).
app.get('/api/version', (_req, res) => {
  res.setHeader('Cache-Control', 'no-store');
  res.json({
    name:       'build-pages',
    version:    PKG_VERSION,
    git:        GIT_SHA,
    node:       process.version,
    started_at: STARTED_AT_ISO,
  });
});

// Index — all projects. Pulls counts from the per-repo bundle cache (shared
// with /p/:slug), so a warm index hit is one HEAD-probe per repo, not a full
// git rev-list scan each.
app.get('/', async (req, res, next) => {
  const t0 = Date.now();
  try {
    // Today-PT window — computed once, applied per project so each card shows
    // its own today contribution alongside fleet-wide totals.
    const tzPT = 'America/Los_Angeles';
    const todayStartMs = startOfDayInTz(todayInTz(tzPT), tzPT);
    const todayEndMs   = todayStartMs + 86400000;
    const entries = await mapLimit(Object.entries(PROJECTS), 16, async ([slug, p]) => {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      const count  = bundle ? bundle.commits.length : 0;
      const latest = bundle && bundle.commits[0] ? bundle.commits[0].dateISO.slice(0, 10) : '';
      const latestHash = bundle && bundle.commits[0] ? bundle.commits[0].shortHash : '';
      const ideas  = bundle ? bundle.facets.ideas.length : 0;
      let today    = 0;
      if (bundle) {
        for (const c of bundle.commits) {
          const t = new Date(c.dateISO).getTime();
          if (t >= todayStartMs && t < todayEndMs) today++;
          else if (t < todayStartMs) break; // commits are newest-first
        }
      }
      return { slug, p, count, latest, latestHash, ideas, today };
    });
    // Featured (curated) first, then alphabetical by name — 540 repos in raw
    // readdir order is unusable.
    entries.sort((a, b) => (b.p.featured ? 1 : 0) - (a.p.featured ? 1 : 0) || a.p.name.localeCompare(b.p.name));
    const totalCommits = entries.reduce((a, e) => a + e.count, 0);
    const totalIdeas   = entries.reduce((a, e) => a + e.ideas, 0);
    // Velocity counters + 7-day sparkline across the whole fleet. Concats
    // per-project commit lists so the helper sees every commit in one shot.
    const fleetCommits = entries.flatMap(e => _projectCache.get(e.slug)?.commits || []);
    // Today-in-PT + yesterday-PT counts — Steve's calendar days, not rolling-24h.
    // Lets Steve see "237 today, 47 yesterday" at a glance.
    const ptDay = (offsetDays) => {
      const tz = 'America/Los_Angeles';
      const baseStr = todayInTz(tz);
      const dayStartMs = startOfDayInTz(baseStr, tz) + offsetDays * 86400000;
      const endMs = dayStartMs + 86400000;
      const dateStr = new Date(dayStartMs + 43200000).toLocaleString('sv-SE', { timeZone: tz }).slice(0, 10);
      let n = 0;
      for (const c of fleetCommits) {
        const t = new Date(c.dateISO).getTime();
        if (t >= dayStartMs && t < endMs) n++;
      }
      return { date: dateStr, count: n };
    };
    const todayPT     = ptDay(0);
    const yesterdayPT = ptDay(-1);
    const delta       = todayPT.count - yesterdayPT.count;
    const deltaSpan   = delta > 0
      ? `<span class="bp-delta bp-delta-up" title="${delta} more than yesterday">↑${delta}</span>`
      : delta < 0
        ? `<span class="bp-delta bp-delta-dn" title="${Math.abs(delta)} fewer than yesterday">↓${Math.abs(delta)}</span>`
        : `<span class="bp-delta bp-delta-eq" title="same as yesterday">—</span>`;
    const fleetSpark = sparkline(fleetCommits);
    const sparkChars = fleetSpark.spark;
    const dayBuckets = fleetSpark.buckets;
    const last24 = fleetSpark.last24;
    const last7d = fleetSpark.last7d;
    const rows = entries.map(({ slug, p, count, latest, latestHash, ideas, today }) => `<li class="bp-card">
        <a href="/p/${esc(slug)}"><h2>${esc(p.name)}</h2></a>
        <p>${esc(p.blurb)}</p>
        <p class="bp-meta">${count} commits · ${ideas} ideas · latest ${latestHash ? `<a href="/p/${esc(slug)}/c/${esc(latestHash)}" title="open commit ${esc(latestHash)}">${esc(latest)}</a>` : esc(latest)}${today > 0 ? ` · <span class="bp-today" title="${today} commits today (PT)">${today} today</span>` : ''}</p>
      </li>`).join('\n');
    const jsonld = {
      '@context': 'https://schema.org',
      '@type':    'WebSite',
      url:        'https://projects.agentabrams.com/',
      name:       'build-pages',
      description:'Live build journals for every project — every commit, every line of design rationale, searchable.',
    };
    const itemList = {
      '@context': 'https://schema.org',
      '@type':    'ItemList',
      itemListElement: entries.map((e, i) => ({
        '@type': 'ListItem',
        position: i + 1,
        url:      `https://projects.agentabrams.com/p/${e.slug}`,
        name:     e.p.name,
      })),
    };
    // Recent across the fleet — first 10 commits date-sorted across all
    // projects. Server-rendered (not client-fetched) so the page is useful
    // without JS. Same bundle cache, so essentially free.
    const recent = [];
    for (const e of entries) {
      const bundle = await getProjectBundle(e.slug, e.p).catch(() => null);
      if (!bundle) continue;
      for (const c of bundle.commits.slice(0, 10)) {
        recent.push({ slug: e.slug, name: e.p.name, hash: c.shortHash, date: c.dateISO, subject: c.subject });
      }
    }
    recent.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
    const recentRows = recent.slice(0, 10).map(c => `<li>
      <a href="/p/${esc(c.slug)}/c/${esc(c.hash)}"><code>${esc(c.hash)}</code></a>
      <span class="bp-d">${esc(c.date.slice(0, 10))}</span>
      <span class="bp-s"><strong>${esc(c.name)}</strong> — ${esc(c.subject)}</span>
    </li>`).join('');

    res.send(shell('build-pages — all projects', `
      <h1>build journals</h1>
      <p class="bp-lede">Every build, every commit, every line. Click into a project to see how it actually got made.</p>
      <p class="bp-meta">${entries.length} projects · ${totalCommits} commits · ${totalIdeas} design-rationale ideas surfaced</p>
      <p class="bp-meta">today (PT): <strong>${todayPT.count}</strong> ${deltaSpan} · yesterday: <strong>${yesterdayPT.count}</strong> · last 24h: <strong>${last24}</strong> · last 7d: <strong>${last7d}</strong> · 7-day shape <code class="bp-spark" title="${dayBuckets.join(' / ')}">${sparkChars}</code> · ${(() => {
        const [l1] = require('os').loadavg();
        const cls = l1 < 8 ? 'bp-load-ok' : l1 < 20 ? 'bp-load-warn' : 'bp-load-hot';
        return `host load <span class="bp-load ${cls}">${l1.toFixed(1)}</span> (1m)`;
      })()}</p>

      <section class="bp-section">
        <h2>Recent across the fleet</h2>
        <ol id="bp-recent" class="bp-commits">${recentRows}</ol>
        <p id="bp-recent-meta" class="bp-meta">auto-refreshes every 60s</p>
      </section>

      <section class="bp-section">
        <h2>Deepest rationale</h2>
        <p class="bp-meta">Top 5 commits by body length — the design-rationale essays that explain WHY, not just WHAT.</p>
        <ol class="bp-commits">${(() => {
          const all = [];
          for (const e of entries) {
            const b = _projectCache.get(e.slug); if (!b) continue;
            for (const c of b.commits) {
              if (c.body.length >= 200) all.push({ slug: e.slug, name: e.p.name, hash: c.shortHash, date: c.dateISO, subject: c.subject, len: c.body.length });
            }
          }
          all.sort((a, b) => b.len - a.len);
          return all.slice(0, 5).map(c => `<li>
            <a href="/p/${esc(c.slug)}/c/${esc(c.hash)}"><code>${esc(c.hash)}</code></a>
            <span class="bp-d">${esc(c.date.slice(0,10))}</span>
            <span class="bp-s"><strong>${esc(c.name)}</strong> · <em>${c.len}c</em> — ${esc(c.subject)}</span>
          </li>`).join('');
        })()}</ol>
      </section>

      <section class="bp-section">
        <h2>Top skills + agents across the fleet</h2>
        <p class="bp-meta">Auto-extracted from commit messages — how often each skill/agent has been invoked across all ${entries.length} build journals.</p>
        <div class="bp-grid">
          <div>
            <h3 style="font-size:15px;margin:0 0 10px;">Skills</h3>
            <ul class="bp-chips">${(() => {
              const tallies = new Map();
              for (const e of entries) {
                const b = _projectCache.get(e.slug);
                if (!b) continue;
                for (const f of b.facets.skills) tallies.set(f.name, (tallies.get(f.name) || 0) + f.count);
              }
              return [...tallies.entries()].sort((a,b)=>b[1]-a[1]).slice(0,15).map(([n,c]) => `<li><span>/${esc(n)}</span><b>${c}</b></li>`).join('');
            })()}</ul>
          </div>
          <div>
            <h3 style="font-size:15px;margin:0 0 10px;">Agents</h3>
            <ul class="bp-chips">${(() => {
              const tallies = new Map();
              for (const e of entries) {
                const b = _projectCache.get(e.slug);
                if (!b) continue;
                for (const f of b.facets.agents) tallies.set(f.name, (tallies.get(f.name) || 0) + f.count);
              }
              const out = [...tallies.entries()].sort((a,b)=>b[1]-a[1]).slice(0,15);
              return out.length ? out.map(([n,c]) => `<li><span>${esc(n)}</span><b>${c}</b></li>`).join('') : '<li class="bp-meta">none detected</li>';
            })()}</ul>
          </div>
        </div>
      </section>

      <section class="bp-section">
        <h2>Search the whole fleet</h2>
        <input id="fleet-q" class="bp-q" type="search" placeholder="search every commit across every project…" autocomplete="off" value="${esc(typeof req.query.q === 'string' ? req.query.q : '')}">
        <p id="fleet-tally" class="bp-meta">type ≥ 2 characters to search</p>
        <ol id="fleet-hits" class="bp-commits"></ol>
      </section>

      <h2>Projects</h2>
      <ul class="bp-cards">${rows}</ul>
      <p class="bp-meta" style="text-align:center;margin-top:30px">rendered in ${Date.now() - t0}ms</p>
    `, `
      <script type="application/ld+json">${JSON.stringify(jsonld)}</script>
      <script type="application/ld+json">${JSON.stringify(itemList)}</script>
      <script>
        const fq = document.getElementById('fleet-q');
        const ft = document.getElementById('fleet-tally');
        const fh = document.getElementById('fleet-hits');
        let pending = null;
        async function doFleetSearch() {
          const q = fq.value.trim();
          if (q.length < 2) { ft.textContent = 'type ≥ 2 characters to search'; fh.innerHTML = ''; return; }
          ft.textContent = 'searching…';
          const r = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=50');
          const data = await r.json();
          const breakdown = data.by_project
            .filter(p => p.count > 0)
            .map(p => \`\${p.name} \${p.count}\`)
            .join(' · ');
          ft.textContent = breakdown
            ? \`\${data.total} hits — \${breakdown} (showing \${data.shown})\`
            : 'no hits';
          fh.innerHTML = data.results.map(h => \`<li>
            <a href="/p/\${h.slug}/c/\${h.hash}"><code>\${h.hash}</code></a>
            <span class="bp-d">\${h.date}</span>
            <span class="bp-s"><strong>\${h.project}</strong> — \${h.subject.replace(/</g,'&lt;')}</span>
          </li>\`).join('');
        }
        fq.addEventListener('input', () => {
          if (pending) clearTimeout(pending);
          pending = setTimeout(doFleetSearch, 150);
          const url = new URL(window.location.href);
          if (fq.value.trim()) url.searchParams.set('q', fq.value);
          else url.searchParams.delete('q');
          history.replaceState(null, '', url);
        });
        // Fire once on load if ?q= was server-rendered into the input.
        if (fq.value.trim().length >= 2) doFleetSearch();

        // Auto-refresh the "Recent across the fleet" rail every 60s so a fresh
        // commit in any project shows up without a full reload. Bails silently
        // on fetch failure so a transient network blip doesn't blank the rail.
        async function refreshRecent() {
          try {
            const r = await fetch('/api/recent?limit=10');
            if (!r.ok) return;
            const data = await r.json();
            const ol = document.getElementById('bp-recent');
            const meta = document.getElementById('bp-recent-meta');
            ol.innerHTML = data.results.map(h => \`<li>
              <a href="/p/\${h.slug}/c/\${h.hash}"><code>\${h.hash}</code></a>
              <span class="bp-d">\${h.date.slice(0,10)}</span>
              <span class="bp-s"><strong>\${h.project}</strong> — \${h.subject.replace(/</g,'&lt;')}</span>
            </li>\`).join('');
            meta.textContent = 'last refreshed ' + new Date().toLocaleTimeString();
          } catch (_) {}
        }
        setInterval(refreshRecent, 60_000);
      </script>
    `));
  } catch (e) { next(e); }
});

// Project page — commits + facets + search + file tree.
app.get('/p/:slug', async (req, res, next) => {
  const p = PROJECTS[req.params.slug];
  if (!p) return res.status(404).send(shell('Not found', '<h1>404</h1><p>No such project.</p>'));
  const t0 = Date.now();
  try {
    const { commits, facets, files } = await getProjectBundle(req.params.slug, p);
    // Prev/next links across the registry — lets you cycle through journals
    // without bouncing back to the home page each time. Wraps at the ends.
    const slugs = Object.keys(PROJECTS);
    const idx = slugs.indexOf(req.params.slug);
    const prevSlug = slugs[(idx - 1 + slugs.length) % slugs.length];
    const nextSlug = slugs[(idx + 1) % slugs.length];
    const idxData = JSON.stringify(commits.map(c => ({
      h: c.shortHash, d: c.dateISO.slice(0, 10), s: c.subject, b: c.body.slice(0, 4000),
    })));
    const ideasHtml = facets.ideas.slice(0, 50).map(i => `
      <details class="bp-idea">
        <summary><span class="bp-h">${esc(i.hash)}</span> · <span class="bp-d">${esc(i.dateISO.slice(0, 10))}</span> · ${esc(i.subject)}</summary>
        <pre>${esc(i.body)}</pre>
      </details>`).join('\n');
    const fileLis = files.slice(0, 500).map(f => `<li><a href="/p/${esc(req.params.slug)}/file?path=${encodeURIComponent(f)}">${esc(f)}</a></li>`).join('');
    const projectJsonld = {
      '@context':    'https://schema.org',
      '@type':       'SoftwareSourceCode',
      name:          p.name,
      description:   p.blurb,
      url:           `https://projects.agentabrams.com/p/${req.params.slug}`,
      codeRepository:`https://projects.agentabrams.com/p/${req.params.slug}`,
      programmingLanguage: 'JavaScript',
      dateModified:  commits[0] ? new Date(commits[0].dateISO).toISOString() : new Date().toISOString(),
    };
    res.send(shell(`${p.name} — build journal`, `
      <script type="application/ld+json">${JSON.stringify(projectJsonld)}</script>
      <h1>${esc(p.name)}</h1>
      <p class="bp-lede">${esc(p.blurb)}</p>
      <p class="bp-meta">repo: <code>${esc(p.repo.replace(HOME, '~'))}</code> · ${commits.length} commits · ${(() => {
        const s = sparkline(commits);
        return `<strong>${s.last24}</strong> in last 24h, <strong>${s.last7d}</strong> in last 7d · <code class="bp-spark" title="${s.buckets.join(' / ')}">${s.spark}</code>`;
      })()}</p>

      <section class="bp-section">
        <h2>Search the build</h2>
        <input id="q" class="bp-q" type="search" placeholder="filter commits by subject, body, file…" autocomplete="off" value="${esc(typeof req.query.q === 'string' ? req.query.q : '')}">
        <p id="bp-tally" class="bp-meta">${commits.length} commits indexed</p>
        <ol id="bp-commits" class="bp-commits">
          ${commits.map(c => `
            <li id="${esc(c.shortHash)}" data-h="${esc(c.shortHash)}" data-blob="${esc((c.subject + ' ' + c.body).toLowerCase().slice(0, 1500))}">
              <a href="/p/${esc(req.params.slug)}/c/${esc(c.shortHash)}"><code>${esc(c.shortHash)}</code></a>
              <span class="bp-d">${esc(c.dateISO.slice(0, 10))}</span>
              <span class="bp-s">${esc(c.subject)}</span>
            </li>`).join('')}
        </ol>
      </section>

      <section class="bp-section">
        <h2>Authors</h2>
        <ul class="bp-chips">${(() => {
          const t = new Map();
          for (const c of commits) {
            const a = canonAuthor(c.author);
            t.set(a, (t.get(a) || 0) + 1);
          }
          return [...t.entries()].sort((a, b) => b[1] - a[1]).map(([n, c]) => `<li><span>${esc(n)}</span><b>${c}</b></li>`).join('');
        })()}</ul>
      </section>

      <section class="bp-section bp-grid">
        <div>
          <h2>Agents used</h2>
          <ul class="bp-chips">
            ${facets.agents.map(a => `<li><span>${esc(a.name)}</span><b>${a.count}</b></li>`).join('') || '<li class="bp-meta">none detected</li>'}
          </ul>
        </div>
        <div>
          <h2>Skills used</h2>
          <ul class="bp-chips">
            ${facets.skills.slice(0, 30).map(s => `<li><span>/${esc(s.name)}</span><b>${s.count}</b></li>`).join('') || '<li class="bp-meta">none detected</li>'}
          </ul>
        </div>
      </section>

      <section class="bp-section">
        <h2>Creative ideas + design notes</h2>
        <p class="bp-meta">Commits with substantial prose (≥120 chars) — the rationale behind each move.</p>
        ${ideasHtml}
      </section>

      <section class="bp-section">
        <h2>File tree</h2>
        <p class="bp-meta">${files.length} files tracked. Click any to browse the source at HEAD.</p>
        <ul class="bp-files">${fileLis}</ul>
      </section>

      <section class="bp-section">
        <h2>Other build journals</h2>
        <p class="bp-meta">
          <a href="/p/${esc(prevSlug)}">← ${esc(PROJECTS[prevSlug].name)}</a>
          &nbsp;·&nbsp;
          <a href="/">all 4 projects</a>
          &nbsp;·&nbsp;
          <a href="/p/${esc(nextSlug)}">${esc(PROJECTS[nextSlug].name)} →</a>
        </p>
      </section>

      <section class="bp-section">
        <h2>Export</h2>
        <p class="bp-meta">
          <a href="/p/${esc(req.params.slug)}/commits.csv">commits.csv</a> ·
          <a href="/p/${esc(req.params.slug)}/feed.atom">feed.atom</a> ·
          <a href="/api/projects/${esc(req.params.slug)}">project.json</a> ·
          <a href="/api/projects/${esc(req.params.slug)}/commits">commits.json</a>
        </p>
      </section>
      <p class="bp-meta" style="text-align:center;margin-top:30px">rendered in ${Date.now() - t0}ms</p>
    `, `
      <script>
        const DATA = ${idxData};
        const q = document.getElementById('q');
        const list = document.getElementById('bp-commits');
        const tally = document.getElementById('bp-tally');
        function applyFilter() {
          const term = q.value.toLowerCase().trim();
          let shown = 0;
          for (const li of list.children) {
            const match = !term || li.dataset.blob.includes(term);
            li.style.display = match ? '' : 'none';
            if (match) shown++;
          }
          tally.textContent = term ? \`\${shown} / \${DATA.length} match "\${q.value}"\` : \`\${DATA.length} commits indexed\`;
          // Reflect search state in URL so a filtered view is shareable. Use
          // replaceState so the back button still escapes the project page.
          const url = new URL(window.location.href);
          if (term) url.searchParams.set('q', q.value); else url.searchParams.delete('q');
          history.replaceState(null, '', url);
        }
        q.addEventListener('input', applyFilter);
        // Apply the initial ?q= from the server-rendered value field.
        if (q.value) applyFilter();
      </script>
    ` + `
      <link rel="alternate" type="application/atom+xml" title="${esc(p.name)} build journal" href="/p/${esc(req.params.slug)}/feed.atom">
    `, {
      canonical: `https://projects.agentabrams.com/p/${req.params.slug}`,
      desc: `${p.name} — ${p.blurb}`,
    }));
  } catch (e) { next(e); }
});

// Commit detail — full diff + body.
app.get('/p/:slug/c/:hash', async (req, res, next) => {
  const p = PROJECTS[req.params.slug];
  if (!p) return res.status(404).send(shell('Not found', '<h1>404</h1>'));
  try {
    const c = await commitDetail(p.repo, req.params.hash);
    // Walk-the-history nav — find this commit's position in the cached log and
    // expose prev/next (chronologically). Falls through gracefully if the
    // hash isn't in the cached list (shouldn't happen, but defensive).
    const bundle = await getProjectBundle(req.params.slug, p);
    const cIdx   = bundle.commits.findIndex(x => c.hash.startsWith(x.shortHash) || x.hash === c.hash);
    const prevC  = cIdx >= 0 && cIdx < bundle.commits.length - 1 ? bundle.commits[cIdx + 1] : null; // older
    const nextC  = cIdx > 0 ? bundle.commits[cIdx - 1] : null; // newer
    const crumbs = {
      '@context': 'https://schema.org',
      '@type':    'BreadcrumbList',
      itemListElement: [
        { '@type': 'ListItem', position: 1, name: 'build-pages', item: 'https://projects.agentabrams.com/' },
        { '@type': 'ListItem', position: 2, name: p.name,        item: `https://projects.agentabrams.com/p/${req.params.slug}` },
        { '@type': 'ListItem', position: 3, name: c.subject.slice(0, 80), item: `https://projects.agentabrams.com/p/${req.params.slug}/c/${req.params.hash}` },
      ],
    };
    res.send(shell(`${c.subject} — ${p.name}`, `
      <script type="application/ld+json">${JSON.stringify(crumbs)}</script>
      <p class="bp-meta"><a href="/p/${esc(req.params.slug)}">← back to ${esc(p.name)}</a></p>
      <h1>${esc(c.subject)}</h1>
      <p class="bp-meta"><code>${esc(c.hash)}</code> · ${esc(c.dateISO)} · ${esc(c.author)}</p>
      ${c.body ? `<section class="bp-body"><pre>${esc(c.body)}</pre></section>` : ''}
      <h2>Files touched</h2>
      <ul class="bp-files">${c.files.map(f => `<li><code>${esc(f)}</code></li>`).join('')}</ul>
      <h2>Diff</h2>
      <pre class="bp-diff">${highlightDiff(esc(c.diff))}</pre>

      <p class="bp-meta">
        ${prevC ? `<a href="/p/${esc(req.params.slug)}/c/${esc(prevC.shortHash)}">← ${esc(prevC.shortHash)} ${esc(prevC.subject.slice(0, 60))}</a>` : '<span>(oldest)</span>'}
        &nbsp;·&nbsp;
        <a href="/p/${esc(req.params.slug)}">back to ${esc(p.name)}</a>
        &nbsp;·&nbsp;
        ${nextC ? `<a href="/p/${esc(req.params.slug)}/c/${esc(nextC.shortHash)}">${esc(nextC.subject.slice(0, 60))} ${esc(nextC.shortHash)} →</a>` : '<span>(newest)</span>'}
      </p>
    `, {
      canonical: `https://projects.agentabrams.com/p/${req.params.slug}/c/${req.params.hash}`,
      desc: `${c.subject} — commit in ${p.name}`,
    }));
  } catch (e) { next(e); }
});

// /api/fleet/today — every commit landed on the calendar date passed via
// ?date=YYYY-MM-DD, or today (Pacific Time, Steve's zone) if omitted.
// Different from /api/recent?since=24h because it's a calendar boundary,
// not rolling. ?tz= can override (e.g. ?tz=UTC).
app.get('/api/fleet/today', async (req, res, next) => {
  const tz = typeof req.query.tz === 'string' ? req.query.tz : 'America/Los_Angeles';
  const dateStr = (typeof req.query.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.date))
    ? req.query.date
    : todayInTz(tz);
  const startMs = startOfDayInTz(dateStr, tz);
  const endMs = startMs + 86400000;
  try {
    const all = [];
    for (const [slug, p] of Object.entries(PROJECTS)) {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      if (!bundle) continue;
      for (const c of bundle.commits) {
        const t = new Date(c.dateISO).getTime();
        if (t >= startMs && t < endMs) {
          all.push({ slug, project: p.name, hash: c.shortHash, date: c.dateISO, author: canonAuthor(c.author), subject: c.subject });
        }
      }
    }
    all.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({ date: dateStr, tz, total: all.length, results: all });
  } catch (e) { next(e); }
});

// /api/fleet/longest — commits with the largest body length across the fleet.
// Long bodies are the "this is why we did it" essays — surfacing them as a
// browsable list makes the design-rationale layer discoverable.
app.get('/api/fleet/longest', async (req, res, next) => {
  const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
  try {
    const all = [];
    for (const [slug, p] of Object.entries(PROJECTS)) {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      if (!bundle) continue;
      for (const c of bundle.commits) {
        if (c.body.length >= 200) {
          all.push({
            slug, project: p.name, hash: c.shortHash, date: c.dateISO.slice(0, 10),
            subject: c.subject, body_len: c.body.length,
          });
        }
      }
    }
    all.sort((a, b) => b.body_len - a.body_len);
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.json({ limit, total: all.length, results: all.slice(0, limit) });
  } catch (e) { next(e); }
});

// /api/fleet/buckets — daily commit-count buckets for the last N days
// (?days=7, max 90), both per-project and fleet-wide. Useful for charting
// libraries that want raw counts rather than the ASCII sparkline. Each bucket
// is anchored to a calendar day (ISO yyyy-mm-dd) so callers can plot it.
app.get('/api/fleet/buckets', async (req, res, next) => {
  const days = Math.min(Math.max(parseInt(req.query.days, 10) || 7, 1), 90);
  try {
    const fleetCommits = [];
    const byProject = [];
    for (const [slug, p] of Object.entries(PROJECTS)) {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      if (!bundle) continue;
      fleetCommits.push(...bundle.commits);
      const s = sparkline(bundle.commits, days);
      byProject.push({ slug, name: p.name, buckets: s.buckets, spark: s.spark });
    }
    const fleet = sparkline(fleetCommits, days);
    // Label each bucket with its calendar date (oldest first → newest last).
    const labels = [];
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    for (let i = days - 1; i >= 0; i--) {
      const d = new Date(today.getTime() - i * 86400000);
      labels.push(d.toISOString().slice(0, 10));
    }
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.json({
      days,
      labels,
      fleet:      { buckets: fleet.buckets, spark: fleet.spark },
      by_project: byProject,
    });
  } catch (e) { next(e); }
});

// /api/fleet/authors — fleet-wide author tallies (post-alias-normalization).
app.get('/api/fleet/authors', async (_req, res, next) => {
  try {
    const tallies = new Map();
    for (const [slug, p] of Object.entries(PROJECTS)) {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      if (!bundle) continue;
      for (const c of bundle.commits) {
        const a = canonAuthor(c.author);
        tallies.set(a, (tallies.get(a) || 0) + 1);
      }
    }
    const out = [...tallies.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.json({ authors: out });
  } catch (e) { next(e); }
});

// /api/fleet/all — one-shot aggregate for dashboards that don't want to make
// 5 separate calls. Pulls velocity + top agents + top skills + recent commits
// in parallel. Each piece is already cache-warm via the per-repo bundle, so
// this lands in ~5-15ms after the first hit.
app.get('/api/fleet/all', async (_req, res, next) => {
  try {
    const now = Date.now();
    const dayMs = 24 * 60 * 60 * 1000;
    let last24 = 0, last7d = 0;
    const recent = [];
    const skillTallies = new Map(), agentTallies = new Map();
    const byProject = [];
    for (const [slug, p] of Object.entries(PROJECTS)) {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      if (!bundle) continue;
      let d24 = 0, d7 = 0;
      for (const c of bundle.commits) {
        const age = now - new Date(c.dateISO).getTime();
        if (age < dayMs) d24++;
        if (age < 7 * dayMs) d7++;
      }
      last24 += d24;
      last7d += d7;
      byProject.push({ slug, name: p.name, commits: bundle.commits.length, last_24h: d24, last_7d: d7 });
      for (const c of bundle.commits.slice(0, 10)) {
        recent.push({ slug, project: p.name, hash: c.shortHash, date: c.dateISO, subject: c.subject });
      }
      for (const f of bundle.facets.skills) skillTallies.set(f.name, (skillTallies.get(f.name) || 0) + f.count);
      for (const f of bundle.facets.agents) agentTallies.set(f.name, (agentTallies.get(f.name) || 0) + f.count);
    }
    recent.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
    const top = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15).map(([name, count]) => ({ name, count }));
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.json({
      projects: byProject,
      velocity: { last_24h: last24, last_7d: last7d },
      recent:   recent.slice(0, 10),
      top_skills: top(skillTallies),
      top_agents: top(agentTallies),
      as_of: new Date().toISOString(),
    });
  } catch (e) { next(e); }
});

// /api/fleet/velocity — commit counts in rolling windows across every project.
// Returns total + per-project breakdown for last-24h and last-7d. Useful for
// "is the work continuing?" dashboards. Same cache lifetime as /api/fleet/*.
app.get('/api/fleet/velocity', async (_req, res, next) => {
  try {
    const now = Date.now();
    const dayMs = 24 * 60 * 60 * 1000;
    const out = { last_24h: 0, last_7d: 0, by_project: [] };
    for (const [slug, p] of Object.entries(PROJECTS)) {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      if (!bundle) continue;
      let d24 = 0, d7 = 0;
      for (const c of bundle.commits) {
        const age = now - new Date(c.dateISO).getTime();
        if (age < dayMs)     d24++;
        if (age < 7 * dayMs) d7++;
      }
      out.last_24h += d24;
      out.last_7d  += d7;
      out.by_project.push({ slug, name: p.name, last_24h: d24, last_7d: d7 });
    }
    out.by_project.sort((a, b) => b.last_7d - a.last_7d);
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.json({ ...out, as_of: new Date().toISOString() });
  } catch (e) { next(e); }
});

// /api/fleet/skills + /api/fleet/agents — aggregated facet counts across every
// project. Merges per-project tallies into a fleet-wide ranking so the user
// can see "the secrets skill is used 17 times across the fleet" without
// fetching 4 endpoints and summing client-side.
for (const facet of ['agents', 'skills']) {
  app.get(`/api/fleet/${facet}`, async (_req, res, next) => {
    try {
      const tallies = new Map();
      for (const [slug, p] of Object.entries(PROJECTS)) {
        const bundle = await getProjectBundle(slug, p).catch(() => null);
        if (!bundle) continue;
        for (const f of bundle.facets[facet]) {
          tallies.set(f.name, (tallies.get(f.name) || 0) + f.count);
        }
      }
      const merged = [...tallies.entries()]
        .map(([name, count]) => ({ name, count }))
        .sort((a, b) => b.count - a.count);
      res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
      res.json({ [facet]: merged });
    } catch (e) { next(e); }
  });
}

// /api/recent — most recent N commits across the entire fleet, merged + sorted
// by date desc. Powers a "today across the fleet" rail. Shares the bundle
// cache so it's free after the first warm.
// Render a Unicode block-char sparkline of daily commit counts over the last
// N days for a given commits[] array. Returns { spark, buckets, last24, last7d }.
// Used by both home (fleet-wide) and /p/:slug (per-project) so the rendering
// logic stays in one place.
const _sparkBlocks = ['▁','▂','▃','▄','▅','▆','▇','█'];
function sparkline(commits, days = 7) {
  const now = Date.now();
  const dayMs = 86400000;
  const buckets = new Array(days).fill(0);
  let last24 = 0, last7d = 0;
  for (const c of commits) {
    const age = now - new Date(c.dateISO).getTime();
    if (age < dayMs)      last24++;
    if (age < 7 * dayMs)  last7d++;
    if (age < days * dayMs && age >= 0) {
      const idx = (days - 1) - Math.floor(age / dayMs);
      if (idx >= 0 && idx < days) buckets[idx]++;
    }
  }
  const max = Math.max(...buckets, 1);
  const spark = buckets.map(n => n === 0 ? ' ' : _sparkBlocks[Math.min(7, Math.floor((n / max) * 7))]).join('');
  return { spark, buckets, last24, last7d };
}

// Normalize git author strings. Steve commits as "SteveStudio2", "Steve",
// "Steve Abrams" — same person, different .gitconfig values on different
// machines. Map these to a canonical name so the authors[] tally doesn't
// fragment. Extensible — add more aliases here as new people come in.
const AUTHOR_ALIASES = {
  'stevestudio2': 'Steve',
  'steve abrams': 'Steve',
  'steveabrams': 'Steve',
  'steve@designerwallcoverings.com': 'Steve',
};
function canonAuthor(name) {
  if (!name) return 'unknown';
  const key = name.toLowerCase().trim();
  return AUTHOR_ALIASES[key] || name;
}

// Resolve the start-of-day epoch-ms for a YYYY-MM-DD date in the given IANA
// timezone. Steve's wall clock is PT; UTC midnight at PT-evening straddles
// "today" weirdly. This computes the same boundary the user sees on their
// clock, DST-aware via Intl.
function startOfDayInTz(dateStr, tz) {
  const probe = new Date(`${dateStr}T00:00:00Z`);
  const fmt = probe.toLocaleString('sv-SE', { timeZone: tz, hour12: false }).slice(0, 19).replace(' ', 'T');
  const fromTz = new Date(fmt).getTime();
  return probe.getTime() + (probe.getTime() - fromTz);
}
function todayInTz(tz) {
  return new Date().toLocaleString('sv-SE', { timeZone: tz }).slice(0, 10);
}

// Parse a ?since= query value into a unix ms cutoff (or 0 = no filter).
// Accepts 24h, 7d, 30m, or an ISO date prefix (YYYY-MM-DD…). Returns 0 for
// missing/malformed input so callers can use the result as a min-Date filter
// without separate "is it set" checks.
function parseSince(s) {
  if (typeof s !== 'string' || !s.length) return 0;
  const rel = s.match(/^(\d+)([dhm])$/);
  if (rel) {
    const mult = rel[2] === 'd' ? 86400000 : rel[2] === 'h' ? 3600000 : 60000;
    return Date.now() - parseInt(rel[1], 10) * mult;
  }
  if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s).getTime();
  return 0;
}

app.get('/api/recent', async (req, res, next) => {
  const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 200);
  const sinceMs = parseSince(req.query.since);
  try {
    const all = [];
    for (const [slug, p] of Object.entries(PROJECTS)) {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      if (!bundle) continue;
      for (const c of bundle.commits.slice(0, limit * 4)) {
        if (sinceMs && new Date(c.dateISO).getTime() < sinceMs) break;
        all.push({
          slug, project: p.name,
          hash: c.shortHash, date: c.dateISO, subject: c.subject,
        });
      }
    }
    all.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({
      total:  all.length,
      limit,
      since:  sinceMs ? new Date(sinceMs).toISOString() : null,
      results: all.slice(0, limit),
    });
  } catch (e) { next(e); }
});

// Cross-project search — case-insensitive substring match on commit
// subject + body across every registered project. Used to power a "search
// the whole fleet" experience on the home page. ?q= required (min 2 chars),
// ?limit= clamps results (default 50, max 200).
app.get('/api/search', async (req, res, next) => {
  const q = (typeof req.query.q === 'string' ? req.query.q : '').trim().toLowerCase();
  const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
  const sinceMs = parseSince(req.query.since);
  if (q.length < 2) return res.json({ q, results: [], note: 'min 2 chars' });
  try {
    // Phase 1 — pull ALL matches per project (no cap), so the "one project
    // monopolizes the page" failure mode is impossible.
    const perProject = await Promise.all(
      Object.entries(PROJECTS).map(async ([slug, p]) => {
        const bundle = await getProjectBundle(slug, p).catch(() => null);
        if (!bundle) return { slug, name: p.name, hits: [] };
        const hits = bundle.commits.filter(c => {
          if (sinceMs && new Date(c.dateISO).getTime() < sinceMs) return false;
          return (c.subject + ' ' + c.body).toLowerCase().includes(q);
        }).map(c => ({
          slug, project: p.name, hash: c.shortHash, date: c.dateISO.slice(0, 10), subject: c.subject,
        }));
        return { slug, name: p.name, hits };
      })
    );
    // Phase 2 — round-robin merge so the first N hits include something from
    // every project that matched at all, then sort by date desc.
    const queues = perProject.map(p => [...p.hits]);
    const interleaved = [];
    while (interleaved.length < limit && queues.some(q => q.length)) {
      for (const q of queues) {
        if (!q.length) continue;
        interleaved.push(q.shift());
        if (interleaved.length >= limit) break;
      }
    }
    interleaved.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
    const grandTotal = perProject.reduce((a, p) => a + p.hits.length, 0);
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({
      q,
      total: grandTotal,
      shown: interleaved.length,
      limit,
      by_project: perProject.map(p => ({ slug: p.slug, name: p.name, count: p.hits.length })),
      results: interleaved,
    });
  } catch (e) { next(e); }
});

// Per-project facet endpoints — agents / skills / ideas as machine-readable
// JSON, for external dashboards that want one facet without parsing the whole
// project detail blob. Same bundle cache, sub-1ms when warm.
for (const facet of ['agents', 'skills', 'ideas']) {
  app.get(`/api/projects/:slug/${facet}`, async (req, res, next) => {
    const p = PROJECTS[req.params.slug];
    if (!p) return res.status(404).json({ error: 'no-such-project' });
    try {
      const bundle = await getProjectBundle(req.params.slug, p);
      res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
      res.json({ slug: req.params.slug, [facet]: bundle.facets[facet] });
    } catch (e) { next(e); }
  });
}

// Raw commits JSON — paginated. Supports ?limit=N (default 100, max 500) and
// ?offset=N. The bundle has the full list cached; this just slices it.
app.get('/api/projects/:slug/commits', async (req, res, next) => {
  const p = PROJECTS[req.params.slug];
  if (!p) return res.status(404).json({ error: 'no-such-project' });
  try {
    const { commits } = await getProjectBundle(req.params.slug, p);
    const limit  = Math.min(Math.max(parseInt(req.query.limit,  10) || 100, 1), 500);
    const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
    const sinceMs = parseSince(req.query.since);
    const filtered = sinceMs
      ? commits.filter(c => new Date(c.dateISO).getTime() >= sinceMs)
      : commits;
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({
      slug: req.params.slug,
      total: filtered.length,
      limit, offset,
      since: sinceMs ? new Date(sinceMs).toISOString() : null,
      commits: filtered.slice(offset, offset + limit).map(c => ({
        hash: c.shortHash, date: c.dateISO, author: c.author, subject: c.subject, body: c.body,
      })),
    });
  } catch (e) { next(e); }
});

// Fleet-wide CSV — every commit across every project in one file, sorted by
// date desc. Adds a `project` column so the consumer can slice/filter. Use:
// `curl https://projects.agentabrams.com/fleet.csv > fleet.csv && open fleet.csv`.
app.get('/fleet.csv', async (_req, res, next) => {
  try {
    const q = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`;
    const all = [];
    for (const [slug, p] of Object.entries(PROJECTS)) {
      const bundle = await getProjectBundle(slug, p).catch(() => null);
      if (!bundle) continue;
      for (const c of bundle.commits) {
        all.push([q(slug), q(c.shortHash), q(c.dateISO), q(c.author), q(c.subject), c.body.length]);
      }
    }
    all.sort((a, b) => (b[2] < a[2] ? -1 : b[2] > a[2] ? 1 : 0));
    const rows = ['project,hash,date,author,subject,body_len'].concat(all.map(r => r.join(',')));
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.setHeader('Content-Disposition', 'attachment; filename="fleet-commits.csv"');
    res.type('text/csv').send(rows.join('\n') + '\n');
  } catch (e) { next(e); }
});

// CSV export — every commit in spreadsheet form. Useful for the "drop this
// into Numbers and slice by date / author" workflow. Header row + RFC-4180
// quoting (double-quote inside double-quoted field).
app.get('/p/:slug/commits.csv', async (req, res, next) => {
  const p = PROJECTS[req.params.slug];
  if (!p) return res.status(404).type('text/plain').send('no-such-project\n');
  try {
    const { commits } = await getProjectBundle(req.params.slug, p);
    const q = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`;
    const rows = ['hash,date,author,subject,body_len'];
    for (const c of commits) {
      rows.push([q(c.shortHash), q(c.dateISO), q(c.author), q(c.subject), c.body.length].join(','));
    }
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.setHeader('Content-Disposition', `attachment; filename="${req.params.slug}-commits.csv"`);
    res.type('text/csv').send(rows.join('\n') + '\n');
  } catch (e) { next(e); }
});

// Atom feed per project — recent N commits in standards-compliant Atom 1.0.
// Lets external tools subscribe to a project's build journal (e.g. Feedly,
// NetNewsWire). Pulls from the same bundle cache as the HTML page.
app.get('/p/:slug/feed.atom', async (req, res, next) => {
  const p = PROJECTS[req.params.slug];
  if (!p) return res.status(404).type('application/xml').send('<error>no-such-project</error>');
  try {
    const { commits } = await getProjectBundle(req.params.slug, p);
    const base = 'https://projects.agentabrams.com';
    const entries = commits.slice(0, 50).map(c => {
      const url  = `${base}/p/${req.params.slug}/c/${c.shortHash}`;
      const body = c.body ? `\n\n${c.body}` : '';
      return `  <entry>
    <id>${url}</id>
    <title>${esc(c.subject)}</title>
    <link href="${url}"/>
    <updated>${new Date(c.dateISO).toISOString()}</updated>
    <author><name>${esc(c.author)}</name></author>
    <content type="text">${esc(c.subject + body)}</content>
  </entry>`;
    }).join('\n');
    const updated = commits[0] ? new Date(commits[0].dateISO).toISOString() : new Date().toISOString();
    const feed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>${base}/p/${req.params.slug}/feed.atom</id>
  <title>${esc(p.name)} — build journal</title>
  <link rel="self" href="${base}/p/${req.params.slug}/feed.atom"/>
  <link href="${base}/p/${req.params.slug}"/>
  <updated>${updated}</updated>
  <subtitle>${esc(p.blurb)}</subtitle>
${entries}
</feed>
`;
    res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=600');
    res.type('application/atom+xml').send(feed);
  } catch (e) { next(e); }
});

// File viewer — show any tracked path at HEAD.
app.get('/p/:slug/file', async (req, res, next) => {
  const p = PROJECTS[req.params.slug];
  if (!p) return res.status(404).send(shell('Not found', '<h1>404</h1>'));
  const file = req.query.path;
  if (!file || file.includes('..')) return res.status(400).send(shell('Bad path', '<h1>400</h1>'));
  try {
    const content = await readBlob(p.repo, 'HEAD', file);
    res.send(shell(`${file} — ${p.name}`, `
      <p class="bp-meta"><a href="/p/${esc(req.params.slug)}">← back to ${esc(p.name)}</a></p>
      <h1><code>${esc(file)}</code></h1>
      <p class="bp-meta">${content.split('\n').length} lines</p>
      <pre class="bp-source">${esc(content)}</pre>
    `));
  } catch (e) { next(e); }
});

// /api — top-level discovery doc. Lists every endpoint with a one-line
// purpose; mirrors the pattern used by asim/sod. Hit this first when
// orienting external consumers (or yourself two months from now).
app.get('/api', (_req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=600');
  res.json({
    name: 'build-pages API',
    base: 'https://projects.agentabrams.com',
    endpoints: {
      'GET /api/health':                       'Liveness probe — JSON {ok, counts.projects, uptime_s, ts, as_of}',
      'GET /api/version':                      'Deploy fingerprint — JSON {name, version, git, node, started_at}',
      'GET /api/projects':                     'List every project journal (slug, name, repo, blurb)',
      'GET /api/projects/:slug':               'Per-project stats — head SHA, commit_count, top agents/skills, first/last commit',
      'GET /api/projects/:slug/commits':       'Paginated raw commits — ?limit=N&offset=N (1-500 default 100)',
      'GET /api/projects/:slug/agents':        'Raw agents facet — sub-agent names auto-extracted from commit bodies',
      'GET /api/projects/:slug/skills':        'Raw skills facet — /skill-name slugs auto-extracted from commit bodies',
      'GET /api/projects/:slug/ideas':         'Raw ideas — commit bodies ≥120 chars (design-rationale + scaffolds)',
      'GET /api/search':                       'Cross-project commit search — ?q=<term>&limit=N&since=<24h|7d|YYYY-MM-DD>, round-robin + by_project breakdown',
      'GET /api/recent':                       'Most recent N commits across the whole fleet — date-sorted, ?limit=20&since=<24h|7d|YYYY-MM-DD>',
      'GET /api/fleet/agents':                 'Fleet-wide agent tallies merged across every project',
      'GET /api/fleet/skills':                 'Fleet-wide skill tallies merged across every project',
      'GET /api/fleet/velocity':               'Commits-per-rolling-window across the fleet — last 24h + last 7d, with per-project breakdown',
      'GET /api/fleet/authors':                'Fleet-wide author tallies (post-alias-normalization, so SteveStudio2/Steve/Steve Abrams collapse to one)',
      'GET /api/fleet/buckets':                'Daily commit-count buckets for the last N days (?days=7, max 90) — fleet + per-project, with ISO date labels',
      'GET /api/fleet/longest':                'Top N commits by body length across the fleet (?limit=20) — the design-rationale essays',
      'GET /api/fleet/today':                  'Calendar-day commit slice (?date=YYYY-MM-DD or today) — different from /api/recent?since=24h (rolling window)',
      'GET /api/fleet/all':                    'One-shot dashboard aggregate — velocity + recent + top skills/agents + per-project counts',
      'GET /p/:slug':                          'Project journal HTML page (?q= deep-link supported)',
      'GET /p/:slug/c/:hash':                  'Commit detail page — full diff + body',
      'GET /p/:slug/feed.atom':                'Atom 1.0 feed — up to 50 recent commits, autodiscovered on project page',
      'GET /p/:slug/commits.csv':              'Per-project commits as CSV (hash, date, author, subject, body_len)',
      'GET /fleet.csv':                        'Fleet-wide commits CSV — every commit across every project, date-sorted desc',
      'GET /p/:slug/file?path=<path>':         'View any tracked file at HEAD',
      'GET /sitemap.xml':                      'XML sitemap — home + every /p/:slug',
      'GET /robots.txt':                       'Crawler policy (allow all, sitemap advertised)',
      'GET /healthz':                          'k8s-style liveness probe (no DB) — JSON {ok, ts}',
    },
    notes: [
      'Bundle cache keyed on HEAD SHA — page hits drop from 80ms cold to ~17ms warm.',
      'Search is min 2 chars, round-robins across projects so no single project monopolizes the page.',
    ],
  });
});

// JSON API — for the eventual cross-project dashboard.
app.get('/api/projects', (_req, res) => {
  res.json({
    projects: Object.entries(PROJECTS).map(([slug, p]) => ({ slug, name: p.name, repo: p.repo, blurb: p.blurb })),
  });
});

// Per-project stats: commit count, last commit, top agents/skills, file count.
// Reads from the per-repo bundle cache so it shares git work with the HTML page.
app.get('/api/projects/:slug', async (req, res, next) => {
  const p = PROJECTS[req.params.slug];
  if (!p) return res.status(404).json({ error: 'no-such-project' });
  try {
    const bundle = await getProjectBundle(req.params.slug, p);
    const first = bundle.commits[bundle.commits.length - 1];
    const last  = bundle.commits[0];
    // Author breakdown — who landed how many commits on this project.
    // Aliases collapsed via canonAuthor() so multiple git-config names map to
    // one person.
    const authorTallies = new Map();
    for (const c of bundle.commits) {
      const a = canonAuthor(c.author);
      authorTallies.set(a, (authorTallies.get(a) || 0) + 1);
    }
    const authors = [...authorTallies.entries()]
      .map(([name, count]) => ({ name, count }))
      .sort((a, b) => b.count - a.count);
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({
      slug: req.params.slug,
      name: p.name,
      blurb: p.blurb,
      head: bundle.head.slice(0, 7),
      commit_count: bundle.commits.length,
      file_count: bundle.files.length,
      first_commit: first ? { hash: first.shortHash, date: first.dateISO.slice(0, 10), subject: first.subject } : null,
      last_commit:  last  ? { hash: last.shortHash,  date: last.dateISO.slice(0, 10),  subject: last.subject  } : null,
      authors,
      agents_top: bundle.facets.agents.slice(0, 10),
      skills_top: bundle.facets.skills.slice(0, 10),
      ideas_count: bundle.facets.ideas.length,
      as_of: new Date(bundle.at).toISOString(),
    });
  } catch (e) { next(e); }
});

// /opensearch.xml — OpenSearch 1.1 description so browsers (Chrome, Firefox,
// Safari Tech Preview) can register projects.agentabrams.com as a custom
// search engine. Triggered by autodiscovery <link rel="search"> in the shell.
app.get('/opensearch.xml', (_req, res) => {
  const base = 'https://projects.agentabrams.com';
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
  <ShortName>build-pages</ShortName>
  <Description>Search every commit across Steve Abrams' projects.</Description>
  <InputEncoding>UTF-8</InputEncoding>
  <Image height="32" width="32" type="image/svg+xml">${base}/favicon.svg</Image>
  <Url type="text/html" method="get" template="${base}/?q={searchTerms}"/>
  <Url type="application/json" method="get" template="${base}/api/search?q={searchTerms}&amp;limit=50"/>
</OpenSearchDescription>
`;
  res.setHeader('Cache-Control', 'public, max-age=86400');
  res.type('application/opensearchdescription+xml').send(xml);
});

// /search — convention alias that 302s to home with ?q= prefilled. Lets folks
// who guess "/search" land on the right surface.
app.get('/search', (req, res) => {
  const q = typeof req.query.q === 'string' ? req.query.q : '';
  res.redirect(302, q ? `/?q=${encodeURIComponent(q)}` : '/');
});

// /privacy — what data this site collects. The honest answer: very little.
app.get('/privacy', (_req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
  res.send(shell('Privacy — build-pages', `
    <h1>Privacy</h1>
    <p class="bp-lede">build-pages is a static read-out of public git history. It collects nothing about you.</p>
    <h2>What we don't collect</h2>
    <ul>
      <li>No accounts. No login. No cookies.</li>
      <li>No analytics scripts. No tracking pixels.</li>
      <li>No form submissions. The only inputs are search filters that stay in your browser URL.</li>
    </ul>
    <h2>What the server logs</h2>
    <p>Standard nginx + Node access logs (IP, path, user-agent, response code, timing) for ops debugging.
    Retained 30 days, not shared.</p>
    <h2>What's public by design</h2>
    <p>Every commit subject, body, author name + email as recorded in the git repo, file paths, and diffs.
    Authors who don't want their email surfaced should set a private one via
    <code>git config user.email</code> before committing. We don't redact retrospectively.</p>
    <p class="bp-meta"><a href="/">← back to all projects</a></p>
  `, '', { canonical: 'https://projects.agentabrams.com/privacy', desc: 'build-pages privacy — no tracking, no accounts, no cookies; standard server logs only.' }));
});

// /terms — minimal use-of-site terms.
app.get('/terms', (_req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
  res.send(shell('Terms — build-pages', `
    <h1>Terms of use</h1>
    <p class="bp-lede">build-pages is published as-is for transparency about how Steve's projects get built.</p>
    <ul>
      <li>All content is rendered from public git repos. No warranty of accuracy or completeness.</li>
      <li>Don't scrape aggressively. Cache-Control headers tell you when to refetch.</li>
      <li>Code snippets and diffs are © their author. Use under the license shown in each repo
      (default: not licensed for redistribution unless stated).</li>
      <li>No support obligation. Reach Steve at his usual channels for questions.</li>
    </ul>
    <p class="bp-meta"><a href="/">← back to all projects</a></p>
  `, '', { canonical: 'https://projects.agentabrams.com/terms', desc: 'build-pages terms — public-as-is content from git, no support obligation.' }));
});

// /about — short explainer for visitors arriving at projects.agentabrams.com.
app.get('/about', (_req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
  res.send(shell('About — build-pages', `
    <h1>About</h1>
    <p class="bp-lede">build-pages is a live build journal for every project in Steve Abrams' workshop.</p>

    <p>Every project here is a real git repo. The pages you're reading are rendered live from <code>git log</code>
    on each refresh — no static export step, no CI build job. New commits appear within seconds of
    landing locally.</p>

    <h2>What you'll find</h2>
    <ul>
      <li><strong>Every commit</strong> — full subject, body, author, date, files touched, diff</li>
      <li><strong>Search</strong> — across one project (<code>/p/&lt;slug&gt;?q=…</code>) or the whole fleet (<code>/?q=…</code>)</li>
      <li><strong>Design rationale</strong> — long commit bodies (≥120 chars) are surfaced as "ideas"</li>
      <li><strong>Sub-agents + skills used</strong> — auto-extracted from commit messages</li>
      <li><strong>Authors + velocity</strong> — daily commit shape, who pushed what, rolling 24h / 7d counts</li>
      <li><strong>Exports</strong> — every project ships .csv, .atom, JSON detail at <a href="/api">/api</a></li>
    </ul>

    <h2>Pattern</h2>
    <p>git is the source of truth. The site reads a per-repo bundle keyed on HEAD SHA — when HEAD moves,
    the cache invalidates and the next request rebuilds it. Cold render ~80ms, warm hit ~17ms.</p>

    <p class="bp-meta"><a href="/">← back to all projects</a></p>
  `, '', {
    canonical: 'https://projects.agentabrams.com/about',
    desc: 'build-pages — a live, git-rendered build journal for every project Steve ships.',
  }));
});

// Branded 404 — every other site in the fleet has one, parity matters.
app.use((req, res) => {
  res.status(404).send(shell('Not found — build-pages', `
    <h1>404</h1>
    <p class="bp-lede">No build journal lives at <code>${esc(req.path)}</code>.</p>
    <p><a href="/">← back to all projects</a></p>
  `));
});

app.use((err, _req, res, _next) => {
  console.error('[build-pages]', err.message);
  res.status(500).send(shell('Error', `<h1>500</h1><pre>${esc(err.message)}</pre>`));
});

const LISTEN_HOST = process.env.HOST || '127.0.0.1';
app.listen(PORT, LISTEN_HOST, () => {
  console.log(`build-pages listening on http://${LISTEN_HOST}:${PORT} — ${Object.keys(PROJECTS).length} projects`);
  // Warm the whole fleet in the background so the first request reads cache, then
  // re-warm every 90s (< BUNDLE_TTL) to pick up new commits without a request ever
  // touching git across 540 repos.
  warmAll().then(() => console.log('build-pages: fleet cache warmed')).catch(() => {});
  setInterval(() => warmAll().catch(() => {}), 90_000);
});