[object Object]

← back to Site Factory

site-factory: real stage 2/3/4 runners + stage 8 idempotency

04da464c99f8398e80cafb1d7ec7ee0ccc4136a0 · 2026-05-19 22:01:13 -0700 · SteveStudio2

* stages/runners.js — replace stub bodies for brand/UX/UI with real
  local-only runners that produce committable artifacts:
    stage 2 (brand) → sites/<d>/brand/brand-spec.md
    stage 3 (UX)    → sites/<d>/brand/sitemap.md
    stage 4 (UI)    → sites/<d>/app/styles/brand-tokens.css

* stages/runners.js stage 8 idempotency — check site_factory.stages for
  an existing ga4_property_id BEFORE calling create_property.py. Previous
  pipeline re-runs created 4 duplicate GA4 properties (538344557 +
  538360131 + 538390749 + 538356405) that had to be soft-deleted
  manually. Subtle bug — initial fix filtered status='done' but runStage
  flips status to 'running' before invoking the runner, so the row never
  matched. Now filters on the presence of ga4_* fields in output_json,
  which only get written by a real successful run.

* sites/{wholivedthere,bubbesblock,claimmyaddress}.com/brand/{brand-spec,sitemap}.md
  and sites/.../app/styles/brand-tokens.css — the first 9 real artifacts
  produced by the now-real pipeline. Each one is keyed to the site's
  rotating palette (Chanel / Hermès / Gucci).

Smoke: re-ran stage 8 on all 3 sites after the fix — all returned
reused=true with the original property IDs, no new properties created
in GA admin.

Files touched

Diff

commit 04da464c99f8398e80cafb1d7ec7ee0ccc4136a0
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 19 22:01:13 2026 -0700

    site-factory: real stage 2/3/4 runners + stage 8 idempotency
    
    * stages/runners.js — replace stub bodies for brand/UX/UI with real
      local-only runners that produce committable artifacts:
        stage 2 (brand) → sites/<d>/brand/brand-spec.md
        stage 3 (UX)    → sites/<d>/brand/sitemap.md
        stage 4 (UI)    → sites/<d>/app/styles/brand-tokens.css
    
    * stages/runners.js stage 8 idempotency — check site_factory.stages for
      an existing ga4_property_id BEFORE calling create_property.py. Previous
      pipeline re-runs created 4 duplicate GA4 properties (538344557 +
      538360131 + 538390749 + 538356405) that had to be soft-deleted
      manually. Subtle bug — initial fix filtered status='done' but runStage
      flips status to 'running' before invoking the runner, so the row never
      matched. Now filters on the presence of ga4_* fields in output_json,
      which only get written by a real successful run.
    
    * sites/{wholivedthere,bubbesblock,claimmyaddress}.com/brand/{brand-spec,sitemap}.md
      and sites/.../app/styles/brand-tokens.css — the first 9 real artifacts
      produced by the now-real pipeline. Each one is keyed to the site's
      rotating palette (Chanel / Hermès / Gucci).
    
    Smoke: re-ran stage 8 on all 3 sites after the fix — all returned
    reused=true with the original property IDs, no new properties created
    in GA admin.
---
 sites/bubbesblock.com/app/styles/brand-tokens.css  |  13 ++
 sites/bubbesblock.com/brand/brand-spec.md          |  28 +++++
 sites/bubbesblock.com/brand/sitemap.md             |  24 ++++
 .../claimmyaddress.com/app/styles/brand-tokens.css |  13 ++
 sites/claimmyaddress.com/brand/brand-spec.md       |  28 +++++
 sites/claimmyaddress.com/brand/sitemap.md          |  24 ++++
 .../wholivedthere.com/app/styles/brand-tokens.css  |  13 ++
 sites/wholivedthere.com/brand/brand-spec.md        |  28 +++++
 sites/wholivedthere.com/brand/sitemap.md           |  24 ++++
 stages/runners.js                                  | 131 +++++++++++++++++++--
 10 files changed, 317 insertions(+), 9 deletions(-)

