[object Object]

← back to Council 0809 Builds

Council 2026-08-09: build ideas 1,2,3,5 (disk forecaster, Kravet MAP gate, sourcing brief, rolls AR deep-link)

371b4e83e14cd533e80b2b58effeefcfca4fdf88 · 2026-08-09 08:40:18 -0700 · Claude (claude-mail)

Files touched

Diff

commit 371b4e83e14cd533e80b2b58effeefcfca4fdf88
Author: Claude (claude-mail) <steve@designerwallcoverings.com>
Date:   Sun Aug 9 08:40:18 2026 -0700

    Council 2026-08-09: build ideas 1,2,3,5 (disk forecaster, Kravet MAP gate, sourcing brief, rolls AR deep-link)
---
 .gitignore                       |   9 +++
 README.md                        |  17 ++++++
 disk-forecaster/INSTALL.md       |  22 ++++++++
 disk-forecaster/forecast-disk.sh |  88 ++++++++++++++++++++++++++++++
 kravet-map-gate/map-gate.js      | 115 +++++++++++++++++++++++++++++++++++++++
 rolls-ar-deeplink/README.md      |  43 +++++++++++++++
 rolls-ar-deeplink/deeplink.js    |  40 ++++++++++++++
 sourcing-brief/build-brief.js    |  90 ++++++++++++++++++++++++++++++
 sourcing-brief/sample-gaps.json  |  35 ++++++++++++
 9 files changed, 459 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..52d085c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/*.tsv
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f531f46
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# Council 2026-08-09 builds — "Build except 4"
+
+Steve's 2026-08-09 email reply to the Officer Idea Council Top-5 digest:
+**"Build except 4. Dust2026"** → build ideas 1, 2, 3, 5; skip idea 4 (canary
+circuit-breaker dashboard).
+
+| # | Idea | Owner | Built this pass | Gated / staged |
+|---|------|-------|-----------------|----------------|
+| 1 | Kamatera Disk Runway Forecaster | vp-operations | `disk-forecaster/forecast-disk.sh` — du→growth-log→linear extrapolation→George+CNCP alert. Verified locally (rate-based runway math). | Kamatera cron **install** (remote prod scheduled job) — staged, see `disk-forecaster/INSTALL.md` |
+| 2 | Kravet MAP Activation Gate | vp-dw-commerce | `kravet-map-gate/map-gate.js` — report + pre-activation `check` mode. Verified: blocks below-MAP, passes at-MAP. Exposure = **286 active SKUs below MAP + 1 sample-leak**. | The **reprice** of the 286 (Shopify price writes) — customer-facing, already in memo `TK-10014-dwkk-below-map` |
+| 3 | Weekly Sourcing Brief | vp-research-content | `sourcing-brief/build-brief.js` + sample. Verified: ranks by demand×gap, carries settlement warnings. | Wire to live design-trend-scout output + weekly George send (additive, low-risk follow-up) |
+| 5 | PDP "How Many Rolls?" AR deep-link | vp-dw-commerce | `rolls-ar-deeplink/deeplink.js` — safe link builder (hides button w/o data). Audit done. | Live Admin-API metafield coverage pull + **PDP theme injection** — customer-facing, gated (see subdir README) |
+
+Everything in this repo is read-only against the `dw_unified` mirror or pure
+local codegen. No production write, no customer-facing change, no DNS, no
+remote push has been executed. The gated columns above are what still needs
+Steve's explicit go (or a live session with the right tokens).
diff --git a/disk-forecaster/INSTALL.md b/disk-forecaster/INSTALL.md
new file mode 100644
index 0000000..4e7a5ee
--- /dev/null
+++ b/disk-forecaster/INSTALL.md
@@ -0,0 +1,22 @@
+# Installing the Disk Runway Forecaster on Kamatera
+
+The script runs locally for testing but its home is Kamatera (45.61.58.125),
+where the TK-10236 ENOSPC crash happened. Installing a cron on the prod box is
+a remote scheduled-job install — **staged for Steve's go**, not auto-run.
+
+## One-time install (run in a live session or paste to Steve)
+```bash
+scp forecast-disk.sh root@45.61.58.125:/root/ops/forecast-disk.sh
+ssh root@45.61.58.125 'chmod +x /root/ops/forecast-disk.sh && \
+  ( crontab -l 2>/dev/null; echo "0 5 * * 1 ROOT=/root TOP=20 THRESHOLD_DAYS=14 GEORGE_BASIC_AUTH_PASS=<pass> /root/ops/forecast-disk.sh >> /root/ops/forecast-disk.log 2>&1" ) | crontab -'
+```
+Weekly (Mon 05:00). First 2 runs only build the baseline log; forecasts begin
+once ≥2 time-separated samples exist.
+
+## Reversible
+`ssh root@45.61.58.125 'crontab -l | grep -v forecast-disk.sh | crontab -'`
+
+## Note on George pass
+The alert path needs `GEORGE_BASIC_AUTH_PASS` (inner George auth). Per memory
+`george-gmail-auth-topology` it lives only in the pm2 process env — wire it in
+during the live install, don't hardcode it here.
diff --git a/disk-forecaster/forecast-disk.sh b/disk-forecaster/forecast-disk.sh
new file mode 100755
index 0000000..1ba64a2
--- /dev/null
+++ b/disk-forecaster/forecast-disk.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Kamatera Disk Runway Forecaster  (Council idea #1, 2026-08-09)
+# -------------------------------------------------------------
+# Forecasting, not thresholding: samples du per top directory, appends to a
+# rolling log, fits a linear growth rate over the trailing window, and
+# extrapolates DAYS-UNTIL-FULL. Fires a George email + CNCP card when any
+# tracked path's runway drops below THRESHOLD_DAYS.
+#
+# Born from TK-10236: prod Postgres ENOSPC-crashed on a 98%-full disk. A
+# threshold canary only screams once you're already near the wall; this
+# predicts the wall ~2 weeks out so the prune happens on a weekday, calmly.
+#
+# Designed to run on Kamatera via cron/launchd (weekly). Safe to dry-run
+# anywhere: pass a ROOT to scan and it just prints the forecast (no email).
+#
+# Usage:
+#   ROOT=/root TOP=20 THRESHOLD_DAYS=14 ./forecast-disk.sh          # real
+#   ROOT="$HOME/Projects" DRY=1 ./forecast-disk.sh                  # dry-run
+set -euo pipefail
+
+ROOT="${ROOT:-/root}"
+TOP="${TOP:-20}"
+THRESHOLD_DAYS="${THRESHOLD_DAYS:-14}"
+LOG="${LOG:-$HOME/disk-growth-log.tsv}"
+DRY="${DRY:-0}"
+NOW="$(date +%s)"
+
+# --- capacity of the filesystem holding ROOT (KB) ---
+avail_kb="$(df -Pk "$ROOT" | awk 'NR==2{print $4}')"
+size_kb="$(df -Pk "$ROOT" | awk 'NR==2{print $2}')"
+
+# --- sample: du -sk per top-N child dir, append to the rolling log ---
+touch "$LOG"
+du -sk "$ROOT"/*/ 2>/dev/null | sort -rn | head -n "$TOP" | while IFS=$'\t' read -r kb path; do
+  printf '%s\t%s\t%s\n' "$NOW" "$kb" "$path" >> "$LOG"
+done
+
+# --- forecast per path from the trailing samples in the log ---
+# linear rate = (latest - earliest)/(t_latest - t_earliest) KB/sec over >=2 pts
+# days_until_full = free_kb / (rate_kb_per_sec * 86400), attributed to fastest grower
+alerts="$(awk -v now="$NOW" -v avail="$avail_kb" -v thr="$THRESHOLD_DAYS" '
+  { t=$1; kb=$2; p=$3;
+    if (!(p in firstT) || t<firstT[p]) { firstT[p]=t; firstKB[p]=kb }
+    if (!(p in lastT)  || t>lastT[p])  { lastT[p]=t;  lastKB[p]=kb }
+  }
+  END {
+    for (p in lastT) {
+      dt = lastT[p]-firstT[p];
+      if (dt <= 0) continue;                       # need >=2 samples apart
+      rate = (lastKB[p]-firstKB[p])/dt;            # KB/sec
+      if (rate <= 0) continue;                     # flat/shrinking = no runway risk
+      days = avail / (rate*86400);
+      if (days < thr) printf "%s\t%.1f\t%.2f\n", p, days, rate*86400/1024/1024;
+    }
+  }' "$LOG" | sort -t$'\t' -k2 -n)"
+
+pct_used="$(df -Pk "$ROOT" | awk 'NR==2{gsub("%","",$5); print $5}')"
+echo "[$(date -u +%FT%TZ)] ROOT=$ROOT used=${pct_used}% avail=$((avail_kb/1024/1024))G threshold=${THRESHOLD_DAYS}d"
+
+if [ -z "$alerts" ]; then
+  echo "OK: no tracked path forecast to fill within ${THRESHOLD_DAYS} days (or <2 samples yet)."
+  exit 0
+fi
+
+echo "RUNWAY ALERT — paths forecast to fill within ${THRESHOLD_DAYS} days:"
+echo "$alerts" | awk -F'\t' '{printf "  %-40s  %5.1f days  (+%.2f GB/day)\n",$1,$2,$3}'
+
+if [ "$DRY" = "1" ]; then
+  echo "(dry-run: email/CNCP suppressed)"
+  exit 0
+fi
+
+# --- fire George email + CNCP card (only on real run) ---
+body="Kamatera disk runway forecast — paths projected to fill within ${THRESHOLD_DAYS} days:
+$(echo "$alerts" | awk -F'\t' '{printf "  %s : %.1f days (+%.2f GB/day)\n",$1,$2,$3}')
+
+Filesystem holding ${ROOT}: ${pct_used}% used, $((avail_kb/1024/1024))G free.
+Prune before the wall — TK-10236 was an ENOSPC crash. Suggested: homesonspec/var/snapshots cache."
+curl -s -X POST http://127.0.0.1:9850/api/send \
+  -H 'Content-Type: application/json' \
+  -u "admin:${GEORGE_BASIC_AUTH_PASS:-}" \
+  -d "$(python3 -c 'import json,os,sys; print(json.dumps({"to":"steve@designerwallcoverings.com","subject":"[disk-forecaster] Kamatera runway < '"$THRESHOLD_DAYS"'d","text":sys.stdin.read()}))' <<<"$body")" \
+  >/dev/null 2>&1 || echo "(warn: George send failed)"
+curl -s -X POST http://127.0.0.1:3333/api/parking-lot \
+  -H 'Content-Type: application/json' \
+  -d "{\"project\":\"kamatera-ops\",\"title\":\"Disk runway < ${THRESHOLD_DAYS}d\",\"detail\":$(python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))' <<<"$body")}" \
+  >/dev/null 2>&1 || true
+echo "alerted."
diff --git a/kravet-map-gate/map-gate.js b/kravet-map-gate/map-gate.js
new file mode 100644
index 0000000..8bda9f1
--- /dev/null
+++ b/kravet-map-gate/map-gate.js
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/**
+ * Kravet MAP Activation Gate  (Council idea #2, 2026-08-09)
+ * ---------------------------------------------------------
+ * Kravet-umbrella brands sell at MAP = wholesale × 1.5 (Kravet enforces it).
+ * A batch push that activates at the generic cost/0.65/0.85 formula instead of
+ * MAP creates a live MAP-compliance breach. This gate answers ONE question
+ * before any status flip to ACTIVE:  "is this price >= MAP?"
+ *
+ * Two modes:
+ *   report        - print current exposure (active Kravet SKUs priced < MAP)
+ *   check <json>   - gate a proposed activation batch; exit 1 if any would breach
+ *
+ * READ-ONLY against the local dw_unified mirror. It never writes a price and
+ * never flips a status — it is a guard other scripts call, not an executor.
+ *
+ * MAP source priority (per CLAUDE.md kravet-lines-price-at-map):
+ *   1. kravet_authoritative_pricing.new_map   (loaded from the Kravet list)
+ *   2. new_whls * 1.5  (derived, when map is null but whls is known)
+ */
+const { execFileSync } = require('child_process');
+
+const KRAVET_VENDOR_RE =
+  'kravet|lee jofa|brunschwig|groundworks|cole & son|baker|clarke|mulberry|' +
+  'threads|gp ?& ?j|colefax|nicolette|aerin|barclay|thom filicia|andrew martin';
+
+function q(sql) {
+  const out = execFileSync(
+    'psql',
+    ['-h', '/tmp', '-d', 'dw_unified', '-tAF', '\t', '-c', sql],
+    { encoding: 'utf8', env: { ...process.env, PATH: '/opt/homebrew/bin:' + process.env.PATH } }
+  );
+  return out.trim().split('\n').filter(Boolean).map((l) => l.split('\t'));
+}
+
+// Effective MAP per mfr_sku: explicit map, else whls*1.5
+const MAP_EXPR = `COALESCE(kap.new_map, kap.new_whls * 1.5)`;
+
+function report() {
+  const rows = q(`
+    WITH k AS (
+      SELECT sp.dw_sku, sp.vendor, sp.mfr_sku, sp.price::numeric AS price,
+             ${MAP_EXPR} AS map
+      FROM shopify_products sp
+      JOIN kravet_authoritative_pricing kap
+        ON upper(trim(sp.mfr_sku)) = upper(trim(kap.mfr_sku))
+       AND coalesce(trim(sp.mfr_sku),'') <> ''
+      WHERE sp.status = 'ACTIVE' AND sp.vendor ~* '${KRAVET_VENDOR_RE}')
+    SELECT
+      count(*),
+      count(*) FILTER (WHERE price < map - 0.01),
+      count(*) FILTER (WHERE abs(price - 4.25) < 0.01)
+    FROM k;`)[0];
+  const [matched, belowMap, sampleLeak] = rows.map(Number);
+  console.log(`Kravet MAP exposure (active, mfr_sku-matched):`);
+  console.log(`  matched to authoritative pricing : ${matched}`);
+  console.log(`  priced BELOW MAP (breach risk)   : ${belowMap}`);
+  console.log(`  $4.25 sample leaking as sell price: ${sampleLeak}`);
+  // worst offenders
+  const worst = q(`
+    WITH k AS (
+      SELECT sp.dw_sku, sp.vendor, sp.price::numeric AS price, ${MAP_EXPR} AS map
+      FROM shopify_products sp
+      JOIN kravet_authoritative_pricing kap
+        ON upper(trim(sp.mfr_sku)) = upper(trim(kap.mfr_sku))
+       AND coalesce(trim(sp.mfr_sku),'') <> ''
+      WHERE sp.status='ACTIVE' AND sp.vendor ~* '${KRAVET_VENDOR_RE}')
+    SELECT dw_sku, vendor, price, round(map,2), round((map-price),2) AS short
+    FROM k WHERE price < map - 0.01 ORDER BY (map-price) DESC LIMIT 10;`);
+  if (worst.length) {
+    console.log(`\n  Top 10 below-MAP (dw_sku | vendor | price | MAP | short-by):`);
+    worst.forEach((r) => console.log('   ', r.join(' | ')));
+  }
+  return { matched, belowMap, sampleLeak };
+}
+
+/**
+ * Gate a proposed activation batch.
+ * batch = [{ mfr_sku, price }]  (the values a push is about to make ACTIVE)
+ * Returns { ok, breaches: [{mfr_sku, price, map}] }.
+ */
+function check(batch) {
+  if (!Array.isArray(batch) || !batch.length) return { ok: true, breaches: [] };
+  const values = batch
+    .map((b) => `('${String(b.mfr_sku).replace(/'/g, "''")}', ${Number(b.price)})`)
+    .join(',');
+  const rows = q(`
+    WITH proposed(mfr_sku, price) AS (VALUES ${values})
+    SELECT p.mfr_sku, p.price, round(${MAP_EXPR},2) AS map
+    FROM proposed p
+    JOIN kravet_authoritative_pricing kap
+      ON upper(trim(p.mfr_sku)) = upper(trim(kap.mfr_sku))
+    WHERE p.price < ${MAP_EXPR} - 0.01;`);
+  const breaches = rows.map(([mfr_sku, price, map]) => ({ mfr_sku, price: +price, map: +map }));
+  return { ok: breaches.length === 0, breaches };
+}
+
+if (require.main === module) {
+  const [, , mode, arg] = process.argv;
+  if (mode === 'check') {
+    const batch = JSON.parse(arg || '[]');
+    const res = check(batch);
+    if (!res.ok) {
+      console.error(`BLOCKED: ${res.breaches.length} SKU(s) would activate below MAP:`);
+      res.breaches.forEach((b) =>
+        console.error(`  ${b.mfr_sku}  price ${b.price} < MAP ${b.map}`));
+      process.exit(1);
+    }
+    console.log(`OK: all ${batch.length} proposed activations are >= MAP.`);
+  } else {
+    report();
+  }
+}
+
+module.exports = { check, report };
diff --git a/rolls-ar-deeplink/README.md b/rolls-ar-deeplink/README.md
new file mode 100644
index 0000000..5a34b7a
--- /dev/null
+++ b/rolls-ar-deeplink/README.md
@@ -0,0 +1,43 @@
+# DW PDP "How Many Rolls?" AR Deep-Link — Council idea #5 (2026-08-09)
+
+**Status: AUDIT DONE · build STAGED (customer-facing PDP change → Steve-gated)**
+
+Idea: DW products carry width + pattern-repeat metafields. Deep-link those into
+the NPH AR measure tool from the product page so a shopper gets "how many rolls
+do I need?" answered at the decision moment — the #1 pre-purchase question, and
+a moat because competitor PDPs don't carry the metafields.
+
+## First-step audit finding (the idea's own gate: "is the data reliable enough?")
+
+Ran against the local `dw_unified` mirror on 2026-08-09:
+
+- `product_specs` holds **zero** `width`/`repeat` rows — it is NOT the store.
+- Width/repeat actually live in **Shopify custom metafields**, which the Mac2
+  mirror does not carry.
+- `pr_width_repeat_migration_log` proves a real migration happened:
+  **1,548 products** got `new_custom_width` + `new_custom_pattern_repeat`.
+
+**Conclusion:** coverage is *partial and not verifiable from the mirror*. Before
+committing a URL format we need a **live Shopify Admin-API metafield pull**
+(custom.width, custom.pattern_repeat) to measure true population across the
+active catalog. That pull needs the Admin token + is a live read against the
+canonical store — staged, not run from this email session.
+
+## Deep-link format (proposed, pending the live coverage number)
+
+```
+https://nph.designerwallcoverings.com/measure
+  ?w={custom.width_in}
+  &vr={custom.vertical_repeat_in}
+  &hr={custom.horizontal_repeat_in}
+  &sku={dw_sku}
+```
+
+`buildDeepLink()` in `deeplink.js` is written and unit-safe; it only emits a
+link when width AND a repeat are present (else the button hides — no broken
+"0 rolls" experience).
+
+## Gated next steps (need Steve go)
+1. Live Admin-API metafield coverage pull → real population %.
+2. If coverage is high enough, inject the button into the PDP theme
+   (snippet in the DW theme 144396058675) — **customer-facing, gated**.
diff --git a/rolls-ar-deeplink/deeplink.js b/rolls-ar-deeplink/deeplink.js
new file mode 100644
index 0000000..4111ed2
--- /dev/null
+++ b/rolls-ar-deeplink/deeplink.js
@@ -0,0 +1,40 @@
+/**
+ * "How Many Rolls?" AR deep-link builder  (Council idea #5, 2026-08-09)
+ * Emits a measure-tool deep-link ONLY when the product carries the metafields
+ * needed to answer the question. Missing data -> null -> the PDP hides the
+ * button rather than showing a broken "0 rolls" flow.
+ */
+const BASE = 'https://nph.designerwallcoverings.com/measure';
+
+function num(v) {
+  const n = parseFloat(v);
+  return Number.isFinite(n) && n > 0 ? n : null;
+}
+
+/**
+ * @param {object} mf  metafields: { width_in, vertical_repeat_in, horizontal_repeat_in }
+ * @param {string} dwSku
+ * @returns {string|null} deep-link, or null if insufficient data (hide the button)
+ */
+function buildDeepLink(mf = {}, dwSku = '') {
+  const w = num(mf.width_in);
+  const vr = num(mf.vertical_repeat_in);
+  const hr = num(mf.horizontal_repeat_in);
+  if (!w || (!vr && !hr)) return null; // need width AND at least one repeat
+  const q = new URLSearchParams();
+  q.set('w', w);
+  if (vr) q.set('vr', vr);
+  if (hr) q.set('hr', hr);
+  if (dwSku) q.set('sku', dwSku);
+  return `${BASE}?${q.toString()}`;
+}
+
+module.exports = { buildDeepLink };
+
+// quick self-test when run directly
+if (require.main === module) {
+  console.log('full :', buildDeepLink({ width_in: 27, vertical_repeat_in: 18, horizontal_repeat_in: 13.5 }, 'DWKK-100367'));
+  console.log('width+vr only:', buildDeepLink({ width_in: 20.5, vertical_repeat_in: 25 }, 'DW-1'));
+  console.log('no repeat -> null:', buildDeepLink({ width_in: 27 }, 'DW-2'));
+  console.log('no width  -> null:', buildDeepLink({ vertical_repeat_in: 18 }, 'DW-3'));
+}
diff --git a/sourcing-brief/build-brief.js b/sourcing-brief/build-brief.js
new file mode 100644
index 0000000..5f7d4fa
--- /dev/null
+++ b/sourcing-brief/build-brief.js
@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+/**
+ * Design Trend Scout -> Weekly Sourcing Brief  (Council idea #3, 2026-08-09)
+ * -------------------------------------------------------------------------
+ * design-trend-scout already finds catalog gaps vs Spoonflower/Etsy top
+ * sellers, but its findings are ephemeral. This turns them into a standing
+ * Monday HTML artifact Steve can act on: top-N trend gaps, each with the
+ * marketplace evidence, the DW gap, and a suggested (settlement-safe) design
+ * brief.
+ *
+ * Input : a JSON array of gap objects (from design-trend-scout output), or the
+ *         bundled sample if none supplied.
+ * Output: an HTML brief to stdout (pipe to a file) — deliver via George.
+ *
+ * Zero paid spend. Reads local JSON, emits HTML. No network.
+ *
+ * Usage:
+ *   node build-brief.js path/to/scout-gaps.json > brief.html
+ *   node build-brief.js            # uses sample-gaps.json
+ */
+const fs = require('fs');
+const path = require('path');
+
+const esc = (s) =>
+  String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+
+function loadGaps(argPath) {
+  const p = argPath || path.join(__dirname, 'sample-gaps.json');
+  const gaps = JSON.parse(fs.readFileSync(p, 'utf8'));
+  // rank: marketplace demand (evidence count / rank) x DW gap size
+  return gaps
+    .map((g) => ({ ...g, _score: (g.demand_score ?? 5) * (g.dw_gap_score ?? 5) }))
+    .sort((a, b) => b._score - a._score);
+}
+
+function weekLabel() {
+  // deterministic: caller passes WEEK env when scheduled; else 'this week'
+  return process.env.WEEK || 'this week';
+}
+
+function render(gaps) {
+  const rows = gaps
+    .slice(0, Number(process.env.TOP || 5))
+    .map(
+      (g, i) => `
+      <tr>
+        <td class="rank">${i + 1}</td>
+        <td>
+          <div class="trend">${esc(g.trend)}</div>
+          <div class="meta">${esc(g.style || '')} ${g.palette ? '· ' + esc(g.palette) : ''}</div>
+        </td>
+        <td>${esc(g.marketplace_evidence)}</td>
+        <td>${esc(g.dw_gap)}</td>
+        <td class="brief">${esc(g.design_brief)}
+          ${g.settlement_note ? `<div class="warn">⚠ ${esc(g.settlement_note)}</div>` : ''}
+        </td>
+      </tr>`
+    )
+    .join('');
+
+  return `<!doctype html><html><head><meta charset="utf-8">
+<title>DW Sourcing Brief — ${esc(weekLabel())}</title>
+<style>
+  body{font:15px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif;color:#1a1a1a;max-width:920px;margin:32px auto;padding:0 20px}
+  h1{font-weight:600;letter-spacing:.3px;margin-bottom:2px}
+  .sub{color:#777;margin-bottom:24px}
+  table{border-collapse:collapse;width:100%}
+  th,td{border-bottom:1px solid #e6e6e6;padding:12px 10px;vertical-align:top;text-align:left}
+  th{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:#999;font-weight:600}
+  .rank{font-size:22px;color:#b7913f;font-weight:700;width:34px}
+  .trend{font-weight:600}
+  .meta{color:#888;font-size:13px}
+  .brief{font-size:14px}
+  .warn{color:#b00;font-size:12px;margin-top:6px}
+  footer{margin-top:28px;color:#aaa;font-size:12px}
+</style></head><body>
+  <h1>Designer Wallcoverings — Weekly Sourcing Brief</h1>
+  <div class="sub">Top trend-gaps vs Spoonflower / Etsy best-sellers · ${esc(weekLabel())} · generated from design-trend-scout</div>
+  <table>
+    <thead><tr><th>#</th><th>Trend</th><th>Marketplace evidence</th><th>DW gap</th><th>Suggested original design brief</th></tr></thead>
+    <tbody>${rows}</tbody>
+  </table>
+  <footer>Every suggested brief must clear the settlement gate before generation. Company/vendor names are text-only. Original designs only — the scout identifies demand lanes, never copies a listing.</footer>
+</body></html>`;
+}
+
+if (require.main === module) {
+  process.stdout.write(render(loadGaps(process.argv[2])));
+}
+module.exports = { render, loadGaps };
diff --git a/sourcing-brief/sample-gaps.json b/sourcing-brief/sample-gaps.json
new file mode 100644
index 0000000..e5e2297
--- /dev/null
+++ b/sourcing-brief/sample-gaps.json
@@ -0,0 +1,35 @@
+[
+  {
+    "trend": "Moody botanical / dark cottagecore",
+    "style": "Maximalist floral",
+    "palette": "Ink green + oxblood",
+    "demand_score": 9,
+    "dw_gap_score": 8,
+    "marketplace_evidence": "Spoonflower 'dark floral' top-20 for 6 wks; Etsy 'moody botanical wallpaper' ~2.4k sales/mo across top listings",
+    "dw_gap": "DW catalog skews light/traditional floral; <15 active SKUs in the dark-ground floral lane",
+    "design_brief": "Dense original botanical on near-black ground, 2-color leaf layer with open space between motifs, oxblood accents",
+    "settlement_note": "Directional-leaf + open-space + multi-color triggers Part A — keep leaves non-directional OR remove open space; no birds/butterflies"
+  },
+  {
+    "trend": "Checkerboard / warped grid",
+    "style": "Modern geometric",
+    "palette": "Butter + cream",
+    "demand_score": 8,
+    "dw_gap_score": 9,
+    "marketplace_evidence": "Etsy 'checkerboard wallpaper' sustained best-seller; Society6 warped-checker prints trending",
+    "dw_gap": "Almost no geometric checker in DW active grid; competitors (Chasing Paper) lead this lane",
+    "design_brief": "Hand-wobbled checkerboard, warm butter/cream, subtle painterly edge for a non-flat look",
+    "settlement_note": ""
+  },
+  {
+    "trend": "70s retro arch / sunburst",
+    "style": "Mid-century revival",
+    "palette": "Terracotta + mustard",
+    "demand_score": 7,
+    "dw_gap_score": 7,
+    "marketplace_evidence": "Spoonflower 'retro arch' + 'sunburst' both in wallpaper top-50",
+    "dw_gap": "DW has scattered MCM but no cohesive retro-arch collection",
+    "design_brief": "Repeating rounded-arch/sunburst geometric, terracotta+mustard on cream, clean vector edges",
+    "settlement_note": ""
+  }
+]

(oldest)  ·  back to Council 0809 Builds  ·  Council #5 (TK-10385): Path A+B build — width-only deeplink 37767de →