← back to Claude Webdev Accelerator

scripts/bounce-intake.js

296 lines

#!/usr/bin/env node
// bounce-intake.js — the connective tissue between the Bounce landing funnel and
// the Web-Dev Accelerator pipeline.
//
// Reads the bounce-studio waitlist (data/waitlist.jsonl), finds leads that have
// NOT yet been triaged, and for a chosen lead:
//   1. scaffolds a client engagement folder under clients/<slug>/ with a BRIEF.md
//      seed shaped for ACCELERATOR.md Phase 0 (Intake),
//   2. drafts a scoped, human-voiced reply into ~/.claude/yolo-queue/pending-approval/
//      (SEND IS GATED — this script NEVER emails anyone),
//   3. records the lead as processed so it is never double-handled.
//
// $0 (local): pure Node builtins, no network, no paid API. The bounce-intake AGENT
// runs this to get its work list, then does the human judgement (reading the
// reference link, writing the real brief) on top of the scaffold.
//
// Usage:
//   node scripts/bounce-intake.js list                 # show new (untriaged) leads
//   node scripts/bounce-intake.js list --all           # show every lead, triaged or not
//   node scripts/bounce-intake.js scaffold <n|email>   # triage lead #n (from list) or by email
//   node scripts/bounce-intake.js scaffold-all         # triage every new lead
//   node scripts/bounce-intake.js show <n|email>       # print one lead's raw record

'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');

const HOME = os.homedir();
const ACCEL = path.join(HOME, 'Projects', 'claude-webdev-accelerator');
const WAITLIST = path.join(HOME, 'Projects', 'bounce-studio', 'data', 'waitlist.jsonl');
const CLIENTS = path.join(ACCEL, 'clients');
const PROCESSED = path.join(CLIENTS, '.intake-processed.json');
const PENDING = path.join(HOME, '.claude', 'yolo-queue', 'pending-approval');

// ---------- helpers ----------
function readLeads() {
  if (!fs.existsSync(WAITLIST)) return [];
  const out = [];
  const lines = fs.readFileSync(WAITLIST, 'utf8').split('\n');
  for (const line of lines) {
    const s = line.trim();
    if (!s) continue;
    try { out.push(JSON.parse(s)); } catch (_) { /* skip malformed line */ }
  }
  return out;
}

function leadId(lead) {
  // Dedupe on EMAIL ALONE, not timestamp+email. A double-click / retry submit
  // produces two different ms-precise timestamps for the same person, so a
  // ts-keyed id would double-scaffold them (two client folders, two gated
  // replies to hand-clean). Email-only absorbs the retry. Trade-off: a genuine
  // re-engagement from the same email later needs a manual marker reset
  // (delete its entry in clients/.intake-processed.json), which is fine for a
  // Steve-reviewed funnel.
  return (lead.email || '').toLowerCase().trim();
}

function readProcessed() {
  if (!fs.existsSync(PROCESSED)) return {};
  try { return JSON.parse(fs.readFileSync(PROCESSED, 'utf8')); } catch (_) { return {}; }
}
function writeProcessed(map) {
  if (!fs.existsSync(CLIENTS)) fs.mkdirSync(CLIENTS, { recursive: true });
  // atomic write: a kill mid-write would otherwise corrupt the marker, and the
  // readProcessed catch would then return {} → every lead re-scaffolds (dup
  // folders + dup gated replies). tmp + rename is atomic on POSIX.
  const tmp = PROCESSED + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(map, null, 2) + '\n');
  fs.renameSync(tmp, PROCESSED);
}