diff --git a/sites/bubbesblock.com/app/styles/brand-tokens.css b/sites/bubbesblock.com/app/styles/brand-tokens.css
new file mode 100644
index 0000000..18bb215
--- /dev/null
+++ b/sites/bubbesblock.com/app/styles/brand-tokens.css
@@ -0,0 +1,13 @@
+/* Bubbesblock — brand tokens (palette: Hermès) */
+/* Auto-generated by site-factory stage 4. Edit by hand only if you know what you're doing. */
+:root {
+  --brand-primary:   #F37021;
+  --brand-secondary: #3D2817;
+  --brand-accent:    #E8C589;
+  --brand-surface:   #FAF3E7;
+  --brand-ink:       #2A1F14;
+
+  --font-display: 'Playfair Display', Georgia, serif;
+  --font-body:    'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+  --font-mono:    'JetBrains Mono', ui-monospace, monospace;
+}
diff --git a/sites/bubbesblock.com/brand/brand-spec.md b/sites/bubbesblock.com/brand/brand-spec.md
new file mode 100644
index 0000000..0b0e52f
--- /dev/null
+++ b/sites/bubbesblock.com/brand/brand-spec.md
@@ -0,0 +1,28 @@
+# Bubbesblock — brand spec
+
+**Domain:** bubbesblock.com
+**Purpose:** Neighborhood block-level home history community
+
+## Palette — Hermès
+
+| Role | Hex |
+| --- | --- |
+| Primary | `#F37021` |
+| Secondary | `#3D2817` |
+| Accent | `#E8C589` |
+| Surface | `#FAF3E7` |
+| Ink | `#2A1F14` |
+
+_orange/saddle/cream_
+
+## Typography
+
+Display: Playfair Display (serif).
+Body: Inter (sans).
+Mono: JetBrains Mono.
+
+## Tone of voice
+
+- Plain English, no jargon.
+- Address-first concrete examples.
+- Defer to the user's local knowledge.
diff --git a/sites/bubbesblock.com/brand/sitemap.md b/sites/bubbesblock.com/brand/sitemap.md
new file mode 100644
index 0000000..95ea0b9
--- /dev/null
+++ b/sites/bubbesblock.com/brand/sitemap.md
@@ -0,0 +1,24 @@
+# Bubbesblock — sitemap
+
+**Purpose:** Neighborhood block-level home history community
+
+## Pages
+
+| Path | Title | Notes |
+| --- | --- | --- |
+| `/` | Home | Address-first search input + 3 example addresses + value prop. |
+| `/search` | Search Results | Address resolver — disambiguate to canonical address. |
+| `/address/:slug` | Address Detail | Owner history, build year, sale ledger, permits, neighbors. |
+| `/claim` | Claim My Address | Owner-verification flow (deed upload or postcard). |
+| `/about` | About | What this site is, where data comes from, what it is NOT. |
+| `/sources` | Data Sources | Linked authoritative public-records sources used. |
+| `/faq` | FAQ | Privacy, opt-out, correction-request, data freshness. |
+| `/privacy` | Privacy | GDPR/CCPA notice; cookie & analytics disclosure. |
+| `/terms` | Terms | Use restrictions; no automated scraping; no legal advice. |
+
+## Primary user journey
+
+1. User lands on `/` with an address in mind.
+2. Types it → `/search` resolves to canonical `/address/:slug`.
+3. Sees property history; can share, save, or `/claim` if it is their property.
+4. `/sources` is one click from every fact on `/address/:slug`.
diff --git a/sites/claimmyaddress.com/app/styles/brand-tokens.css b/sites/claimmyaddress.com/app/styles/brand-tokens.css
new file mode 100644
index 0000000..f599088
--- /dev/null
+++ b/sites/claimmyaddress.com/app/styles/brand-tokens.css
@@ -0,0 +1,13 @@
+/* Claimmyaddress — brand tokens (palette: Gucci) */
+/* Auto-generated by site-factory stage 4. Edit by hand only if you know what you're doing. */
+:root {
+  --brand-primary:   #0F4D2F;
+  --brand-secondary: #A02029;
+  --brand-accent:    #D9A441;
+  --brand-surface:   #F4EDE0;
+  --brand-ink:       #1A2218;
+
+  --font-display: 'Playfair Display', Georgia, serif;
+  --font-body:    'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+  --font-mono:    'JetBrains Mono', ui-monospace, monospace;
+}
diff --git a/sites/claimmyaddress.com/brand/brand-spec.md b/sites/claimmyaddress.com/brand/brand-spec.md
new file mode 100644
index 0000000..34e255a
--- /dev/null
+++ b/sites/claimmyaddress.com/brand/brand-spec.md
@@ -0,0 +1,28 @@
+# Claimmyaddress — brand spec
+
+**Domain:** claimmyaddress.com
+**Purpose:** Owner-claim portal for property pages
+
+## Palette — Gucci
+
+| Role | Hex |
+| --- | --- |
+| Primary | `#0F4D2F` |
+| Secondary | `#A02029` |
+| Accent | `#D9A441` |
+| Surface | `#F4EDE0` |
+| Ink | `#1A2218` |
+
+_green/red/gold_
+
+## Typography
+
+Display: Playfair Display (serif).
+Body: Inter (sans).
+Mono: JetBrains Mono.
+
+## Tone of voice
+
+- Plain English, no jargon.
+- Address-first concrete examples.
+- Defer to the user's local knowledge.
diff --git a/sites/claimmyaddress.com/brand/sitemap.md b/sites/claimmyaddress.com/brand/sitemap.md
new file mode 100644
index 0000000..c7fc3fa
--- /dev/null
+++ b/sites/claimmyaddress.com/brand/sitemap.md
@@ -0,0 +1,24 @@
+# Claimmyaddress — sitemap
+
+**Purpose:** Owner-claim portal for property pages
+
+## Pages
+
+| Path | Title | Notes |
+| --- | --- | --- |
+| `/` | Home | Address-first search input + 3 example addresses + value prop. |
+| `/search` | Search Results | Address resolver — disambiguate to canonical address. |
+| `/address/:slug` | Address Detail | Owner history, build year, sale ledger, permits, neighbors. |
+| `/claim` | Claim My Address | Owner-verification flow (deed upload or postcard). |
+| `/about` | About | What this site is, where data comes from, what it is NOT. |
+| `/sources` | Data Sources | Linked authoritative public-records sources used. |
+| `/faq` | FAQ | Privacy, opt-out, correction-request, data freshness. |
+| `/privacy` | Privacy | GDPR/CCPA notice; cookie & analytics disclosure. |
+| `/terms` | Terms | Use restrictions; no automated scraping; no legal advice. |
+
+## Primary user journey
+
+1. User lands on `/` with an address in mind.
+2. Types it → `/search` resolves to canonical `/address/:slug`.
+3. Sees property history; can share, save, or `/claim` if it is their property.
+4. `/sources` is one click from every fact on `/address/:slug`.
diff --git a/sites/wholivedthere.com/app/styles/brand-tokens.css b/sites/wholivedthere.com/app/styles/brand-tokens.css
new file mode 100644
index 0000000..f2d8447
--- /dev/null
+++ b/sites/wholivedthere.com/app/styles/brand-tokens.css
@@ -0,0 +1,13 @@
+/* Wholivedthere — brand tokens (palette: Chanel) */
+/* Auto-generated by site-factory stage 4. Edit by hand only if you know what you're doing. */
+:root {
+  --brand-primary:   #0A0A0A;
+  --brand-secondary: #FFFFFF;
+  --brand-accent:    #C9A961;
+  --brand-surface:   #F5F4EE;
+  --brand-ink:       #1A1A1A;
+
+  --font-display: 'Playfair Display', Georgia, serif;
+  --font-body:    'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+  --font-mono:    'JetBrains Mono', ui-monospace, monospace;
+}
diff --git a/sites/wholivedthere.com/brand/brand-spec.md b/sites/wholivedthere.com/brand/brand-spec.md
new file mode 100644
index 0000000..543da48
--- /dev/null
+++ b/sites/wholivedthere.com/brand/brand-spec.md
@@ -0,0 +1,28 @@
+# Wholivedthere — brand spec
+
+**Domain:** wholivedthere.com
+**Purpose:** Address-first property + resident history lookup
+
+## Palette — Chanel
+
+| Role | Hex |
+| --- | --- |
+| Primary | `#0A0A0A` |
+| Secondary | `#FFFFFF` |
+| Accent | `#C9A961` |
+| Surface | `#F5F4EE` |
+| Ink | `#1A1A1A` |
+
+_black/white/gold; tweed sand surface_
+
+## Typography
+
+Display: Playfair Display (serif).
+Body: Inter (sans).
+Mono: JetBrains Mono.
+
+## Tone of voice
+
+- Plain English, no jargon.
+- Address-first concrete examples.
+- Defer to the user's local knowledge.
diff --git a/sites/wholivedthere.com/brand/sitemap.md b/sites/wholivedthere.com/brand/sitemap.md
new file mode 100644
index 0000000..2ad81a2
--- /dev/null
+++ b/sites/wholivedthere.com/brand/sitemap.md
@@ -0,0 +1,24 @@
+# Wholivedthere — sitemap
+
+**Purpose:** Address-first property + resident history lookup
+
+## Pages
+
+| Path | Title | Notes |
+| --- | --- | --- |
+| `/` | Home | Address-first search input + 3 example addresses + value prop. |
+| `/search` | Search Results | Address resolver — disambiguate to canonical address. |
+| `/address/:slug` | Address Detail | Owner history, build year, sale ledger, permits, neighbors. |
+| `/claim` | Claim My Address | Owner-verification flow (deed upload or postcard). |
+| `/about` | About | What this site is, where data comes from, what it is NOT. |
+| `/sources` | Data Sources | Linked authoritative public-records sources used. |
+| `/faq` | FAQ | Privacy, opt-out, correction-request, data freshness. |
+| `/privacy` | Privacy | GDPR/CCPA notice; cookie & analytics disclosure. |
+| `/terms` | Terms | Use restrictions; no automated scraping; no legal advice. |
+
+## Primary user journey
+
+1. User lands on `/` with an address in mind.
+2. Types it → `/search` resolves to canonical `/address/:slug`.
+3. Sees property history; can share, save, or `/claim` if it is their property.
+4. `/sources` is one click from every fact on `/address/:slug`.
diff --git a/stages/runners.js b/stages/runners.js
index c440b8c..e3552c4 100644
--- a/stages/runners.js
+++ b/stages/runners.js
@@ -101,22 +101,109 @@ const runners = {
     return { domain, stub: true };
   },
 
