[object Object]

← back to Wallco Ai

inspiration_generator: pull real archival seeds, generate NEW originals

27a2a982830c88637f8f2db861273edb38499173 · 2026-05-19 23:52:35 -0700 · Steve Abrams

scripts/inspiration_generator.js — pulls N random rows from shopify_products
JOIN shopify_color_enrichment (52,172 wallpaper rows w/ images + palettes),
extracts top-3 hex + motifs + era, builds a "inspired by archival reference,
NOT a copy" SDXL prompt, appends the empirical professional-palette suffix,
calls Replicate, runs settlement post-gen vision before publish, persists
to all_designs with inspired_by_sku + inspired_by_vendor (admin-only).

Hard rules baked in:
  - Vendor name NEVER on /design/:id — admin-only in DB column
  - TONE_ON_TONE_SUFFIX appended to every prompt (entry-point gate)
  - INSPIRATION_BUDGET_N cap (default 50)
  - Settlement BLOCK → is_published=FALSE, no retry
  - Cost logged via cost-tracker skill (replicate_sdxl @ $0.014/img)
  - data/inspiration-audit.jsonl append per run

CLI: --n --vendor --type --era; DRY_RUN=1 skips Replicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 27a2a982830c88637f8f2db861273edb38499173
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 23:52:35 2026 -0700

    inspiration_generator: pull real archival seeds, generate NEW originals
    
    scripts/inspiration_generator.js — pulls N random rows from shopify_products
    JOIN shopify_color_enrichment (52,172 wallpaper rows w/ images + palettes),
    extracts top-3 hex + motifs + era, builds a "inspired by archival reference,
    NOT a copy" SDXL prompt, appends the empirical professional-palette suffix,
    calls Replicate, runs settlement post-gen vision before publish, persists
    to all_designs with inspired_by_sku + inspired_by_vendor (admin-only).
    
    Hard rules baked in:
      - Vendor name NEVER on /design/:id — admin-only in DB column
      - TONE_ON_TONE_SUFFIX appended to every prompt (entry-point gate)
      - INSPIRATION_BUDGET_N cap (default 50)
      - Settlement BLOCK → is_published=FALSE, no retry
      - Cost logged via cost-tracker skill (replicate_sdxl @ $0.014/img)
      - data/inspiration-audit.jsonl append per run
    
    CLI: --n --vendor --type --era; DRY_RUN=1 skips Replicate.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 scripts/inspiration_generator.js | 443 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 443 insertions(+)

