← 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);
}