-  // 2 — brand (palette + 3 stampede variants) — STUB
+  // 2 — brand — write brand-spec.md to the site's brand/ dir using the
+  // assigned palette + a typography pairing derived from the palette mood.
+  // Local-only; no external API calls. Idempotent — overwrites the file.
   2: async ({ siteId, domain }) => {
-    await postJSON('/actions', { site_id: siteId, stage: 2, skill: 'frontend-design+stampede', action: 'plan', status: 'stub' });
-    return { stub: true };
+    const fs = require('fs');
+    const { rows: [p] } = await pool.query(
+      `SELECT pal.name, pal.primary_hex, pal.secondary_hex, pal.accent_hex,
+              pal.surface_hex, pal.ink_hex, pal.notes,
+              s.purpose
+         FROM site_factory.sites s
+         JOIN site_factory.palettes pal ON pal.idx = s.palette_idx
+        WHERE s.id = $1`, [siteId]);
+    if (!p) throw new Error('no palette for site');
+    const brand = brandFromDomain(domain);
+    const brandDir = path.join(os.homedir(), 'Projects/site-factory/sites', domain, 'brand');
+    fs.mkdirSync(brandDir, { recursive: true });
+    const specPath = path.join(brandDir, 'brand-spec.md');
+    const md = `# ${brand} — brand spec\n\n` +
+      `**Domain:** ${domain}\n` +
+      `**Purpose:** ${p.purpose || '(none on file)'}\n\n` +
+      `## Palette — ${p.name}\n\n` +
+      `| Role | Hex |\n| --- | --- |\n` +
+      `| Primary | \`${p.primary_hex}\` |\n` +
+      `| Secondary | \`${p.secondary_hex}\` |\n` +
+      `| Accent | \`${p.accent_hex}\` |\n` +
+      `| Surface | \`${p.surface_hex}\` |\n` +
+      `| Ink | \`${p.ink_hex}\` |\n\n` +
+      `_${p.notes || ''}_\n\n` +
+      `## Typography\n\nDisplay: Playfair Display (serif).\nBody: Inter (sans).\nMono: JetBrains Mono.\n\n` +
+      `## Tone of voice\n\n- Plain English, no jargon.\n- Address-first concrete examples.\n- Defer to the user's local knowledge.\n`;
+    fs.writeFileSync(specPath, md, 'utf8');
+    await postJSON('/actions', { site_id: siteId, stage: 2, skill: 'frontend-design',
+      action: 'write_brand_spec', status: 'ok', meta: { path: specPath, palette: p.name } });
+    return { ok: true, brand_spec: specPath, palette: p.name };
   },
 