// Collapse untrusted free text to a single safe line before embedding it in a
// markdown BRIEF/reply — kills newline-spliced fake sections (a note of
// "foo\n\n## Injected" would otherwise render as a real BRIEF heading) and strips
// leading markdown structural chars.
function oneLine(s) {
  return (s || '').toString().replace(/\s+/g, ' ').replace(/^[#>*\-\s]+/, '').trim();
}

// Pull the first URL out of the ref field or the free-text note. Only accepts a
// real dot-domain http(s) URL — rejects junk like "https://javascript:alert(1)"
// that the server's bare-domain normalization can otherwise manufacture.
function extractRef(lead) {
  const clean = (u) => (/^https?:\/\/[^\s/.]+\.[^\s]{2,}/i.test((u || '').trim()) ? u.trim() : '');
  if (lead.ref) { const r = clean(lead.ref); if (r) return r; }
  const m = (lead.note || '').match(/https?:\/\/[^\s)]+/i);
  return m ? clean(m[0]) : '';
}

function slugFor(lead, taken) {
  const email = (lead.email || '').toLowerCase();
  const domain = (email.split('@')[1] || 'client').split('.')[0];
  let base = domain.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'client';
  let slug = base, n = 2;
  while (taken.has(slug)) { slug = `${base}-${n++}`; }
  taken.add(slug);
  return slug;
}

function fmtDate(iso) {
  // explicit placeholder over a fake sentinel date — a missing ts should read as
  // "(no timestamp)" in the BRIEF, not a misleading "Jan 1, 2026".
  if (!iso) return '(no timestamp)';
  return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}

// ---------- brief + reply templates ----------
function briefSeed(lead, slug, ref) {
  const ts = lead.ts || '';
  return `# Bounce engagement — ${slug}

<!-- Seeded by scripts/bounce-intake.js from a Bounce waitlist lead. -->
<!-- This is a SEED for ACCELERATOR.md Phase 0 (Intake). The bounce-build agent -->
<!-- fills the real spec after reading the reference below. -->

## Lead
- **Email:** ${lead.email || '(none)'}
- **Submitted:** ${fmtDate(ts)}  (\`${ts}\`)
- **What they're building:** ${oneLine(lead.budget) || '(not specified)'}
- **Reference link:** ${ref || '(none provided — ask for one)'}
- **Their note:** ${oneLine(lead.note) || '(none)'}

## Phase 0 · Intake — brief (fill this in)
- **Client / brand:**
- **Outcome / "high value":**  <!-- revenue, deal won, time saved -->
- **What we build:**  <!-- one paragraph, scoped -->
- **Hard constraints:**  <!-- brand, domain, deadline, budget = $15k flat -->
- **Differentiation vector:**  <!-- from /site-audit + /competitors on the ref link -->

## Six-skill plan (Bounce marketing name → real skill)
1. **brief**      → /site-audit + /competitors on the ref → scoped BRIEF (this file)
2. **design**     → /mockups <ref> 3 · four-horsemen · graphic-designer critique
3. **front-end**  → frontend-design / vp-engineering build from the locked brief
4. **components** → bento-grid · rotating-hero-page · page-flip-ui · modal-rig (drop-in)
5. **motion**     → motion-graphics / hyperframes-animation (scroll reveals, fades)
6. **media**      → media-use · room-setting-generator · canvas-design (on-brand imagery)

## Status
- [x] Lead captured + engagement scaffolded (bounce-intake)
- [ ] Reference audited, brief locked (Phase 0/1)
- [ ] Scaffold + build (Phase 2/3)
- [ ] /5x verify + /contrarian gate (Phase 4)
- [ ] Preview behind basic auth for Steve (Phase 5)
- [ ] STOP — launch/deploy/DNS gated (Phase 6)
`;
}

function replyDraft(lead, slug, ref) {
  const first = (lead.email || '').split('@')[0].replace(/[._-].*$/, '');
  // First name only, title case (CLAUDE.md welcome-email rule): upcase first
  // char, downcase the rest so ALLCAPS aliases (INFO@, SALES@) → "Info"/"Sales"
  // rather than "hey INFO,".
  const firstTitle = first ? first.charAt(0).toUpperCase() + first.slice(1).toLowerCase() : 'there';
  const buildLine = oneLine(lead.budget) || 'your site';
  const refLine = ref
    ? `i've already pulled up ${ref} and started the reference teardown.`
    : `if you've got a site or a look you love, drop the link and i'll start the teardown.`;
  return `# GATED — Bounce lead reply draft · ${slug}

**Action for Steve:** review + send (or edit). Nothing has been sent. The lead is
already scaffolded at \`clients/${slug}/BRIEF.md\` in claude-webdev-accelerator.

- **To:** ${lead.email || '(no email)'}
- **Re:** ${buildLine}
- **Reference:** ${ref || '(none provided)'}
- **Submitted:** ${fmtDate(lead.ts)}

---

**Subject:** your Bounce build — scoped brief inside

hey ${firstTitle},

thanks for sending this over. ${refLine}

here's how it goes from here: i come back with a one-paragraph scoped brief —
the palette, type, and motion language locked — before a single line of layout
exists. then the six skills build it: front-end turns the brief straight into
clean code, design + components handle the polished pages and the animated
pieces, motion runs the scroll reveals, and media generates the on-brand imagery.
about a week, brief to launch-ready. $15k flat — you know the number before we
start and it doesn't move.

want me to lock the brief and give you a start date?

— bounce /idc

---

*Draft only. Send is gated (no outbound mail from the funnel). Delete this file
after sending, or move to a 'sent' folder.*
`;
}

// ---------- commands ----------
function newLeads() {
  const leads = readLeads();
  const done = readProcessed();
  return leads
    .map((lead, i) => ({ lead, i }))
    .filter(({ lead }) => !done[leadId(lead)]);
}

function cmdList(showAll) {
  const leads = readLeads();
  const done = readProcessed();
  if (!leads.length) { console.log('No leads yet in', WAITLIST); return; }
  const rows = showAll ? leads : leads.filter((l) => !done[leadId(l)]);
  if (!rows.length) { console.log('No new leads — all', leads.length, 'triaged. (use --all to see them.)'); return; }
  console.log(`${rows.length} ${showAll ? 'total' : 'new'} lead(s):\n`);
  rows.forEach((lead) => {
    const idx = leads.indexOf(lead) + 1;
    const flag = done[leadId(lead)] ? '✓' : '•';
    const ref = extractRef(lead);
    console.log(`  ${flag} #${idx}  ${lead.email || '(no email)'}  [${lead.budget || '—'}]  ${fmtDate(lead.ts)}`);
    if (ref) console.log(`        ref: ${ref}`);
    if (lead.note) console.log(`        note: ${oneLine(lead.note).slice(0, 120)}`);
  });
  console.log('\nTriage one: node scripts/bounce-intake.js scaffold <n|email>');
}

function resolveLead(sel) {
  const leads = readLeads();
  if (/^\d+$/.test(sel)) return leads[parseInt(sel, 10) - 1] || null;
  const s = sel.toLowerCase();
  return leads.find((l) => (l.email || '').toLowerCase() === s) || null;
}

function scaffoldLead(lead, takenSlugs) {
  const done = readProcessed();
  if (done[leadId(lead)]) {
    console.log(`Already triaged: ${lead.email} → ${done[leadId(lead)].slug}`);
    return null;
  }
  const ref = extractRef(lead);
  const slug = slugFor(lead, takenSlugs);
  const dir = path.join(CLIENTS, slug);
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(dir, 'BRIEF.md'), briefSeed(lead, slug, ref));

  if (!fs.existsSync(PENDING)) fs.mkdirSync(PENDING, { recursive: true });
  const replyPath = path.join(PENDING, `bounce-lead-${slug}.md`);
  fs.writeFileSync(replyPath, replyDraft(lead, slug, ref));

  done[leadId(lead)] = { slug, at: new Date().toISOString(), reply: replyPath };
  writeProcessed(done);

  console.log(`✓ ${lead.email} → clients/${slug}/BRIEF.md`);
  console.log(`  reply draft (GATED): ${replyPath}`);
  return slug;
}

