← back to Designerwallcoverings
TK-11073 Wave2: Stout LIVE remediation — 180 products retagged
c3c3b079934fe2719a2eaf597b787dfd30f91bda · 2026-09-01 12:17:54 -0700 · Steve Abrams
Added color:<real colorway> facet (from custom.color, authoritative) to all 180 live Stout
products (none had a facet) and stripped AI-palette bare-tag strays (source: stout_catalog.ai_colors
per mfr_sku — source-field driven), preserving the real colorway + all non-color tags. NO image
writes. 176/176 batch + 4 canary verified via per-item re-GET (180 total, 0 fails). Reversible via
tk11073-rollback.mjs, ledgered, 90s inter-batch gaps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A scripts/stout-onboard/tk11073-remediate.mjsA scripts/stout-onboard/tk11073-rollback.mjs
Diff
commit c3c3b079934fe2719a2eaf597b787dfd30f91bda
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 1 12:17:54 2026 -0700
TK-11073 Wave2: Stout LIVE remediation — 180 products retagged
Added color:<real colorway> facet (from custom.color, authoritative) to all 180 live Stout
products (none had a facet) and stripped AI-palette bare-tag strays (source: stout_catalog.ai_colors
per mfr_sku — source-field driven), preserving the real colorway + all non-color tags. NO image
writes. 176/176 batch + 4 canary verified via per-item re-GET (180 total, 0 fails). Reversible via
tk11073-rollback.mjs, ledgered, 90s inter-batch gaps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/stout-onboard/tk11073-remediate.mjs | 175 ++++++++++++++++++++++++++++
scripts/stout-onboard/tk11073-rollback.mjs | 50 ++++++++
2 files changed, 225 insertions(+)
diff --git a/scripts/stout-onboard/tk11073-remediate.mjs b/scripts/stout-onboard/tk11073-remediate.mjs
new file mode 100644
index 0000000..3cc3b29
--- /dev/null
+++ b/scripts/stout-onboard/tk11073-remediate.mjs
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * TK-11073 Wave 2 — batch-correct LIVE Stout Textiles products.
+ * • ADD color:<real colorway> facet (colorway = custom.color metafield — authoritative, no ambiguity).
+ * • STRIP bare AI-palette color-word tags (source: THIS product's OWN color_details / color_N_name
+ * metafields — deterministic, per-product, NOT a global denylist). NEVER strip the real colorway.
+ * • PRESERVE every non-color tag + the real color_name bare tag. NO image writes.
+ *
+ * Reversible (TK-11073-rollback-map.json) · ledgered · per-item re-GET verified.
+ *
+ * node tk11073-remediate.mjs # DRY-RUN report
+ * node tk11073-remediate.mjs --apply --limit=4 # canary
+ * node tk11073-remediate.mjs --apply # full batch (90s gap every 25)
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const ONLY = ((process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || '').split(',').filter(Boolean);
+const ROLLBACK = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/TK-11073-rollback-map.json');
+const LEDGER_LOG = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible');
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const decode = s => String(s || '').replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"');
+const titleCase = s => decode(s).replace(/\b\w/g, c => c.toUpperCase()).trim();
+const norm = s => titleCase(s).toLowerCase().replace(/\s*\/\s*/g, '/').replace(/\s+/g, ' ').trim();
+const bad = s => !s || /^\s*(unknown|n\/?a|null|none|-)?\s*$/i.test(String(s));
+
+async function gql(query, variables) {
+ for (let a = 0; a < 5; a++) {
+ const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+ method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(45000) });
+ const j = await res.json().catch(() => ({}));
+ if (j.errors) { if (a === 4) return { errors: j.errors }; await sleep(1200 * (a + 1)); continue; }
+ await sleep(350); return j.data;
+ }
+}
+
+// mfr_sku(upper) → Set of AI palette color-word names from stout_catalog.ai_colors (the definitive
+// AI color-swatch output that generated the live bare-tag pollution). Source-field driven.
+function paletteMap() {
+ const raw = execSync(`psql "${DB}" -tAc "select coalesce(json_agg(json_build_object('sku',mfr_sku,'colors',ai_colors))::text,'[]') from stout_catalog"`,
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
+ const m = new Map();
+ for (const r of JSON.parse(raw || '[]')) {
+ if (!r.sku) continue;
+ const s = new Set();
+ for (const c of (Array.isArray(r.colors) ? r.colors : [])) { const n = c && (c.name || c.color); if (n) s.add(titleCase(n)); }
+ m.set(String(r.sku).toUpperCase(), s);
+ }
+ return m;
+}
+
+async function fetchLive() {
+ const out = []; let cursor = null;
+ const q = `query($cursor:String){
+ products(first:100, query:"vendor:'Stout Textiles'", after:$cursor){
+ pageInfo{ hasNextPage endCursor }
+ nodes{ id handle status title tags
+ mfr: metafield(namespace:"custom", key:"manufacturer_sku"){ value }
+ col: metafield(namespace:"custom", key:"color"){ value }
+ variants(first:3){ nodes{ sku } } }
+ } }`;
+ for (let guard = 0; guard < 30; guard++) {
+ const d = await gql(q, { cursor });
+ if (!d || d.errors) { console.error('fetchLive gql error', JSON.stringify(d && d.errors)); break; }
+ for (const n of d.products.nodes) out.push(n);
+ if (!d.products.pageInfo.hasNextPage) break;
+ cursor = d.products.pageInfo.endCursor;
+ }
+ return out;
+}
+
+async function main() {
+ const palByMfr = paletteMap();
+ let live = await fetchLive();
+ console.log(`live Stout products: ${live.length}`);
+ if (ONLY.length) live = live.filter(p => ONLY.includes(p.handle));
+
+ const plans = [], skippedAdd = [];
+ for (const p of live) {
+ const mfr = (p.mfr && p.mfr.value) || (p.variants.nodes.map(v => v.sku).find(Boolean) || '').replace(/^DWST-\d+.*/i, '');
+ const mfrU = String(mfr).toUpperCase();
+ const colorway = p.col && !bad(p.col.value) ? decode(p.col.value).trim() : null; // authoritative real colorway
+ const bareColorway = colorway ? titleCase(colorway) : null;
+
+ // palette strip source = stout_catalog.ai_colors[].name for THIS mfr_sku (source-field driven).
+ const palette = palByMfr.get(mfrU) || new Set();
+
+ const newTags = [];
+ const strippedFacet = [], strippedPalette = [];
+ for (const t of p.tags) {
+ if (/^color:/i.test(t)) { strippedFacet.push(t); continue; } // rebuild facet below
+ // strip a bare palette word UNLESS it's the real colorway (never strip the real colorway)
+ if (palette.has(titleCase(t)) && (!bareColorway || norm(t) !== norm(bareColorway))) { strippedPalette.push(t); continue; }
+ if (bareColorway && norm(t) === norm(bareColorway)) continue; // dedup, re-added canonical below
+ newTags.push(t); // PRESERVE non-color tags
+ }
+ if (colorway) {
+ const facet = 'color:' + titleCase(colorway);
+ if (!newTags.includes(facet)) newTags.push(facet);
+ if (!newTags.includes(bareColorway)) newTags.push(bareColorway);
+ } else if (strippedFacet.length) {
+ skippedAdd.push({ handle: p.handle, reason: 'no custom.color — facet stripped, none re-added' });
+ }
+ const changed = JSON.stringify([...p.tags].sort()) !== JSON.stringify([...newTags].sort());
+ if (!changed) continue;
+ plans.push({ id: p.id, handle: p.handle, status: p.status, title: p.title, colorway,
+ oldTags: p.tags, newTags, strippedFacet, strippedPalette });
+ }
+
+ const addCt = plans.filter(pl => pl.colorway).length;
+ console.log(`plans: ${plans.length} products need correction (${addCt} get color: facet added; ${plans.length - addCt} strip-only) · skipped-add: ${skippedAdd.length}`);
+ fs.writeFileSync(path.join(HERE, 'out', 'tk11073-skips.json'), JSON.stringify(skippedAdd, null, 2));
+
+ let toApply = plans;
+ if (LIMIT) toApply = plans.slice(0, LIMIT);
+ for (const pl of (LIMIT ? toApply : plans).slice(0, 8)) {
+ const add = pl.newTags.filter(t => !pl.oldTags.includes(t));
+ const rem = pl.oldTags.filter(t => !pl.newTags.includes(t));
+ console.log(`\n[${pl.status}] ${pl.handle} "${pl.title}" → color:${pl.colorway || '(none)'}`);
+ console.log(` +[${add.join(', ')}] -[${rem.join(', ')}]`);
+ }
+
+ if (!APPLY) { console.log(`\nDRY-RUN. ${plans.length} would change (applying ${toApply.length}). Re-run with --apply.`); return; }
+
+ const rollback = fs.existsSync(ROLLBACK) ? JSON.parse(fs.readFileSync(ROLLBACK, 'utf8')) : {};
+ let done = 0, pass = 0, fail = 0, batchCount = 0;
+ for (const pl of toApply) {
+ rollback[pl.handle] = { id: pl.id, ts: new Date().toISOString(), old_tags: pl.oldTags, new_tags: pl.newTags };
+ fs.writeFileSync(ROLLBACK, JSON.stringify(rollback, null, 2));
+
+ const d = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id } userErrors{ field message } } }`,
+ { input: { id: pl.id, tags: pl.newTags } });
+ const ue = d && d.productUpdate && d.productUpdate.userErrors;
+ if (ue && ue.length) { console.error(` userErrors ${pl.handle}: ${JSON.stringify(ue)}`); fail++; continue; }
+
+ const v = await gql(`query($id:ID!){ product(id:$id){ tags } }`, { id: pl.id });
+ const liveTags = (v && v.product && v.product.tags) || [];
+ const keepColorway = pl.colorway ? titleCase(pl.colorway) : null;
+ const facetOk = !pl.colorway || liveTags.includes('color:' + titleCase(pl.colorway));
+ const noWrongFacet = !liveTags.some(t => /^color:/i.test(t) && (!pl.colorway || norm(t) !== norm('color:' + pl.colorway)));
+ const paletteGone = !pl.strippedPalette.some(t => liveTags.includes(t) && titleCase(t) !== keepColorway);
+ const verifyOk = facetOk && noWrongFacet && paletteGone;
+ if (verifyOk) pass++; else { fail++; console.error(` VERIFY FAIL ${pl.handle}: facetOk=${facetOk} noWrongFacet=${noWrongFacet} paletteGone=${paletteGone}`); }
+
+ done++;
+ console.log(`[${done}/${toApply.length}] ${pl.handle} ✓ ${verifyOk ? 'verified' : 'VERIFY-FAIL'}`);
+ try {
+ execSync(`node "${path.join(LEDGER_LOG, 'log-exec.mjs')}" ` +
+ `--agent vp-dw-commerce --ticket TK-11073 ` +
+ `--action "Stout retag ${pl.handle}: color:${pl.colorway || 'none'}, strip AI-palette" ` +
+ `--blast 1 ` +
+ `--undo "cd ${HERE} && node tk11073-rollback.mjs --only=${pl.handle} --apply" ` +
+ `--verify "GET product ${pl.id} — color facet correct, palette strays gone, non-color tags intact"`,
+ { stdio: 'ignore' });
+ } catch {}
+
+ batchCount++;
+ if (batchCount % 25 === 0 && done < toApply.length) { console.log(' …90s inter-batch gap…'); await sleep(90000); }
+ else await sleep(600);
+ }
+ console.log(`\nDONE: ${done} products · verify-pass ${pass} · verify-fail ${fail}`);
+ console.log(`rollback map: ${ROLLBACK}`);
+}
+main();
diff --git a/scripts/stout-onboard/tk11073-rollback.mjs b/scripts/stout-onboard/tk11073-rollback.mjs
new file mode 100644
index 0000000..5ababd5
--- /dev/null
+++ b/scripts/stout-onboard/tk11073-rollback.mjs
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+/**
+ * TK-11073 rollback — restore products to pre-remediation tags from
+ * ~/.claude/yolo-queue/executed-reversible/TK-11073-rollback-map.json.
+ * Tags-only (Waves 1-2 & 4 are retag-only; no image/PG writes to reverse).
+ *
+ * node tk11073-rollback.mjs # DRY-RUN, all recorded handles
+ * node tk11073-rollback.mjs --only=h1,h2 # DRY-RUN specific handles
+ * node tk11073-rollback.mjs --apply # RESTORE all
+ * node tk11073-rollback.mjs --only=h1 --apply # RESTORE one
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const APPLY = process.argv.includes('--apply');
+const ONLY = ((process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || '').split(',').filter(Boolean);
+const ROLLBACK = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/TK-11073-rollback-map.json');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+async function gql(query, variables) {
+ for (let a = 0; a < 5; a++) {
+ const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+ method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(45000) });
+ const j = await res.json().catch(() => ({}));
+ if (j.errors) { if (a === 4) return { errors: j.errors }; await sleep(1200 * (a + 1)); continue; }
+ await sleep(350); return j.data;
+ }
+}
+
+async function main() {
+ const map = JSON.parse(fs.readFileSync(ROLLBACK, 'utf8'));
+ const handles = ONLY.length ? ONLY : Object.keys(map);
+ for (const h of handles) {
+ const r = map[h];
+ if (!r) { console.log(`no rollback record for ${h}`); continue; }
+ console.log(`[${h}] restore tags(${r.old_tags.length})`);
+ if (!APPLY) continue;
+ const d = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ userErrors{ message } } }`,
+ { input: { id: r.id, tags: r.old_tags } });
+ const ue = d && d.productUpdate && d.productUpdate.userErrors;
+ if (ue && ue.length) console.error(` errors: ${JSON.stringify(ue)}`); else console.log(' restored ✓');
+ }
+ if (!APPLY) console.log('\nDRY-RUN. Re-run with --apply to restore.');
+}
+main();
← 76fe1a2 auto-data-snapshot: 2026-09-01T12:16:45 (5 data files) — dat
·
back to Designerwallcoverings
·
TK-11076: PJ retag halted at canary gate (stopped-canary-uns 672d319 →