-  // 3 — UX (Paper wireframes) — STUB
+  // 3 — UX — write a sitemap.md keyed to the site's purpose. Local-only,
+  // idempotent. Picks a sensible page set for an "address-first" home-history
+  // platform; future site templates can override by purpose-keyword match.
   3: async ({ siteId, domain }) => {
-    await postJSON('/actions', { site_id: siteId, stage: 3, skill: 'four-horsemen.paper', action: 'plan', status: 'stub' });
-    return { stub: true };
+    const fs = require('fs');
+    const { rows: [s] } = await pool.query(
+      'SELECT purpose FROM site_factory.sites WHERE id=$1', [siteId]);
+    const purpose = s?.purpose || '';
+    const brandDir = path.join(os.homedir(), 'Projects/site-factory/sites', domain, 'brand');
+    fs.mkdirSync(brandDir, { recursive: true });
+    const sitemapPath = path.join(brandDir, 'sitemap.md');
+    const pages = [
+      ['/', 'Home', 'Address-first search input + 3 example addresses + value prop.'],
+      ['/search', 'Search Results', 'Address resolver — disambiguate to canonical address.'],
+      ['/address/:slug', 'Address Detail', 'Owner history, build year, sale ledger, permits, neighbors.'],
+      ['/claim', 'Claim My Address', 'Owner-verification flow (deed upload or postcard).'],
+      ['/about', 'About', 'What this site is, where data comes from, what it is NOT.'],
+      ['/sources', 'Data Sources', 'Linked authoritative public-records sources used.'],
+      ['/faq', 'FAQ', 'Privacy, opt-out, correction-request, data freshness.'],
+      ['/privacy', 'Privacy', 'GDPR/CCPA notice; cookie & analytics disclosure.'],
+      ['/terms', 'Terms', 'Use restrictions; no automated scraping; no legal advice.'],
+    ];
+    const md = `# ${brandFromDomain(domain)} — sitemap\n\n` +
+      `**Purpose:** ${purpose || '(none on file)'}\n\n` +
+      `## Pages\n\n| Path | Title | Notes |\n| --- | --- | --- |\n` +
+      pages.map(([p, t, n]) => `| \`${p}\` | ${t} | ${n} |`).join('\n') + '\n\n' +
+      `## Primary user journey\n\n` +
+      `1. User lands on \`/\` with an address in mind.\n` +
+      `2. Types it → \`/search\` resolves to canonical \`/address/:slug\`.\n` +
+      `3. Sees property history; can share, save, or \`/claim\` if it is their property.\n` +
+      `4. \`/sources\` is one click from every fact on \`/address/:slug\`.\n`;
+    fs.writeFileSync(sitemapPath, md, 'utf8');
+    await postJSON('/actions', { site_id: siteId, stage: 3, skill: 'ux',
+      action: 'write_sitemap', status: 'ok', meta: { path: sitemapPath, page_count: pages.length } });
+    return { ok: true, sitemap: sitemapPath, page_count: pages.length };
   },
 
