← back to Atomic50 Onboard
Add monthly propose-only Atomic 50 cadence (15th @ 08:00 PT, first run 2026-07-15)
a321e2f670165f61419d1f1fb733a17a9fdcae86 · 2026-07-13 16:43:53 -0700 · steve
Files touched
A cadence/atomic50-cadence.mjsA cadence/com.steve.atomic50-cadence.plistA cadence/data/latest.json
Diff
commit a321e2f670165f61419d1f1fb733a17a9fdcae86
Author: steve <steve@designerwallcoverings.com>
Date: Mon Jul 13 16:43:53 2026 -0700
Add monthly propose-only Atomic 50 cadence (15th @ 08:00 PT, first run 2026-07-15)
---
cadence/atomic50-cadence.mjs | 209 +++++++++++++++++++++++++++++++
cadence/com.steve.atomic50-cadence.plist | 17 +++
cadence/data/latest.json | 15 +++
3 files changed, 241 insertions(+)
diff --git a/cadence/atomic50-cadence.mjs b/cadence/atomic50-cadence.mjs
new file mode 100644
index 0000000..fcd778e
--- /dev/null
+++ b/cadence/atomic50-cadence.mjs
@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+/**
+ * atomic50-cadence.mjs — MONTHLY Atomic 50 Ceilings line maintenance.
+ * PROPOSE-ONLY. NEVER writes Shopify, never publishes. Modeled on york-cadence.mjs.
+ *
+ * Each run (15th @ 08:00 PT):
+ * a. Best-effort re-scrape / refresh atomic50_catalog (try/catch — a headless failure
+ * must NOT crash the cadence). The atomic50-scraper-manager skill ships no runnable
+ * script entrypoint today, so this pass currently SKIPS the re-scrape and notes it;
+ * when an entrypoint lands, point RESCRAPE_CMD at it and it will run best-effort.
+ * b. Diff catalog vs live Shopify (vendor "Atomic 50 Ceilings", keyed by sec/dwc mfr_sku):
+ * - onboard-ready : real catalog SKU (not LBI-BOYD) not yet on Shopify
+ * - disco : catalog row flagged discontinued but still live ACTIVE
+ * - image-drift : live product carrying an Atomic 50 mfr_sku with NO image
+ * Since all 58 real SKUs are currently mapped, the go-forward signal is NEW or REMOVED.
+ * c. If anything is actionable -> ONE dated pending-approval memo + ONE CNCP parking-lot
+ * card. If nothing changed -> write nothing (silent, like the canaries).
+ * d. NEVER auto-write Shopify. Propose only.
+ * e. Bump vendor_registry next_scheduled_crawl +1 month, last_product_update=now().
+ * f. Append a run-log line to cadence/data/cadence.log.
+ *
+ * Cost: $0 — local PG (host=/tmp socket) + read-only Shopify Admin GraphQL (rate-limited).
+ */
+import fs from 'node:fs';
+import { execSync } from 'node:child_process';
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const { Client } = require('pg');
+
+const ROOT = process.env.HOME + '/Projects/atomic50-onboard';
+const DATA = ROOT + '/cadence/data';
+const SHOP = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
+const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+const VENDOR = 'Atomic 50 Ceilings';
+// Set this to a runnable scraper entrypoint (e.g. `${process.env.HOME}/.../scrape.mjs`)
+// to enable best-effort re-scrape. Leave null to skip re-scrape (current state).
+const RESCRAPE_CMD = null;
+
+// ---- token (parse .env directly; do NOT shell-source — unquoted-value bug) ----
+function readToken() {
+ const env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
+ for (const line of env.split('\n')) {
+ const m = line.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/);
+ if (m) return m[1].trim().replace(/^["']|["']$/g, '');
+ }
+ throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+}
+const TOKEN = readToken();
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const norm = (s) => String(s || '').toUpperCase().trim();
+const stamp = () => new Date().toISOString();
+
+const gql = async (query, v) => {
+ for (let a = 0; a < 8; a++) {
+ let j;
+ try {
+ const r = await fetch(URL, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables: v }),
+ });
+ j = await r.json();
+ } catch (e) { await sleep(1500 * (a + 1)); continue; }
+ if (j.errors) {
+ if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
+ throw new Error(JSON.stringify(j.errors));
+ }
+ const t = j.extensions?.cost?.throttleStatus;
+ if (t && t.currentlyAvailable < 300) await sleep(1500);
+ return j.data;
+ }
+ throw new Error('gql retries exhausted');
+};
+
+// ---- a. best-effort re-scrape ------------------------------------------------
+function rescrape() {
+ if (!RESCRAPE_CMD) return { ran: false, note: 'no scraper entrypoint wired — re-scrape skipped this pass' };
+ try {
+ execSync(RESCRAPE_CMD, { stdio: 'ignore', timeout: 20 * 60 * 1000 });
+ return { ran: true, note: 'atomic50 re-scrape completed' };
+ } catch (e) {
+ return { ran: false, note: `re-scrape failed (continuing on existing catalog): ${String(e.message || e).slice(0, 200)}` };
+ }
+}
+
+async function main() {
+ fs.mkdirSync(DATA, { recursive: true });
+ const ts = stamp();
+ const rescr = rescrape();
+
+ const c = new Client({ host: '/tmp', database: 'dw_unified' });
+ await c.connect();
+
+ // ---- catalog snapshot (exclude the LBI-BOYD resource row) ----
+ const { rows: cat } = await c.query(`
+ SELECT mfr_sku, dw_sku, product_type, shopify_product_id, on_shopify,
+ discontinued, image_url
+ FROM atomic50_catalog
+ WHERE mfr_sku <> 'LBI-BOYD'`);
+ const catByMfr = new Map();
+ for (const r of cat) catByMfr.set(norm(r.mfr_sku), r);
+
+ // ---- live Shopify: vendor "Atomic 50 Ceilings", keyed by sec/dwc mfr_sku ----
+ const liveMfr = new Set();
+ const liveNoImage = [];
+ const liveActiveByMfr = new Map();
+ let cur = null;
+ for (let pg = 0; pg < 60; pg++) {
+ const d = (await gql(
+ `query($c:String){products(first:60,after:$c,query:"vendor:'Atomic 50 Ceilings'"){pageInfo{hasNextPage endCursor} nodes{id status title featuredImage{url} sec:metafield(namespace:"sec",key:"mfr_sku"){value} dwc:metafield(namespace:"dwc",key:"manufacturer_sku"){value} cust:metafield(namespace:"custom",key:"manufacturer_sku"){value}}}}`,
+ { c: cur }
+ ))?.products;
+ if (!d) break;
+ for (const p of d.nodes) {
+ const mfr = norm(p.sec?.value || p.dwc?.value || p.cust?.value);
+ if (!mfr) continue;
+ liveMfr.add(mfr);
+ if (p.status === 'ACTIVE') liveActiveByMfr.set(mfr, p);
+ if (!p.featuredImage?.url) liveNoImage.push({ mfr, title: p.title, status: p.status });
+ }
+ if (!d.pageInfo.hasNextPage) break;
+ cur = d.pageInfo.endCursor;
+ }
+
+ // ---- b. diff ----
+ // onboard-ready: real catalog SKU not present on live Shopify (NEW on the site since onboard)
+ const onboard = cat
+ .filter((r) => !liveMfr.has(norm(r.mfr_sku)))
+ .map((r) => ({ mfr: r.mfr_sku, dw_sku: r.dw_sku, type: r.product_type }));
+
+ // disco: catalog row flagged discontinued but still live ACTIVE (REMOVED from the site)
+ const disco = [];
+ for (const [mfr, p] of liveActiveByMfr) {
+ const row = catByMfr.get(mfr);
+ if (row && row.discontinued) disco.push({ mfr, title: p.title });
+ }
+
+ // image-drift: live Atomic 50 product with no image (kept as draft per DW rule, worth flagging)
+ const imageDrift = liveNoImage;
+
+ const actionable = onboard.length + disco.length + imageDrift.length;
+
+ const out = {
+ ts,
+ rescrape: rescr,
+ catalog_real: cat.length,
+ live_shopify: liveMfr.size,
+ onboard_ready: onboard.length,
+ disco: disco.length,
+ image_drift: imageDrift.length,
+ onboard_sample: onboard.slice(0, 12),
+ disco_sample: disco.slice(0, 10),
+ image_drift_sample: imageDrift.slice(0, 10),
+ };
+ fs.writeFileSync(DATA + '/latest.json', JSON.stringify(out, null, 2));
+
+ // ---- c. propose only on change (silent otherwise) ----
+ if (actionable > 0) {
+ const day = ts.slice(0, 10);
+ const memo =
+`# Atomic 50 cadence — ${actionable} actionable (${day})
+
+Monthly propose-only diff of atomic50_catalog vs live Shopify (vendor "${VENDOR}").
+All customer-facing writes stay GATED — this is a surface, not an auto-apply.
+
+- **Onboard-ready**: ${onboard.length} real catalog SKU(s) not yet on Shopify → run push-shopify.js (creates DRAFT, quote-only/sample-only). Sample: ${onboard.slice(0, 8).map((o) => o.mfr).join(', ') || '—'}
+- **Disco**: ${disco.length} live ACTIVE now flagged discontinued in catalog → ARCHIVE (never delete). Sample: ${disco.slice(0, 8).map((d) => d.mfr).join(', ') || '—'}
+- **Image-drift**: ${imageDrift.length} live Atomic 50 product(s) with NO image → keep DRAFT + tag Needs-Image until backfilled. Sample: ${imageDrift.slice(0, 8).map((i) => i.mfr).join(', ') || '—'}
+
+Re-scrape this pass: ${rescr.ran ? 'ran' : 'skipped'} — ${rescr.note}
+Data: cadence/data/latest.json
+
+## Decision
+- [ ] **APPROVE** — which action to run (onboard DRAFT / archive disco / backfill images)
+- [ ] **REVISE** — adjust scope
+- [ ] **BLOCK** — do nothing this cycle
+`;
+ fs.writeFileSync(process.env.HOME + '/.claude/yolo-queue/pending-approval/atomic50-cadence-actions.md', memo);
+ try {
+ execSync(
+ `curl -s -X POST http://127.0.0.1:3333/api/parking-lot -H 'Content-Type: application/json' -d ${JSON.stringify(
+ JSON.stringify({
+ project: 'atomic50-onboard',
+ title: `Atomic 50 cadence: ${actionable} actionable`,
+ note: `onboard ${onboard.length} / disco ${disco.length} / image-drift ${imageDrift.length}`,
+ })
+ )}`,
+ { stdio: 'ignore' }
+ );
+ } catch (e) { /* CNCP down — memo still written */ }
+ }
+
+ // ---- e. bump the schedule (+1 month) and stamp last_product_update ----
+ await c.query(`
+ UPDATE vendor_registry
+ SET next_scheduled_crawl = next_scheduled_crawl + interval '1 month',
+ last_product_update = now()
+ WHERE vendor_name = $1`, [VENDOR]);
+
+ await c.end();
+
+ // ---- f. run-log line ----
+ const logLine = `${ts} | catalog ${cat.length} | live ${liveMfr.size} | onboard-ready ${onboard.length} | disco ${disco.length} | image-drift ${imageDrift.length} | actionable ${actionable} | rescrape=${rescr.ran ? 'ran' : 'skipped'}\n`;
+ fs.appendFileSync(DATA + '/cadence.log', logLine);
+ console.log('atomic50-cadence ' + logLine.trim());
+}
+
+main().catch((e) => { console.error('atomic50-cadence FAILED:', e); process.exit(1); });
diff --git a/cadence/com.steve.atomic50-cadence.plist b/cadence/com.steve.atomic50-cadence.plist
new file mode 100644
index 0000000..f58c2b7
--- /dev/null
+++ b/cadence/com.steve.atomic50-cadence.plist
@@ -0,0 +1,17 @@
+<?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.atomic50-cadence</string>
+ <key>ProgramArguments</key><array>
+ <string>/opt/homebrew/bin/node</string>
+ <string>/Users/macstudio3/Projects/atomic50-onboard/cadence/atomic50-cadence.mjs</string>
+ </array>
+ <key>StartCalendarInterval</key><dict>
+ <key>Day</key><integer>15</integer>
+ <key>Hour</key><integer>8</integer>
+ <key>Minute</key><integer>0</integer>
+ </dict>
+ <key>StandardOutPath</key><string>/Users/macstudio3/Projects/atomic50-onboard/cadence/data/cadence.log</string>
+ <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/atomic50-onboard/cadence/data/cadence.err</string>
+ <key>RunAtLoad</key><false/>
+</dict></plist>
diff --git a/cadence/data/latest.json b/cadence/data/latest.json
new file mode 100644
index 0000000..9129307
--- /dev/null
+++ b/cadence/data/latest.json
@@ -0,0 +1,15 @@
+{
+ "ts": "2026-07-13T23:43:33.707Z",
+ "rescrape": {
+ "ran": false,
+ "note": "no scraper entrypoint wired — re-scrape skipped this pass"
+ },
+ "catalog_real": 58,
+ "live_shopify": 58,
+ "onboard_ready": 0,
+ "disco": 0,
+ "image_drift": 0,
+ "onboard_sample": [],
+ "disco_sample": [],
+ "image_drift_sample": []
+}
\ No newline at end of file
← 7949d34 Add finish-swatch hover gallery (grid + PDP)
·
back to Atomic50 Onboard
·
atomic50: feed-first scraper CLI + wire cadence re-scrape 138c903 →