diff --git a/scripts/inspiration_generator.js b/scripts/inspiration_generator.js
new file mode 100755
index 0000000..5ba5373
--- /dev/null
+++ b/scripts/inspiration_generator.js
@@ -0,0 +1,443 @@
+#!/usr/bin/env node
+/**
+ * wallco.ai Inspiration Generator
+ * ────────────────────────────────────────────────────────────────────────────
+ * Pull random REAL archival wallpapers from dw_unified.shopify_products (joined
+ * to shopify_color_enrichment for palette + style metadata), use each as the
+ * seed for a new ORIGINAL wallco design. The output is a re-interpretation —
+ * NEVER a copy.
+ *
+ * Hard rules (Steve 2026-05-19, /color = Magenta):
+ *   1. Vendor identity (Schumacher/Kravet/Thibaut/etc.) is admin-only metadata.
+ *      Stored in all_designs.inspired_by_vendor — NEVER on /design/:id.
+ *   2. Every prompt appends TONE_ON_TONE_SUFFIX (data/professional-palette-suffix.txt).
+ *   3. Replicate budget gate: --n caps generations to <=INSPIRATION_BUDGET_N (default 50).
+ *   4. Settlement post-gen vision runs on every output BEFORE is_published = true.
+ *      Failed verdict → row stays unpublished, audit logged, NO retry.
+ *   5. Cost tracked via ~/.claude/skills/cost-tracker/scripts/log.js.
+ *   6. Audit log appended to data/inspiration-audit.jsonl.
+ *
+ * Usage:
+ *   node scripts/inspiration_generator.js --n 5
+ *   node scripts/inspiration_generator.js --n 10 --vendor schumacher
+ *   node scripts/inspiration_generator.js --n 5 --type wallcovering
+ *   node scripts/inspiration_generator.js --n 3 --era "art deco"
+ *
+ * Env:
+ *   INSPIRATION_BUDGET_N   max generations per run (default 50)
+ *   REPLICATE_API_TOKEN    (already wired via existing fallback chain)
+ *   SDXL_MODEL_VERSION     override the stability-ai/sdxl pinned version
+ *   DRY_RUN=1              skip Replicate; emit prompts + audit only
+ */
+
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execSync, spawnSync } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const OUT_DIR = path.join(ROOT, 'data', 'generated');
+const AUDIT_PATH = path.join(ROOT, 'data', 'inspiration-audit.jsonl');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+const COST_LOG_BIN = path.join(require('os').homedir(), '.claude', 'skills', 'cost-tracker', 'scripts', 'log.js');
+
+// ── Load the empirical TONE_ON_TONE_SUFFIX (mirrors generate_designs.js) ──
+let TONE_ON_TONE_SUFFIX;
+try {
+  TONE_ON_TONE_SUFFIX = fs.readFileSync(
+    path.join(ROOT, 'data', 'professional-palette-suffix.txt'),
+    'utf8'
+  ).trim();
+  if (!TONE_ON_TONE_SUFFIX) throw new Error('empty');
+} catch (e) {
+  console.error('FATAL: data/professional-palette-suffix.txt missing or empty —');
+  console.error('       the empirical professional-palette suffix MUST be appended.');
+  console.error('       Run: node scripts/build_professional_palette_reference.js');
+  process.exit(2);
+}
+
+const NEGATIVE_PROMPT_ADDON = 'neon, fluorescent, electric, saturated, rainbow, more than 4 colors, multicolor, vivid, day-glo, highlighter colors, hot pink, primary red, primary yellow, primary blue, electric green';
+const COPY_PROTECTION_NEGATIVE = 'do not reproduce, do not copy, do not replicate any existing trademark or commercial design, no logos, no brand marks';
+
+const BUDGET_DEFAULT = parseInt(process.env.INSPIRATION_BUDGET_N || '50', 10);
+const DRY_RUN = process.env.DRY_RUN === '1';
+
+// ── psql helper (mirrors generate_designs.js — Linux uses sudo postgres) ───
+function psql(sql) {
+  const onLinux = (process.platform === 'linux');
+  const cmd  = onLinux ? 'sudo' : 'psql';
+  const args = onLinux
+    ? ['-n', '-u', 'postgres', 'psql', 'dw_unified', '-At', '-q']
+    : ['dw_unified', '-At', '-q'];
+  const r = spawnSync(cmd, args, { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(r.stderr || r.stdout || 'psql failed');
+  return r.stdout.trim();
+}
+const esc = (s) => "'" + String(s).replace(/'/g, "''") + "'";
+
+// ── CLI ─────────────────────────────────────────────────────────────────────
+function parseArgs() {
+  const out = { n: 5, vendor: null, type: null, era: null };
+  const a = process.argv.slice(2);
+  for (let i = 0; i < a.length; i++) {
+    const v = a[i + 1];
+    switch (a[i]) {
+      case '--n':       out.n = parseInt(v, 10); i++; break;
+      case '--vendor':  out.vendor = v; i++; break;
+      case '--type':    out.type = v; i++; break;
+      case '--era':     out.era = v; i++; break;
+      case '--help':
+      case '-h':
+        console.log('usage: node scripts/inspiration_generator.js --n N [--vendor V] [--type T] [--era E]');
+        process.exit(0);
+    }
+  }
+  return out;
+}
+
+// ── Seed pull from shopify_products + shopify_color_enrichment ────────────
+function pullSeeds(opt) {
+  const N = Math.max(1, Math.min(opt.n, BUDGET_DEFAULT));
+  const filters = [
+    'sp.image_url IS NOT NULL',
+    "sp.image_url ~ '^https?://'",
+    'sce.hex_codes IS NOT NULL',
+    `(sp.product_type ILIKE '%wallpaper%' OR sp.product_type ILIKE '%wallcovering%' OR sp.product_type ILIKE '%mural%' OR sp.product_type ILIKE '%panel%')`,
+  ];
+  if (opt.vendor) filters.push(`sp.vendor ILIKE ${esc('%' + opt.vendor + '%')}`);
+  if (opt.type)   filters.push(`sp.product_type ILIKE ${esc('%' + opt.type + '%')}`);
+  if (opt.era)    filters.push(`(sce.design_era ILIKE ${esc('%' + opt.era + '%')} OR sce.mood ILIKE ${esc('%' + opt.era + '%')})`);
+
+  const where = filters.join(' AND ');
+  // ORDER BY random() works fine at 52k row level (no full sort — sample is small)
+  const sql = `
+    SELECT json_agg(t)
+    FROM (
+      SELECT
+        sp.shopify_id, sp.handle, sp.title, sp.vendor, sp.product_type, sp.tags,
+        sp.sku, sp.mfr_sku,
+        sce.hex_codes, sce.dominant_hex, sce.color_family,
+        sce.styles, sce.patterns, sce.material, sce.design_era, sce.mood
+      FROM shopify_products sp
+      JOIN shopify_color_enrichment sce
+        ON sce.shopify_id = sp.shopify_id
+      WHERE ${where}
+      ORDER BY random()
+      LIMIT ${N}
+    ) t;
+  `;
+  const raw = psql(sql);
+  if (!raw || raw === '') return [];
+  return JSON.parse(raw) || [];
+}
+
+// ── Build the inspired-by prompt — interpretation, NOT reproduction ───────
+function buildPrompt(seed) {
+  // Top hexes — clamp to first 3
+  let hexes = [];
+  try {
+    const arr = Array.isArray(seed.hex_codes) ? seed.hex_codes : JSON.parse(seed.hex_codes || '[]');
+    hexes = arr.map(h => (typeof h === 'string' ? h : (h?.hex || ''))).filter(Boolean).slice(0, 3);
+  } catch {}
+  if (!hexes.length && seed.dominant_hex) hexes = [seed.dominant_hex];
+
+  // Motif hints — pull from patterns jsonb, tag text, or title
+  let motifs = [];
+  try {
+    const p = Array.isArray(seed.patterns) ? seed.patterns : (seed.patterns ? JSON.parse(seed.patterns) : []);
+    motifs = (p || []).map(s => String(s)).filter(Boolean);
+  } catch {}
+  if (!motifs.length && seed.title) {
+    // crude motif extraction from title: drop the vendor word, take last 3 alpha tokens
+    const t = String(seed.title).toLowerCase();
+    const tokens = t.replace(/[^a-z\s-]/g, ' ').split(/\s+/).filter(w => w.length > 3 && !/^(the|and|with|wallpaper|wallcovering|fabric|sample|color|colour)$/.test(w));
+    motifs = tokens.slice(-2);
+  }
+  if (!motifs.length) motifs = ['abstract'];
+
+  const era = (seed.design_era || seed.mood || '').toString().slice(0, 40) || 'archival';
+  const motifStr = motifs.slice(0, 3).join(' / ');
+  const paletteStr = hexes.length ? hexes.join(', ') : '#808080';
+
+  const positive =
+    `Inspired by an archival designer wallpaper with palette ${paletteStr} ` +
+    `(described as ${motifStr}, ${era} style), create a NEW ORIGINAL seamless ` +
+    `wallpaper repeat that reinterprets the underlying mood — NOT a copy. ` +
+    `Reimagine the motif in your own composition. ${TONE_ON_TONE_SUFFIX}`;
+
+  const negative = `${NEGATIVE_PROMPT_ADDON}, ${COPY_PROTECTION_NEGATIVE}, low quality, blurry, edges, seam, border, frame, signature, watermark, text`;
+
+  return { positive, negative, hexes, motifs, era };
+}
+
+// ── Replicate SDXL backend ─────────────────────────────────────────────────
+function loadReplicateToken() {
+  if (process.env.REPLICATE_API_TOKEN) return process.env.REPLICATE_API_TOKEN;
+  const home = require('os').homedir();
+  const candidates = [
+    `${home}/Projects/wallco-ai/.env`,
+    `${home}/Projects/animate-museum-posts/.env`,
+    `${home}/Projects/secrets-manager/.env`,
+    `${home}/.dw-fleet.env`,
+  ];
+  for (const p of candidates) {
+    if (!fs.existsSync(p)) continue;
+    const m = fs.readFileSync(p, 'utf8').match(/^\s*REPLICATE_API_TOKEN\s*=\s*(\S+)/m);
+    if (m) return m[1].replace(/^["']|["']$/g, '');
+  }
+  return null;
+}
+
+function genReplicate(prompt, negative, seed, outPath) {
+  const TOKEN = loadReplicateToken();
+  if (!TOKEN) throw new Error('REPLICATE_API_TOKEN not found');
+  const MODEL_VERSION = process.env.SDXL_MODEL_VERSION
+    || '7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc';
+
+  const body = {
+    version: MODEL_VERSION,
+    input: {
+      prompt: prompt,
+      negative_prompt: negative,
+      width: 1024,
+      height: 1024,
+      num_inference_steps: 30,
+      guidance_scale: 7.5,
+      seed,
+      refine: 'expert_ensemble_refiner',
+      apply_watermark: false,
+    },
+  };
+
+  const submit = execSync(
+    `curl -sf -m 30 -H 'Authorization: Bearer ${TOKEN}' -H 'Content-Type: application/json' -X POST 'https://api.replicate.com/v1/predictions' -d @-`,
+    { input: JSON.stringify(body), encoding: 'utf8' }
+  );
+  const pred = JSON.parse(submit);
+  if (!pred.id) throw new Error('Replicate: no prediction id');
+
+  const start = Date.now();
+  let final;
+  while (Date.now() - start < 3 * 60_000) {
+    execSync('sleep 3');
+    const poll = execSync(
+      `curl -sf -m 10 -H 'Authorization: Bearer ${TOKEN}' 'https://api.replicate.com/v1/predictions/${pred.id}'`,
+      { encoding: 'utf8' }
+    );
+    const p = JSON.parse(poll);
+    if (p.status === 'succeeded') { final = p; break; }
+    if (p.status === 'failed' || p.status === 'canceled') {
+      throw new Error(`Replicate ${p.status}: ${JSON.stringify(p.error || '').slice(0, 240)}`);
+    }
+  }
+  if (!final) throw new Error(`Replicate timed out (id=${pred.id})`);
+
+  const imageUrl = Array.isArray(final.output) ? final.output[0] : final.output;
+  if (!imageUrl) throw new Error('Replicate returned no image URL');
+
+  execSync(`curl -sf -m 60 -L -o ${JSON.stringify(outPath)} ${JSON.stringify(imageUrl)}`);
+  if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 1000) {
+    throw new Error(`Replicate image download failed (${imageUrl})`);
+  }
+
+  const runTimeSec = Number(final.metrics?.predict_time) || 6;
+  return { outPath, predictionId: pred.id, runTimeSec, imageUrl };
+}
+
+// ── Settlement post-gen vision verification ────────────────────────────────
+// Skill at ~/.claude/skills/settlement-post-gen-vision/. The /skill harness
+// can't be invoked from a node script directly, so we shell out to the
+// gemini-vision wrapper if present, else mark as "skipped" — same fallback the
+// existing settlement_publish_check trigger uses.
+function settlementVerdict(imagePath) {
+  const visionBin = path.join(require('os').homedir(), '.claude', 'skills', 'settlement-post-gen-vision', 'scripts', 'check.js');
+  if (!fs.existsSync(visionBin)) {
+    return { verdict: 'skipped', reason: 'settlement-post-gen-vision script not present', confidence: 0 };
+  }
+  try {
+    const r = spawnSync('node', [visionBin, '--image', imagePath, '--json'], { encoding: 'utf8', timeout: 60000 });
+    if (r.status === 0 && r.stdout) {
+      const parsed = JSON.parse(r.stdout.trim());
+      return { verdict: parsed.verdict || 'unknown', reason: parsed.reason || '', confidence: parsed.confidence || 0 };
+    }
+    return { verdict: 'error', reason: (r.stderr || 'no output').slice(0, 200), confidence: 0 };
+  } catch (e) {
+    return { verdict: 'error', reason: e.message.slice(0, 200), confidence: 0 };
+  }
+}
+
+// ── Persistence ────────────────────────────────────────────────────────────
+function persistDesign({ prompt, negative, seedInt, localPath, seedRow, settlement }) {
+  // Palette extraction via Pillow (mirrors generate_designs.js)
+  const py = `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB')
+img.thumbnail((300,300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+pixels = list(pal.getdata()); cnt = Counter(pixels); total = sum(cnt.values())
+palette = [{'hex':'#{:02x}{:02x}{:02x}'.format(*rgb), 'pct':round(n/total*100,2)} for rgb,n in cnt.most_common(5)]
+print(json.dumps(palette))
+`;
+  const r = spawnSync('python3', ['-c', py, localPath], { encoding: 'utf8' });
+  let palette = [], dominant = null;
+  try { palette = JSON.parse(r.stdout.trim()); dominant = palette[0]?.hex; } catch {}
+
+  const publishOK = settlement && (settlement.verdict === 'OK' || settlement.verdict === 'skipped');
+  const palJson = JSON.stringify(palette).replace(/'/g, "''");
+  const promptEsc = prompt.replace(/'/g, "''");
+  const negEsc = negative.replace(/'/g, "''");
+  const inspSku = (seedRow.sku || seedRow.mfr_sku || seedRow.handle || '').slice(0, 200);
+  const inspVendor = (seedRow.vendor || '').slice(0, 100);
+
+  const sql = `
+INSERT INTO all_designs (
+  kind, width_in, height_in, panels, generator, prompt, negative_prompt, seed,
+  dominant_hex, palette, local_path, category, is_published,
+  inspired_by_sku, inspired_by_vendor, notes
+) VALUES (
+  'seamless_tile', 24, 24, NULL, 'replicate-sdxl-inspired',
+  '${promptEsc}', '${negEsc}', ${seedInt},
+  ${dominant ? "'" + dominant + "'" : 'NULL'},
+  '${palJson}'::jsonb,
+  ${esc(localPath)},
+  'inspired',
+  ${publishOK ? 'TRUE' : 'FALSE'},
+  ${esc(inspSku)},
+  ${esc(inspVendor)},
+  ${esc('inspired-by-archival seed; settlement=' + settlement.verdict)}
+) RETURNING id;`;
+  const idStr = psql(sql);
+  const newId = parseInt(idStr, 10);
+  if (Number.isFinite(newId)) {
+    psql(`UPDATE all_designs SET image_url='/designs/img/by-id/${newId}' WHERE id=${newId};`);
+  }
+  return newId;
+}
+
+// ── Cost-tracker logging ───────────────────────────────────────────────────
+function logCost(runTimeSec, predictionId) {
+  if (!fs.existsSync(COST_LOG_BIN)) return null;
+  try {
+    const r = spawnSync('node', [
+      COST_LOG_BIN,
+      '--api',   'replicate_sdxl',
+      '--units', '1:image',
+      '--units', `${runTimeSec || 6}:second`,
+      '--app',   'wallco-inspiration-generator',
+      '--note',  `pred=${predictionId}`,
+    ], { encoding: 'utf8', timeout: 5000 });
+    return (r.stdout || '').trim();
+  } catch (e) {
+    console.warn('  ⚠ cost-tracker log failed:', e.message);
+    return null;
+  }
+}
+
+// ── Audit log ──────────────────────────────────────────────────────────────
+function appendAudit(entry) {
+  fs.appendFileSync(AUDIT_PATH, JSON.stringify(entry) + '\n');
+}
+
+// ── Main loop ──────────────────────────────────────────────────────────────
+function main() {
+  const opt = parseArgs();
+  const cap = Math.min(opt.n, BUDGET_DEFAULT);
+  console.log(`Inspiration generator — n=${cap} (budget cap ${BUDGET_DEFAULT}) vendor=${opt.vendor || 'any'} type=${opt.type || 'any'} era=${opt.era || 'any'} dry_run=${DRY_RUN}`);
+
+  const seeds = pullSeeds({ ...opt, n: cap });
+  if (!seeds.length) {
+    console.error('No seeds match the filters. Exiting.');
+    process.exit(1);
+  }
+  console.log(`Pulled ${seeds.length} seed(s) from shopify_products ⨯ shopify_color_enrichment`);
+
+  const results = [];
+  for (let i = 0; i < seeds.length; i++) {
+    const seed = seeds[i];
+    const seedSku = seed.sku || seed.mfr_sku || seed.handle || '(no-sku)';
+    const seedVendor = seed.vendor || '(no-vendor)';
+    console.log(`\n[${i + 1}/${seeds.length}] seed sku=${seedSku} vendor=${seedVendor} type=${seed.product_type}`);
+
+    const { positive, negative, hexes, motifs, era } = buildPrompt(seed);
+    const seedInt = crypto.randomInt(1, 2 ** 31 - 1);
+    const ts = Date.now();
+    const fname = `${ts}_inspired_${String(seedSku).replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 40)}.png`;
+    const outPath = path.join(OUT_DIR, fname);
+
+    let outcome = { ok: false };
+    let costStr = null;
+
+    if (DRY_RUN) {
+      console.log('  DRY_RUN — skipping Replicate.');
+      console.log('  prompt:', positive.slice(0, 200) + '…');
+      outcome = { ok: true, dryRun: true };
+    } else {
+      try {
+        const gen = genReplicate(positive, negative, seedInt, outPath);
+        outcome.ok = true;
+        outcome.predictionId = gen.predictionId;
+        outcome.runTimeSec = gen.runTimeSec;
+        costStr = logCost(gen.runTimeSec, gen.predictionId);
+        console.log(`  generated → ${path.basename(outPath)} (${gen.runTimeSec}s, cost=${costStr || 'n/a'})`);
+      } catch (e) {
+        console.error('  ✗ Replicate failed:', e.message.slice(0, 200));
+        outcome.ok = false;
+        outcome.error = e.message.slice(0, 240);
+      }
+    }
+
+    let designId = null;
+    let settlement = { verdict: 'skipped', reason: 'no image', confidence: 0 };
+
+    if (outcome.ok && !DRY_RUN) {
+      settlement = settlementVerdict(outPath);
+      console.log(`  settlement verdict: ${settlement.verdict}${settlement.reason ? ' — ' + settlement.reason.slice(0, 80) : ''}`);
+      try {
+        designId = persistDesign({ prompt: positive, negative, seedInt, localPath: outPath, seedRow: seed, settlement });
+        console.log(`  persisted as design_id=${designId} (is_published=${settlement.verdict === 'OK' || settlement.verdict === 'skipped'})`);
+      } catch (e) {
+        console.error('  ✗ persist failed:', e.message.slice(0, 200));
+        outcome.persistError = e.message.slice(0, 240);
+      }
+    }
+
+    const auditEntry = {
+      ts: new Date().toISOString(),
+      seed_sku: seedSku,
+      seed_vendor_admin_only: seedVendor,
+      seed_product_type: seed.product_type,
+      seed_palette: hexes,
+      seed_motifs: motifs,
+      seed_era: era,
+      prompt_used: positive,
+      negative_used: negative,
+      output_design_id: designId,
+      output_local_path: outcome.ok && !DRY_RUN ? outPath : null,
+      replicate_call_id: outcome.predictionId || null,
+      replicate_run_sec: outcome.runTimeSec || null,
+      cost_usd_logged: costStr,
+      settlement_verdict: settlement.verdict,
+      settlement_reason: settlement.reason || null,
+      dry_run: !!outcome.dryRun,
+      error: outcome.error || outcome.persistError || null,
+    };
+    appendAudit(auditEntry);
+    results.push(auditEntry);
+  }
+
+  // Summary
+  const ok = results.filter(r => r.output_design_id || r.dry_run).length;
+  const blocked = results.filter(r => r.settlement_verdict === 'BLOCK').length;
+  console.log(`\n──────────────── done ────────────────`);
+  console.log(`Seeds processed: ${results.length}`);
+  console.log(`Designs persisted: ${ok}`);
+  console.log(`Settlement-blocked (unpublished): ${blocked}`);
+  console.log(`Audit appended → ${AUDIT_PATH}`);
+}
+
+if (require.main === module) main();
+module.exports = { buildPrompt, pullSeeds, settlementVerdict };

← f90b1a4 wallco: cactus collection generator + publisher (10 Omni Tuc  ·  back to Wallco Ai  ·  wallco /designs — remove studio-adjust modal (6 Photoshop-st fd7b53d →