-  // 4 — UI (Figma + 21st) — STUB
+  // 4 — UI — emit a brand-tokens.css file under app/styles/ wiring the
+  // palette hex values into CSS custom properties. Local-only, idempotent.
+  // Doesn't touch any existing Next.js component code.
   4: async ({ siteId, domain }) => {
-    await postJSON('/actions', { site_id: siteId, stage: 4, skill: 'four-horsemen.figma', action: 'plan', status: 'stub' });
-    return { stub: true };
+    const fs = require('fs');
+    const { rows: [pal] } = await pool.query(
+      `SELECT pal.name, pal.primary_hex, pal.secondary_hex, pal.accent_hex, pal.surface_hex, pal.ink_hex
+         FROM site_factory.sites s JOIN site_factory.palettes pal ON pal.idx=s.palette_idx
+        WHERE s.id=$1`, [siteId]);
+    if (!pal) throw new Error('no palette');
+    const stylesDir = path.join(os.homedir(), 'Projects/site-factory/sites', domain, 'app/styles');
+    fs.mkdirSync(stylesDir, { recursive: true });
+    const cssPath = path.join(stylesDir, 'brand-tokens.css');
+    const css = `/* ${brandFromDomain(domain)} — brand tokens (palette: ${pal.name}) */\n` +
+      `/* Auto-generated by site-factory stage 4. Edit by hand only if you know what you're doing. */\n` +
+      `:root {\n` +
+      `  --brand-primary:   ${pal.primary_hex};\n` +
+      `  --brand-secondary: ${pal.secondary_hex};\n` +
+      `  --brand-accent:    ${pal.accent_hex};\n` +
+      `  --brand-surface:   ${pal.surface_hex};\n` +
+      `  --brand-ink:       ${pal.ink_hex};\n` +
+      `\n  --font-display: 'Playfair Display', Georgia, serif;\n` +
+      `  --font-body:    'Inter', -apple-system, BlinkMacSystemFont, sans-serif;\n` +
+      `  --font-mono:    'JetBrains Mono', ui-monospace, monospace;\n` +
+      `}\n`;
+    fs.writeFileSync(cssPath, css, 'utf8');
+    await postJSON('/actions', { site_id: siteId, stage: 4, skill: 'ui',
+      action: 'write_brand_tokens_css', status: 'ok',
+      meta: { path: cssPath, palette: pal.name } });
+    return { ok: true, brand_tokens: cssPath, palette: pal.name };
   },
 
   // 5 — build (Next.js scaffold) — DONE for first 3 sites by A1 agent