function cmdScaffold(sel) {
  const lead = resolveLead(sel);
  if (!lead) { console.error('No lead matches', JSON.stringify(sel)); process.exit(1); }
  const taken = new Set(fs.existsSync(CLIENTS) ? fs.readdirSync(CLIENTS).filter((f) => !f.startsWith('.')) : []);
  const slug = scaffoldLead(lead, taken);
  if (slug) {
    console.log('\nNext: hand it to the build agent →  clients/' + slug + '/BRIEF.md');
    console.log('      (bounce-build reads the ref, locks the brief, runs the pipeline.)');
  }
}

function cmdScaffoldAll() {
  const fresh = newLeads();
  if (!fresh.length) { console.log('No new leads to triage.'); return; }
  const taken = new Set(fs.existsSync(CLIENTS) ? fs.readdirSync(CLIENTS).filter((f) => !f.startsWith('.')) : []);
  let n = 0;
  for (const { lead } of fresh) { if (scaffoldLead(lead, taken)) n++; }
  console.log(`\nTriaged ${n} new lead(s).`);
}

function cmdShow(sel) {
  const lead = resolveLead(sel);
  if (!lead) { console.error('No lead matches', JSON.stringify(sel)); process.exit(1); }
  console.log(JSON.stringify(lead, null, 2));
}

// ---------- main ----------
const [cmd, ...rest] = process.argv.slice(2);
switch (cmd) {
  case 'list': cmdList(rest.includes('--all')); break;
  case 'scaffold': if (!rest[0]) { console.error('usage: scaffold <n|email>'); process.exit(1); } cmdScaffold(rest[0]); break;
  case 'scaffold-all': cmdScaffoldAll(); break;
  case 'show': if (!rest[0]) { console.error('usage: show <n|email>'); process.exit(1); } cmdShow(rest[0]); break;
  default:
    console.log('bounce-intake — waitlist → accelerator engagement\n');
    console.log('  list [--all]            show new (or all) leads');
    console.log('  scaffold <n|email>      triage one lead → clients/<slug>/BRIEF.md + gated reply draft');
    console.log('  scaffold-all            triage every new lead');
    console.log('  show <n|email>          print a lead record');
    console.log('\nwaitlist:', WAITLIST);
}