← back to Sanderson Onboard
SDG go-live pipeline: enrich($0 ollama)->dw_sku->settlement gate->6-field gate->create+golive(both variants,qty=2026,sample$4.25); cadence + rollback + healthcheck (TK-10881)
7ff6f000838cc4026e8eb57e2257a4888dcf2502 · 2026-08-26 10:42:48 -0700 · Steve Abrams
Files touched
A com.steve.sdg-publish-cadence.plistA scripts/assemble_queue.mjsA scripts/build_manifest.mjsA scripts/cadence_healthcheck.mjsA scripts/cadence_resume.shA scripts/create_sdg.mjsA scripts/rollback_sdg.mjsA scripts/settlement_gate.mjs
Diff
commit 7ff6f000838cc4026e8eb57e2257a4888dcf2502
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 26 10:42:48 2026 -0700
SDG go-live pipeline: enrich($0 ollama)->dw_sku->settlement gate->6-field gate->create+golive(both variants,qty=2026,sample$4.25); cadence + rollback + healthcheck (TK-10881)
---
com.steve.sdg-publish-cadence.plist | 29 +++++++
scripts/assemble_queue.mjs | 25 ++++++
scripts/build_manifest.mjs | 132 ++++++++++++++++++++++++++++++++
scripts/cadence_healthcheck.mjs | 16 ++++
scripts/cadence_resume.sh | 30 ++++++++
scripts/create_sdg.mjs | 149 ++++++++++++++++++++++++++++++++++++
scripts/rollback_sdg.mjs | 30 ++++++++
scripts/settlement_gate.mjs | 80 +++++++++++++++++++
8 files changed, 491 insertions(+)
diff --git a/com.steve.sdg-publish-cadence.plist b/com.steve.sdg-publish-cadence.plist
new file mode 100644
index 0000000..5d5bb68
--- /dev/null
+++ b/com.steve.sdg-publish-cadence.plist
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.sdg-publish-cadence</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/bin/zsh</string>
+ <string>/Users/macstudio3/Projects/sanderson-onboard/scripts/cadence_resume.sh</string>
+ </array>
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>DAILY</key><string>200</string>
+ <key>THRESHOLD</key><string>0.85</string>
+ <key>OLLAMA_URL</key><string>http://192.168.1.133:11434</string>
+ <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+ </dict>
+ <key>StartCalendarInterval</key>
+ <dict>
+ <key>Hour</key><integer>4</integer>
+ <key>Minute</key><integer>30</integer>
+ </dict>
+ <key>StandardOutPath</key>
+ <string>/Users/macstudio3/Projects/sanderson-onboard/pilot/cadence.launchd.out.log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/macstudio3/Projects/sanderson-onboard/pilot/cadence.launchd.err.log</string>
+</dict>
+</plist>
diff --git a/scripts/assemble_queue.mjs b/scripts/assemble_queue.mjs
new file mode 100644
index 0000000..8e96d6e
--- /dev/null
+++ b/scripts/assemble_queue.mjs
@@ -0,0 +1,25 @@
+// assemble_queue.mjs — merge the 4 settlement-clean manifests into a round-robin, most-ready-first
+// queue capped at --daily. Skips dw_skus already CREATED (create-audit.jsonl). Writes pilot/queue-today.json.
+import fs from 'node:fs';
+const DAILY = parseInt((process.argv.find(a => a.startsWith('--daily=')) || '').split('=')[1] || '200', 10);
+const CAP = Math.min(DAILY, 500);
+const AUDIT = new URL('../pilot/create-audit.jsonl', import.meta.url).pathname;
+const done = new Set();
+if (fs.existsSync(AUDIT)) for (const l of fs.readFileSync(AUDIT, 'utf8').trim().split('\n').filter(Boolean)) {
+ try { const r = JSON.parse(l); if (r.action === 'CREATED') done.add(r.dw); } catch {}
+}
+const lists = {};
+for (const b of ['sanderson', 'harlequin', 'zoffany', 'morris']) {
+ const p = new URL(`../pilot/manifest-${b}.json`, import.meta.url).pathname;
+ lists[b] = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')).filter(it => !done.has(it.dw_sku)) : [];
+}
+const out = [];
+const order = ['sanderson', 'harlequin', 'zoffany', 'morris'];
+let i = 0;
+while (out.length < CAP && order.some(b => lists[b].length)) {
+ const b = order[i % order.length]; i++;
+ if (lists[b].length) out.push(lists[b].shift());
+}
+fs.writeFileSync(new URL('../pilot/queue-today.json', import.meta.url).pathname, JSON.stringify(out, null, 2));
+const byBrand = out.reduce((a, x) => { a[x.vendor] = (a[x.vendor] || 0) + 1; return a; }, {});
+console.log(`queue-today: ${out.length} items ${JSON.stringify(byBrand)}`);
diff --git a/scripts/build_manifest.mjs b/scripts/build_manifest.mjs
new file mode 100644
index 0000000..4c277b4
--- /dev/null
+++ b/scripts/build_manifest.mjs
@@ -0,0 +1,132 @@
+// build_manifest.mjs — SDG go-live: enrich → assign dw_sku → 5-field/6-gate → settlement-scope tag → emit manifest.
+// $0: descriptions/tags via LOCAL Ollama (mac1 qwen2.5:7b). NEVER fabricates price/color/dimension.
+// Reads the 4 *_feed_pricing tables; writes pilot/manifest-<brand>.json + a HELD report.
+// Idempotent dw_sku assignment: numbering continues from the max already on Shopify per prefix.
+//
+// Gate fields (must all pass to be BUILD-eligible; else HELD with reason):
+// 1 sellable variant (implicit) 2 sample variant (implicit) 3 complete price (retail_usd>0)
+// 4 description (enriched) 5 >=2 tags 6 featured image (feed image -> absolute URL)
+// color falls back to pattern label when blank (Morris); width/length omitted-not-fabricated when absent.
+import fs from 'node:fs';
+import { execSync } from 'node:child_process';
+
+const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
+const MODEL = 'qwen2.5:7b';
+const IMG_BASE = 'https://www.sanderson.design/static/media/catalog';
+const PILOT = new URL('../pilot/', import.meta.url).pathname;
+
+// brand -> { table, vendor (customer-facing), prefix (registered), startNum }
+const BRANDS = {
+ sanderson: { table: 'sanderson_feed_pricing', vendor: 'Sanderson', prefix: 'DWXH', start: 600000 },
+ harlequin: { table: 'harlequin_feed_pricing', vendor: 'Harlequin', prefix: 'DWHF', start: 70608 },
+ zoffany: { table: 'zoffany_feed_pricing', vendor: 'Zoffany', prefix: 'DWZF', start: 600000 },
+ morris: { table: 'morris_feed_pricing', vendor: 'Morris & Co.', prefix: 'DWWM', start: 600000 },
+};
+
+function psql(sql) {
+ return execSync(`psql -h /tmp -d dw_unified -tA -F $'\\t' -c "${sql.replace(/"/g, '\\"')}"`, { encoding: 'utf8', maxBuffer: 1 << 28 });
+}
+
+// absolute image URL from feed relative path (or empty)
+const absImg = (rel) => {
+ const s = String(rel || '').trim();
+ if (!s) return '';
+ if (/^https?:\/\//i.test(s)) return s;
+ return IMG_BASE + (s.startsWith('/') ? s : '/' + s);
+};
+
+// derive tags: product_type, collection, brand, pattern (all real feed values — never AI)
+function deriveTags(r, vendor) {
+ const t = new Set();
+ if (r.product_type) t.add(r.product_type === 'fabric' ? 'Fabric' : 'Wallcovering');
+ t.add(vendor);
+ if (r.collection) t.add(r.collection.slice(0, 60));
+ if (r.pattern) t.add(r.pattern.split('/')[0].trim().slice(0, 40));
+ if (r.color) t.add(r.color.split('/')[0].trim().slice(0, 30));
+ return [...t].filter(Boolean);
+}
+
+async function ollamaDesc(r, vendor, timeoutMs = 20000) {
+ const kind = r.product_type === 'fabric' ? 'fabric' : 'wallcovering';
+ const unit = kind === 'fabric' ? 'yard' : 'roll';
+ const prompt = `Write ONE concise, elegant 2-sentence product description for a luxury ${kind} for an interior-design storefront. Pattern: "${r.pattern}". Colorway: "${r.color || r.pattern}". Brand: ${vendor}. Collection: "${r.collection || ''}". Sold per ${unit}. RULES: never use the word "wallpaper" (say "wallcovering"); do not invent dimensions, prices, or materials not given; no markdown, no lists, plain prose only. Output only the description text.`;
+ try {
+ const ctrl = new AbortController();
+ const to = setTimeout(() => ctrl.abort(), timeoutMs);
+ const res = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST', signal: ctrl.signal,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: MODEL, prompt, stream: false, options: { temperature: 0.6, num_predict: 120 } }),
+ });
+ clearTimeout(to);
+ const j = await res.json();
+ let d = String(j.response || '').trim().replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+ d = d.replace(/\bwallpapers?\b/gi, (m) => (m[0] === m[0].toUpperCase() ? 'Wallcovering' : 'wallcovering'));
+ return d.split('\n').filter(Boolean).join(' ').slice(0, 600);
+ } catch { return ''; }
+}
+
+async function main() {
+ const only = (process.argv.find(a => a.startsWith('--brand=')) || '').split('=')[1];
+ const limit = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+ const brands = only ? { [only]: BRANDS[only] } : BRANDS;
+ fs.mkdirSync(PILOT, { recursive: true });
+
+ for (const [bk, b] of Object.entries(brands)) {
+ const cols = 'base_code,mfr_sku,product_type,pattern,color,collection,width,length,ssp_usd,trade_usd,retail_usd,image';
+ const rows = psql(`SELECT ${cols} FROM ${b.table} ORDER BY base_code`).trim().split('\n').filter(Boolean).map(line => {
+ const [base_code, mfr_sku, product_type, pattern, color, collection, width, length, ssp_usd, trade_usd, retail_usd, image] = line.split('\t');
+ return { base_code, mfr_sku, product_type, pattern, color, collection, width, length, ssp_usd, trade_usd, retail_usd, image };
+ });
+
+ // existing shopify mfr_skus for this vendor -> dedup HOLD
+ const existing = new Set(
+ psql(`SELECT upper(mfr_sku) FROM shopify_products WHERE mfr_sku IS NOT NULL AND vendor ILIKE '%${b.vendor.replace(/[^a-z ]/gi, '')}%'`)
+ .trim().split('\n').filter(Boolean)
+ );
+
+ const manifest = [];
+ const held = [];
+ let num = b.start;
+ const slice = limit > 0 ? rows.slice(0, limit) : rows;
+
+ for (const r of slice) {
+ const color = (r.color && r.color.trim()) ? r.color.trim() : (r.pattern ? r.pattern.trim() : '');
+ const img = absImg(r.image);
+ const price = parseFloat(r.retail_usd);
+ const reasons = [];
+ // GATE 3: price
+ if (!(price > 0)) reasons.push('5field:no-price');
+ // GATE 6: image
+ if (!img) reasons.push('image-missing');
+ // color fallback -> if STILL empty, cannot title -> HOLD (never "Unknown")
+ if (!r.pattern && !color) reasons.push('5field:no-title-source');
+ // dedup: already on shopify by mfr_sku
+ if (r.mfr_sku && existing.has(String(r.mfr_sku).toUpperCase())) reasons.push('dedup:already-on-shopify');
+
+ if (reasons.length) { held.push({ base_code: r.base_code, mfr_sku: r.mfr_sku, reasons, img: !!img, price: price || null }); continue; }
+
+ const dw_sku = `${b.prefix}-${num++}`;
+ const tags = deriveTags({ ...r, color }, b.vendor);
+ if (tags.length < 2) tags.push(b.vendor + (r.product_type === 'fabric' ? ' Fabric' : ' Wallcovering'));
+ const description = await ollamaDesc({ ...r, color }, b.vendor);
+ // GATE 4: description — if ollama failed, deterministic non-AI fallback (still a real description)
+ const desc = description || `${r.pattern}${color ? ' in ' + color : ''} from the ${r.collection || b.vendor} collection by ${b.vendor}, sold per ${r.product_type === 'fabric' ? 'yard' : 'roll'}.`;
+
+ manifest.push({
+ dw_sku, mfr_sku: r.mfr_sku, base_code: r.base_code,
+ vendor: b.vendor, product_type: r.product_type === 'fabric' ? 'Fabric' : 'Wallcovering',
+ pattern: r.pattern, color, collection: r.collection || '',
+ width: r.width || '', length: r.length || '',
+ retail_usd: price.toFixed(2), sample_price: '4.25',
+ image: img, description: desc, tags,
+ unit: r.product_type === 'fabric' ? 'Yard' : 'Roll',
+ });
+ }
+ fs.writeFileSync(`${PILOT}manifest-${bk}.json`, JSON.stringify(manifest, null, 2));
+ fs.writeFileSync(`${PILOT}held-${bk}.json`, JSON.stringify(held, null, 2));
+ const holdBy = held.reduce((a, h) => { const k = h.reasons[0]; a[k] = (a[k] || 0) + 1; return a; }, {});
+ console.log(`${bk}: BUILD-eligible=${manifest.length} HELD=${held.length} ${JSON.stringify(holdBy)}`);
+ }
+}
+main();
diff --git a/scripts/cadence_healthcheck.mjs b/scripts/cadence_healthcheck.mjs
new file mode 100644
index 0000000..d346fab
--- /dev/null
+++ b/scripts/cadence_healthcheck.mjs
@@ -0,0 +1,16 @@
+// cadence_healthcheck.mjs — compute the last run's compliance pass-rate from create-audit.jsonl.
+// Pass-rate = CREATED / (CREATED + HELD + ERR_CREATE) over the most recent run window (since last CAP/DONE marker).
+// Exit 0 if >= threshold, exit 1 (halt) otherwise. Reads only.
+import fs from 'node:fs';
+const THRESHOLD = parseFloat((process.argv.find(a => a.startsWith('--threshold=')) || '').split('=')[1] || '0.85');
+const AUDIT = new URL('../pilot/create-audit.jsonl', import.meta.url).pathname;
+if (!fs.existsSync(AUDIT)) { console.log('no audit yet — pass'); process.exit(0); }
+const lines = fs.readFileSync(AUDIT, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+// window = last ~250 events (a day's worth); good enough for the trend
+const win = lines.slice(-300);
+let created = 0, held = 0, err = 0;
+for (const r of win) { if (r.action === 'CREATED') created++; else if (r.action === 'HELD') held++; else if (r.action === 'ERR_CREATE') err++; }
+const denom = created + held + err;
+const rate = denom ? created / denom : 1;
+console.log(`healthcheck: created=${created} held=${held} err=${err} rate=${rate.toFixed(3)} threshold=${THRESHOLD}`);
+process.exit(rate >= THRESHOLD ? 0 : 1);
diff --git a/scripts/cadence_resume.sh b/scripts/cadence_resume.sh
new file mode 100644
index 0000000..09a59be
--- /dev/null
+++ b/scripts/cadence_resume.sh
@@ -0,0 +1,30 @@
+#!/bin/zsh
+# cadence_resume.sh — daily ~200/day SDG publish resume, hard cap <=500/run, same per-item gates.
+# Modeled on com.steve.hollywood-create-resume. Auto-halts if a batch's compliance pass-rate drops
+# below THRESHOLD. Each item: settlement-gate + 6-field gate (enforced inside create_sdg.mjs).
+set -e
+cd "$(dirname "$0")/.."
+LOG=pilot/cadence.log
+DAILY=${DAILY:-200}
+THRESHOLD=${THRESHOLD:-0.85} # min (created)/(created+held+err) to keep going next day
+echo "=== $(date -u +%FT%TZ) SDG cadence run (daily=$DAILY) ===" >> $LOG
+
+# 1) Ensure per-brand full manifests exist + are enriched (idempotent; skips already-built rows via audit)
+for b in sanderson harlequin zoffany morris; do
+ node scripts/build_manifest.mjs --brand=$b >> $LOG 2>&1 || true
+ node scripts/settlement_gate.mjs --brand=$b >> $LOG 2>&1 || true
+done
+
+# 2) Assemble a combined, round-robin, most-ready-first queue capped at DAILY
+node scripts/assemble_queue.mjs --daily=$DAILY >> $LOG 2>&1
+
+# 3) Create+go-live the day's queue (idempotent resume via create-audit.jsonl; caps at 500 hard)
+node scripts/create_sdg.mjs --manifest=pilot/queue-today.json --apply --limit=$DAILY >> $LOG 2>&1
+RC=$?
+
+# 4) Compliance halt-check: compute pass-rate from the audit tail; halt (unload) if below threshold
+node scripts/cadence_healthcheck.mjs --threshold=$THRESHOLD >> $LOG 2>&1 || {
+ echo "!! compliance below $THRESHOLD — unloading cadence to halt" >> $LOG
+ launchctl unload ~/Library/LaunchAgents/com.steve.sdg-publish-cadence.plist 2>/dev/null || true
+}
+echo "=== run complete rc=$RC ===" >> $LOG
diff --git a/scripts/create_sdg.mjs b/scripts/create_sdg.mjs
new file mode 100644
index 0000000..f54c370
--- /dev/null
+++ b/scripts/create_sdg.mjs
@@ -0,0 +1,149 @@
+// create_sdg.mjs — create genuinely-absent SDG products on the LIVE DW store, both variants in ONE POST
+// (no productVariantsBulkCreate -> sample-overwrite bug cannot fire), then go-live via artmura-proven GraphQL
+// (track -> activate -> set 2026 -> publish 13 channels -> ACTIVE). Adapted from the proven hollywood-create.mjs.
+// DRY-RUN by default; --apply --limit=N to write. Reads pilot/manifest-<brand>.json (settlement-clean).
+// Per-item FINAL 6-gate re-check before activation: sample+sellable+price+desc+>=2tags+image (else SKIP-HELD).
+// Records restore-map for reversibility; logs each activation to the executed-reversible ledger.
+import fs from 'node:fs';
+
+const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
+const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
+const REST = `https://${STORE}/admin/api/2024-10`;
+const GQL = `${REST}/graphql.json`;
+const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const MANIFEST = (process.argv.find(a => a.startsWith('--manifest=')) || '').split('=')[1];
+const AUDIT = new URL('../pilot/create-audit.jsonl', import.meta.url).pathname;
+const RESTORE = new URL('../pilot/restore-map.jsonl', import.meta.url).pathname;
+const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
+
+const LOCATION_ID = 'gid://shopify/Location/5795643504';
+const TARGET_QTY = 2026;
+const PUBLICATIONS = [22208643184,22497296496,29646651457,29739483201,29776969793,37904089153,
+ 43657658419,44234276915,44317474867,44317507635,71898464307,115856375859,140027723827].map(n => `gid://shopify/Publication/${n}`);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const titleCase = s => String(s || '').replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase());
+
+// 6-gate: sample+sellable are structural; check price/desc/tags/image + title-source
+function gate(it) {
+ const r = [];
+ if (!(parseFloat(it.retail_usd) > 0)) r.push('no-price');
+ if (!it.image) r.push('no-image');
+ if (!it.description || it.description.length < 10) r.push('no-description');
+ if (!it.tags || it.tags.length < 2) r.push('lt2-tags');
+ if (!it.pattern && !it.color) r.push('no-title-source');
+ if (it.settlement && it.settlement.verdict === 'BLOCK') r.push('settlement-block');
+ return r;
+}
+
+function payload(it) {
+ // Title format: "Pattern Name Real Color Name | Brand Name", Title Case, never "Unknown"/"Wallpaper".
+ const colorPart = it.color && it.color.trim() && it.color.trim() !== it.pattern.trim() ? ` ${it.color.trim()}` : '';
+ let title = titleCase(`${it.pattern}${colorPart}`).replace(/\bwallpapers?\b/gi, 'Wallcovering');
+ title = `${title} | ${it.vendor}`;
+ const tags = [...new Set(it.tags.map(t => String(t).trim()).filter(Boolean))];
+ const dw = it.dw_sku, unit = it.unit;
+ const row = (l, v) => (v ? `<tr><td>${l}</td><td>${v}</td></tr>` : '');
+ const body = `<p>${String(it.description).replace(/\bwallpapers?\b/gi, 'wallcovering')}</p>` +
+ `<table>` + row('Pattern', it.pattern) + row('Color', it.color) + row('Collection', it.collection) +
+ row('Width', it.width) + row('Length', it.length) + `<tr><td>Sold Per</td><td>${unit}</td></tr></table>`;
+ const sl = 'single_line_text_field', ml = 'multi_line_text_field';
+ const metafields = [];
+ const mf = (ns, k, v, type = sl) => { let x = String(v || '').trim(); if (type === sl) x = x.replace(/\s+/g, ' '); if (x) metafields.push({ namespace: ns, key: k, type, value: x }); };
+ if (it.width) mf('global', 'width', it.width);
+ mf('global', 'unit_of_measure', `Sold Per ${unit}`);
+ mf('custom', 'pattern_name', it.pattern);
+ mf('custom', 'supplier_name', it.vendor);
+ mf('custom', 'manufacturer_sku', it.mfr_sku);
+ mf('dwc', 'manufacturer_sku', it.mfr_sku);
+ mf('dwc', 'dw_sku', it.dw_sku);
+ mf('global', 'dw_sku', it.dw_sku);
+ return { product: {
+ title, vendor: it.vendor, product_type: it.product_type, status: 'draft',
+ tags: tags.join(', '), body_html: body, metafields, images: [{ src: it.image }],
+ options: [{ name: 'Size' }],
+ variants: [
+ { sku: dw, price: String(parseFloat(it.retail_usd).toFixed(2)), option1: `Sold Per ${unit}`, taxable: true, requires_shipping: true },
+ { sku: `${dw}-Sample`, price: '4.25', option1: 'Sample', taxable: true, requires_shipping: true },
+ ],
+ } };
+}
+
+async function gql(query, variables) {
+ for (let a = 0; a < 4; a++) {
+ const r = await fetch(GQL, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
+ const j = await r.json();
+ if (j.errors) { if (a === 3) throw new Error(JSON.stringify(j.errors)); await sleep(900 * (a + 1)); continue; }
+ return j.data;
+ }
+}
+const Q_V = `query($id:ID!){product(id:$id){status variants(first:10){edges{node{id sku inventoryItem{id}}}}}}`;
+const M_TRACK = `mutation($id:ID!){inventoryItemUpdate(id:$id,input:{tracked:true}){userErrors{message}}}`;
+const M_ACT = `mutation($iid:ID!,$loc:ID!){inventoryActivate(inventoryItemId:$iid,locationId:$loc){userErrors{message}}}`;
+const M_QTY = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{message}}}`;
+const M_PUB = `mutation($id:ID!,$pubs:[PublicationInput!]!){publishablePublish(id:$id,input:$pubs){userErrors{message}}}`;
+const M_ACTIVE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{status}userErrors{message}}}`;
+
+async function goLive(pid) {
+ const gid = `gid://shopify/Product/${pid}`;
+ const d = await gql(Q_V, { id: gid });
+ const items = d.product.variants.edges.map(e => e.node.inventoryItem.id);
+ for (const iid of items) { await gql(M_TRACK, { id: iid }); await gql(M_ACT, { iid, loc: LOCATION_ID }); }
+ await gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: items.map(iid => ({ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY })) } });
+ await gql(M_PUB, { id: gid, pubs: PUBLICATIONS.map(p => ({ publicationId: p })) });
+ const r = await gql(M_ACTIVE, { id: gid });
+ return { status: r.productUpdate?.product?.status, varCount: items.length };
+}
+
+async function main() {
+ if (!MANIFEST) { console.error('need --manifest=path'); process.exit(1); }
+ const items = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
+ const createdSet = new Set();
+ if (fs.existsSync(AUDIT)) for (const l of fs.readFileSync(AUDIT, 'utf8').trim().split('\n').filter(Boolean)) {
+ try { const r = JSON.parse(l); if (r.action === 'CREATED') createdSet.add(r.dw); } catch {}
+ }
+ const remaining = items.filter(it => !createdSet.has(it.dw_sku));
+ const todo = LIMIT > 0 ? remaining.slice(0, LIMIT) : remaining;
+ if (createdSet.size) console.log(`resume: ${createdSet.size} created, ${remaining.length} remain`);
+ const out = APPLY ? fs.createWriteStream(AUDIT, { flags: 'a' }) : null;
+ const restoreOut = APPLY ? fs.createWriteStream(RESTORE, { flags: 'a' }) : null;
+ const ledgerOut = APPLY ? fs.createWriteStream(LEDGER, { flags: 'a' }) : null;
+ console.log(`create_sdg: ${todo.length} items · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+ let created = 0, err = 0, held = 0;
+ for (const it of todo) {
+ const g = gate(it);
+ if (g.length) { console.error(` SKIP-HELD ${it.dw_sku}: ${g.join(',')}`); held++; if (out) out.write(JSON.stringify({ dw: it.dw_sku, action: 'HELD', reasons: g }) + '\n'); continue; }
+ const pl = payload(it);
+ if (!APPLY) {
+ console.log(`\n${it.dw_sku} "${pl.product.title}"`);
+ console.log(` variants: [${pl.product.variants.map(v => v.option1 + ' ' + v.sku + ' $' + v.price).join(' | ')}] img:${pl.product.images[0]?.src ? 'yes' : 'NONE'} tags:${pl.product.tags.split(',').length}`);
+ continue;
+ }
+ try {
+ const r = await fetch(`${REST}/products.json`, { method: 'POST', headers: H, body: JSON.stringify(pl) });
+ const txt = await r.text();
+ if (r.status === 429 && /daily variant creation limit/i.test(txt)) {
+ console.error(` ⛔ daily variant cap at ${created} — stopping (resume next run).`);
+ out.write(JSON.stringify({ action: 'CAP_ABORT', created }) + '\n'); out.end();
+ process.exit(3);
+ }
+ let j; try { j = JSON.parse(txt); } catch { j = {}; }
+ if (!r.ok || !j.product) { console.error(` ERR ${it.dw_sku}: ${r.status} ${txt.slice(0,120)}`); out.write(JSON.stringify({ dw: it.dw_sku, action: 'ERR_CREATE', status: r.status }) + '\n'); err++; await sleep(700); continue; }
+ const pid = j.product.id;
+ const variants = j.product.variants.map(v => ({ sku: v.sku, price: v.price, opt: v.option1, iid: v.inventory_item_id }));
+ const sampleOk = variants.some(v => /sample/i.test(v.sku) && v.price === '4.25');
+ const gl = await goLive(pid);
+ out.write(JSON.stringify({ dw: it.dw_sku, mfr: it.mfr_sku, vendor: it.vendor, pid, action: 'CREATED', status: gl.status, variants, sampleOk, varCount: gl.varCount }) + '\n');
+ restoreOut.write(JSON.stringify({ dw: it.dw_sku, pid, variant_ids: variants.map(v => v.sku), vendor: it.vendor, created_at: new Date().toISOString() }) + '\n');
+ ledgerOut.write(JSON.stringify({ ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10881', action: `activate SDG product ${it.dw_sku} (${it.vendor})`, blast_radius: 1, undo_cmd: `node scripts/rollback_sdg.mjs --pid=${pid}`, verify: `product ${pid} status=${gl.status} sampleOk=${sampleOk}` }) + '\n');
+ console.log(` ✓ ${it.dw_sku} → ${pid} ${gl.status} vars=${variants.length} sampleOk=${sampleOk}`);
+ created++; await sleep(500);
+ } catch (e) { console.error(` ERR ${it.dw_sku}: ${e.message.slice(0,120)}`); err++; await sleep(700); }
+ }
+ if (out) { out.end(); restoreOut.end(); ledgerOut.end(); }
+ console.log(`\nDONE: created=${created} held=${held} err=${err} of ${todo.length}` + (APPLY ? ` · audit ${AUDIT}` : ' · DRY-RUN'));
+}
+main();
diff --git a/scripts/rollback_sdg.mjs b/scripts/rollback_sdg.mjs
new file mode 100644
index 0000000..6806ec2
--- /dev/null
+++ b/scripts/rollback_sdg.mjs
@@ -0,0 +1,30 @@
+// rollback_sdg.mjs — reverse an SDG activation. Default: unpublish + set DRAFT (soft, reversible).
+// --delete: hard delete the product. --pid=N single, or --all reads pilot/restore-map.jsonl.
+import fs from 'node:fs';
+const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
+const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
+const REST = `https://${STORE}/admin/api/2024-10`;
+const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
+const DELETE = process.argv.includes('--delete');
+const ALL = process.argv.includes('--all');
+const PID = (process.argv.find(a => a.startsWith('--pid=')) || '').split('=')[1];
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function one(pid) {
+ if (DELETE) {
+ const r = await fetch(`${REST}/products/${pid}.json`, { method: 'DELETE', headers: H });
+ console.log(` ${pid} DELETE ${r.status}`); return;
+ }
+ const r = await fetch(`${REST}/products/${pid}.json`, { method: 'PUT', headers: H, body: JSON.stringify({ product: { id: pid, status: 'draft', published: false } }) });
+ console.log(` ${pid} -> draft/unpublished ${r.status}`);
+}
+async function main() {
+ let pids = [];
+ if (PID) pids = [PID];
+ else if (ALL) pids = fs.readFileSync(new URL('../pilot/restore-map.jsonl', import.meta.url).pathname, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l).pid);
+ else { console.error('need --pid=N or --all'); process.exit(1); }
+ console.log(`rollback ${pids.length} products (${DELETE ? 'DELETE' : 'draft+unpublish'})`);
+ for (const p of pids) { await one(p); await sleep(300); }
+}
+main();
diff --git a/scripts/settlement_gate.mjs b/scripts/settlement_gate.mjs
new file mode 100644
index 0000000..9df9bbb
--- /dev/null
+++ b/scripts/settlement_gate.mjs
@@ -0,0 +1,80 @@
+// settlement_gate.mjs — applies the DW Settlement combined gate over a manifest, text-side, per design.
+// Binding rule (SETTLEMENT-BINDING-TEXT.md): a design is PROHIBITED iff BOTH
+// Part A (directional foliage/leaves/fronds) AND Part B (bananas|grapes|birds|butterflies) are present.
+// We have pattern name + colorway + collection (not a generated image), so this is a NAME-SIGNAL screen.
+// Defendant-favorable: borderline -> BLOCK. A Part-B name signal on a foliage/botanical/tropical name = BLOCK.
+// A Part-B signal alone on a clearly non-foliage substrate (geometric/stripe/plain/trellis) = allow.
+// SDG-specific known blocks: Morris "Strawberry Thief" (birds), tropical/bird motif lines.
+// Output: annotates each manifest item with settlement:{verdict, reason}; writes back + a blocked report.
+import fs from 'node:fs';
+
+// Part B — prohibited specific elements (any one)
+const PART_B = [
+ /\bbanana(s|\s*pods?)?\b/i, /\bgrapes?\b/i,
+ /\bbird(s|life)?\b/i, /\bstrawberry\s*thief\b/i, // Strawberry Thief = the canonical Morris bird print
+ /\bbutterfl(y|ies)\b/i,
+ // common bird species names that appear in SDG/Morris/Harlequin pattern names
+ /\b(finch|swallow|swift|heron|crane|peacock|pheasant|dove|owl|kingfisher|wren|robin|nightingale|hummingbird|cockatoo|parrot|toucan|flamingo|sparrow|magpie|starling|thrush|lovebird)\b/i,
+];
+// Part A — directional foliage / open-botanical signals (leaves, palm fronds, similar foliage)
+const PART_A = [
+ /\b(leaf|leaves|foliage|frond|palm|jungle|tropical|botanic(al)?|forest|vine|fern|bough|willow|acanthus|arboreal|meadow|garden|orchard|blossom|floral|flower)\b/i,
+];
+// Clearly NON-foliage substrate -> Part A NOT satisfied -> allow even with a Part B token
+const NON_FOLIAGE = [
+ /\b(stripe|check|plaid|geometr|trellis|lattice|cane|chevron|herringbone|dot|spot|plain|texture|weave|moir|damask|paisley|ikat|houndstooth|grid|diamond|key|fret)\b/i,
+];
+// Acceptable-element carve-out (tree trunks / branches / non-prohibited fruit-animal) -> permitted
+const ACCEPTABLE = [/\b(tree|trunk|branch|bough)\b/i]; // conservative: only clear trunk/branch words
+
+// "Dove" (dove-grey), "Robin"(robin's-egg), "Wren", "Crane"(colorway) are COMMON PAINT-COLOR names.
+// In the COLOR field alone they are NOT a bird-motif signal — only a bird token in the PATTERN name,
+// or a bird token anywhere combined with a foliage pattern, is a real motif signal. This kills the
+// "Koto Dove/Moonstone" / "Lotus Dove/Moonstone" false positives while keeping "Squirrel and Dove Embroidery".
+const BIRD_RE = /\b(bird|strawberry\s*thief|butterfl(y|ies)|finch|swallow|swift|heron|peacock|pheasant|owl|kingfisher|nightingale|hummingbird|cockatoo|parrot|toucan|flamingo|magpie|starling|thrush|lovebird)\b/i;
+// ambiguous words that are also paint colors — only count as bird when in PATTERN, not COLOR
+const AMBIG_BIRD_RE = /\b(dove|robin|wren|crane|sparrow)\b/i;
+const OTHER_B_RE = /\b(banana(s|\s*pods?)?|grapes?)\b/i;
+
+function classify(pattern, color, collection) {
+ const patt = String(pattern || '');
+ const all = `${pattern || ''} ${color || ''} ${collection || ''}`;
+ const a = PART_A.some(re => re.test(all));
+ const nonFoliage = NON_FOLIAGE.some(re => re.test(all));
+ const acceptable = ACCEPTABLE.some(re => re.test(all));
+ // bird/butterfly motif present iff: an unambiguous bird word ANYWHERE, OR an ambiguous
+ // paint-color-ish bird word only when it's in the PATTERN (motif), OR ambiguous word + foliage.
+ const birdUnambig = BIRD_RE.test(all);
+ const birdInPattern = AMBIG_BIRD_RE.test(patt);
+ const birdWithFoliage = AMBIG_BIRD_RE.test(all) && a;
+ const birdOrBfly = birdUnambig || birdInPattern || birdWithFoliage;
+ const otherB = OTHER_B_RE.test(all);
+
+ if ((birdOrBfly || otherB) && a && !nonFoliage) {
+ if (acceptable && !birdOrBfly) return { verdict: 'OK', reason: 'partA+partB but acceptable carve-out (trunk/branch), non-bird' };
+ return { verdict: 'BLOCK', reason: birdOrBfly ? 'partA-foliage + bird/butterfly (Strawberry-Thief class)' : 'partA-foliage + partB(banana/grape)' };
+ }
+ // bird/butterfly motif with no clear non-foliage substrate = borderline -> defendant-favorable BLOCK
+ if (birdOrBfly && !nonFoliage && !acceptable) return { verdict: 'BLOCK', reason: 'bird/butterfly motif, substrate ambiguous — defendant-favorable' };
+ return { verdict: 'OK', reason: '' };
+}
+
+function run(brand) {
+ const path = new URL(`../pilot/manifest-${brand}.json`, import.meta.url).pathname;
+ if (!fs.existsSync(path)) { console.log(`${brand}: no manifest`); return; }
+ const items = JSON.parse(fs.readFileSync(path, 'utf8'));
+ const kept = [], blocked = [];
+ for (const it of items) {
+ const c = classify(it.pattern, it.color, it.collection);
+ it.settlement = c;
+ if (c.verdict === 'BLOCK') blocked.push({ dw_sku: it.dw_sku, name: `${it.pattern} ${it.color}`, reason: c.reason });
+ else kept.push(it);
+ }
+ fs.writeFileSync(path, JSON.stringify(kept, null, 2)); // manifest now settlement-clean
+ fs.writeFileSync(new URL(`../pilot/settlement-blocked-${brand}.json`, import.meta.url).pathname, JSON.stringify(blocked, null, 2));
+ const byReason = blocked.reduce((a, x) => { a[x.reason] = (a[x.reason] || 0) + 1; return a; }, {});
+ console.log(`${brand}: settlement-clean=${kept.length} BLOCKED=${blocked.length} ${JSON.stringify(byReason)}`);
+}
+
+const only = (process.argv.find(a => a.startsWith('--brand=')) || '').split('=')[1];
+for (const b of only ? [only] : ['sanderson', 'harlequin', 'zoffany', 'morris']) run(b);
← c6d9704 TK-10877: harvest Morris & Co full catalog (wp+fabric) from
·
back to Sanderson Onboard
·
SDG: title-case fix for color/, graceful write_inventory-sco 0d235c3 →