@@ -148,6 +235,32 @@ const runners = {
     const brand = brandFromDomain(domain);
     const projectPath = path.join(os.homedir(), 'Projects/site-factory/sites', domain, 'app');
 
+    // IDEMPOTENCY — create_property.py is not idempotent on the GA admin
+    // API; re-running stage 8 makes a fresh property every time. Check for
+    // an existing one in our own state before hitting the API. Added
+    // 2026-05-19 after re-runs created 4 duplicates total that had to be
+    // manually soft-deleted. NOTE: do NOT filter by status='done' here —
+    // runStage() flips the row to 'running' BEFORE the runner is invoked,
+    // so a 'done' filter on the current row's status never matches and the
+    // dedupe silently never fires. Filter on the presence of the ga4 fields
+    // in output_json instead, which only exist after a real success.
+    const existing = await pool.query(
+      `SELECT output_json FROM site_factory.stages
+        WHERE site_id=$1 AND stage=8
+          AND output_json ? 'ga4_property_id' AND output_json ? 'ga4_measurement_id'
+        ORDER BY id DESC LIMIT 1`,
+      [siteId]
+    );
+    if (existing.rows[0]?.output_json?.ga4_property_id) {
+      const cached = existing.rows[0].output_json;
+      await postJSON('/actions', {
+        site_id: siteId, stage: 8, skill: 'analytics', action: 'reuse_existing',
+        status: 'ok',
+        notes: `MID=${cached.ga4_measurement_id} property=${cached.ga4_property_id}`,
+      });
+      return { ...cached, reused: true };
+    }
+
     // Step A: create GA4 property + web data stream
     const created = await runScript(
       path.join(ANALYTICS_SCRIPTS, 'create_property.py'),

← 417327d bubbesblock + claimmyaddress: ship live GA4 (backfill from s  ·  back to Site Factory  ·  auto-save: 2026-06-21T18:53:00 (12 files) — logs/cncp-panel. 5bd4ae3 →