[object Object]

← back to Designerwallcoverings

stroheim onboard (TK-00039): dedicated 'stroheim' variant-budget lane + create-drafts wiring

4eaf385ec4df8b1a1620a601fe7209abdc7eb863 · 2026-07-27 18:09:58 -0700 · Steve Abrams

- budget.cjs: add finite 'stroheim' category modeled on stout (own ~100/day floor,
  auto-zeroes on completion, clamped to the ~1000/day Shopify cap). Contrarian-gated (SHIP IT).
- create-drafts.mjs: BUDGET_CAT default backlog->stroheim (stroheim-scoped; verified no other
  caller). settlement-gate.mjs: retry transient img-fetch + SKIP (not fail-closed BLOCK) on
  fetch/vision error. resume.sh: note dedicated lane. Add (uninstalled, Steve-gated) launchd plist.

Prices were already in canonical dw_unified (771/782 roll retail); local mirror was stale.
771 minted DWST-810001..810771, 771 payloads built. Drafts trickle once budget frees (gated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 4eaf385ec4df8b1a1620a601fe7209abdc7eb863
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 18:09:58 2026 -0700

    stroheim onboard (TK-00039): dedicated 'stroheim' variant-budget lane + create-drafts wiring
    
    - budget.cjs: add finite 'stroheim' category modeled on stout (own ~100/day floor,
      auto-zeroes on completion, clamped to the ~1000/day Shopify cap). Contrarian-gated (SHIP IT).
    - create-drafts.mjs: BUDGET_CAT default backlog->stroheim (stroheim-scoped; verified no other
      caller). settlement-gate.mjs: retry transient img-fetch + SKIP (not fail-closed BLOCK) on
      fetch/vision error. resume.sh: note dedicated lane. Add (uninstalled, Steve-gated) launchd plist.
    
    Prices were already in canonical dw_unified (771/782 roll retail); local mirror was stale.
    771 minted DWST-810001..810771, 771 payloads built. Drafts trickle once budget frees (gated).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .../com.steve.stroheim-onboard-resume.plist        | 27 +++++++++++
 scripts/stroheim-onboard/create-drafts.mjs         |  2 +-
 scripts/stroheim-onboard/resume.sh                 |  3 +-
 scripts/stroheim-onboard/settlement-gate.mjs       | 32 +++++++------
 scripts/variant-budget/budget.cjs                  | 53 +++++++++++++++++-----
 5 files changed, 91 insertions(+), 26 deletions(-)

diff --git a/scripts/stroheim-onboard/com.steve.stroheim-onboard-resume.plist b/scripts/stroheim-onboard/com.steve.stroheim-onboard-resume.plist
new file mode 100644
index 0000000..3eff8ee
--- /dev/null
+++ b/scripts/stroheim-onboard/com.steve.stroheim-onboard-resume.plist
@@ -0,0 +1,27 @@
+<?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.stroheim-onboard-resume</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>/Users/macstudio3/Projects/designerwallcoverings/scripts/stroheim-onboard/resume.sh</string>
+  </array>
+  <!-- Trickle the 771 settlement-OK Stroheim (DWST-810xxx) DRAFTS under the DEDICATED 'stroheim'
+       variant slice (own floor ~100/day = ~50 products/day, ~16 days; NEVER 'upload'). resume.sh
+       is DRAFTS-ONLY (STROHEIM_ALLOW_GOLIVE unset → go-live SKIPPED) — activation stays a
+       separate Steve-gated step. 00:40 primary + 13:40 retry (offset from stout 00:30/13:30,
+       muralsource 00:20/13:20, greenland 00:10/13:00 to avoid backlog contention). resume.sh is
+       idempotent + settlement-gated + cap-clean and no-ops once every OK SKU is created.
+       INSTALL (Steve-gated — scheduled-job install). To arm:
+         cp ~/Projects/designerwallcoverings/scripts/stroheim-onboard/com.steve.stroheim-onboard-resume.plist ~/Library/LaunchAgents/
+         launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.stroheim-onboard-resume.plist -->
+  <key>StartCalendarInterval</key>
+  <array>
+    <dict><key>Hour</key><integer>0</integer><key>Minute</key><integer>40</integer></dict>
+    <dict><key>Hour</key><integer>13</integer><key>Minute</key><integer>40</integer></dict>
+  </array>
+  <key>StandardOutPath</key><string>/Users/macstudio3/Projects/designerwallcoverings/scripts/stroheim-onboard/out/launchd.out</string>
+  <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/designerwallcoverings/scripts/stroheim-onboard/out/launchd.err</string>
+</dict></plist>
diff --git a/scripts/stroheim-onboard/create-drafts.mjs b/scripts/stroheim-onboard/create-drafts.mjs
index e6916b7..23a649a 100644
--- a/scripts/stroheim-onboard/create-drafts.mjs
+++ b/scripts/stroheim-onboard/create-drafts.mjs
@@ -30,7 +30,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
 const require = createRequire(import.meta.url);
 const budget = require('../variant-budget/budget.cjs');
-const BUDGET_CAT = process.env.DW_STROHEIM_BUDGET || 'backlog';   // SHARED backlog budget — never 'upload'
+const BUDGET_CAT = process.env.DW_STROHEIM_BUDGET || 'stroheim';  // dedicated finite-onboard category (TK-00039) — floor-protected, never 'upload'
 const APPLY = process.argv.includes('--apply');
 const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
 const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
diff --git a/scripts/stroheim-onboard/resume.sh b/scripts/stroheim-onboard/resume.sh
index ecb55f9..4eaf95b 100755
--- a/scripts/stroheim-onboard/resume.sh
+++ b/scripts/stroheim-onboard/resume.sh
@@ -7,7 +7,8 @@
 #   1. mint-skus       — assign DWST-810001+ to priced+imaged net_new rows (staging only)
 #   2. build-payloads  — build the plan from stroheim_catalog (skips no-price/no-image/unminted)
 #   3. settlement-gate — Gemini-vision gate any ungated image (memo #3; metered; $0 if 0 payloads)
-#   4. create-drafts   — mint settlement-OK drafts as DRAFT (shared 'backlog' variant budget)
+#   4. create-drafts   — mint settlement-OK drafts as DRAFT (DEDICATED 'stroheim' variant budget,
+#                        own ~100/day floor, TK-00039; never 'upload'/'backlog')
 #   5. go-live         — ONLY if STROHEIM_ALLOW_GOLIVE=1 (Steve-gated activation; channels
 #                        except Google/YouTube). Default: SKIPPED — drafts stay drafts.
 #
diff --git a/scripts/stroheim-onboard/settlement-gate.mjs b/scripts/stroheim-onboard/settlement-gate.mjs
index ea16afa..7e3b350 100644
--- a/scripts/stroheim-onboard/settlement-gate.mjs
+++ b/scripts/stroheim-onboard/settlement-gate.mjs
@@ -51,11 +51,15 @@ Respond with ONLY this JSON, booleans true/false (never null), plus a 3-6 word e
 {"a1":bool,"a2":bool,"a3":bool,"b":bool,"acceptable":bool,"evidence":{"a1":"","a2":"","a3":"","b":"","acceptable":""}}`;
 
 async function fetchB64(url) {
-  const r = await fetch(url, { signal: AbortSignal.timeout(30000) });
-  if (!r.ok) throw new Error(`img ${r.status}`);
-  const ct = r.headers.get('content-type') || 'image/jpeg';
-  const buf = Buffer.from(await r.arrayBuffer());
-  return { mime: ct.split(';')[0], data: buf.toString('base64') };
+  for (let t = 0; t < 4; t++) {
+    try {
+      const r = await fetch(url, { signal: AbortSignal.timeout(30000) });
+      if (!r.ok) throw new Error(`img ${r.status}`);
+      const ct = r.headers.get('content-type') || 'image/jpeg';
+      const buf = Buffer.from(await r.arrayBuffer());
+      return { mime: ct.split(';')[0], data: buf.toString('base64') };
+    } catch (e) { if (t === 3) throw e; await sleep(800 * (t + 1)); }   // retry transient img-fetch (ECONNRESET/timeout)
+  }
 }
 
 async function gemini(b64) {
@@ -98,20 +102,22 @@ async function main() {
   if (!APPLY) { console.log(`  first 3: ${todo.slice(0, 3).map(p => p.sku).join(', ')}`); console.log('DRY-RUN. --apply to gate.'); return; }
 
   const fd = fs.openSync(path.join(OUT, 'settlement-verdicts.jsonl'), 'a');
-  let ok = 0, block = 0, err = 0, spent = 0;
+  let ok = 0, block = 0, skip = 0, spent = 0;
   for (const p of todo) {
-    let rec;
-    try {
-      const b64 = await fetchB64(p.image_url);
-      const v = await gemini(b64); spent += COST_PER_IMG;
-      rec = { sku: p.sku, ...verdict(v), gated_at: new Date().toISOString() };
-    } catch (e) { rec = { sku: p.sku, verdict: 'BLOCK', reason: `error: ${String(e).slice(0, 60)} → fail closed`, gated_at: new Date().toISOString() }; err++; }
+    let b64;
+    try { b64 = await fetchB64(p.image_url); }
+    catch (e) { skip++; await sleep(400); continue; }                 // img-fetch failed after retries → skip, retry next pass
+    const v = await gemini(b64); spent += COST_PER_IMG;
+    const hasAll = v && ['a1', 'a2', 'a3', 'b'].every(k => typeof v[k] === 'boolean');
+    if (!hasAll) { skip++; await sleep(400); continue; }               // vision null/malformed → SKIP (not BLOCK), retry next pass
+    const rec = { sku: p.sku, ...verdict(v), gated_at: new Date().toISOString() };
     fs.writeSync(fd, JSON.stringify(rec) + '\n');
     if (rec.verdict === 'OK') ok++; else block++;
-    if ((ok + block) % 20 === 0) console.log(`  ...${ok + block}/${todo.length} (OK ${ok}, BLOCK ${block}, err ${err}) · $${spent.toFixed(4)}`);
+    if ((ok + block + skip) % 20 === 0) console.log(`  ...${ok + block + skip}/${todo.length} (OK ${ok}, BLOCK ${block}, skip ${skip}) · $${spent.toFixed(4)}`);
     await sleep(400);
   }
   fs.closeSync(fd);
+  console.log(`  pass result: OK ${ok} · BLOCK ${block} · skipped(retry-next-pass) ${skip}`);
   console.log(`\nDONE. OK=${ok} BLOCK=${block} err=${err} of ${todo.length} · actual ~$${spent.toFixed(4)} (Gemini ${MODEL} vision)`);
   if (block) console.log(`  ⚠ ${block} BLOCK/held — will NOT be created/activated; review out/settlement-verdicts.jsonl (grep BLOCK).`);
 }
diff --git a/scripts/variant-budget/budget.cjs b/scripts/variant-budget/budget.cjs
index 8524573..5d4a242 100644
--- a/scripts/variant-budget/budget.cjs
+++ b/scripts/variant-budget/budget.cjs
@@ -53,7 +53,7 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');           // designerwall
 const ROLL_DONE = path.join(REPO_ROOT, 'roll-scale-done2.txt');  // build-roll-scale.js resume file
 const PGENV = { PGHOST: '/tmp', PGPORT: '5432', PGUSER: 'stevestudio2', PGDATABASE: 'dw_unified',
   PATH: '/opt/homebrew/opt/postgresql@14/bin:/usr/local/bin:/usr/bin:/bin' };
-const VALID = new Set(['roll', 'sample', 'upload', 'osborne', 'backlog', 'maharam', 'stout']);
+const VALID = new Set(['roll', 'sample', 'upload', 'osborne', 'backlog', 'maharam', 'stout', 'stroheim']);
 // 'upload' = the all-day new-product uploader (cadence-import). Modest 200/day slice
 // carved from the shared 800 ceiling via competition (NOT a hard reservation): on light
 // days it's free leftover; on max-capacity days it trims roll/sample slightly. Keeps new
@@ -114,6 +114,16 @@ const MAH_OUT = path.join(REPO_ROOT, 'scripts', 'maharam-onboard', 'out');
 const STOUT_CAP = parseInt(process.env.DW_STOUT_CAP || '100', 10);
 const STOUT_FLOOR = parseInt(process.env.DW_STOUT_FLOOR || String(STOUT_CAP), 10);
 const STOUT_OUT = path.join(REPO_ROOT, 'scripts', 'stout-onboard', 'out');
+// 'stroheim' = the finite Stroheim wallcovering onboard (771 priced products, 2 variants each:
+// Roll + $4.25 Sample). TK-00039 (Steve 2026-07-27, "Own 'stroheim' floor like stout"): modeled
+// on 'stout' — its OWN category + a floor reservation (below) that auto-zeroes when created.jsonl
+// reaches the payload total, so the ~100/day trickle can't be starved by the 5-field 'backlog'
+// drain (the slice it was previously sharing) NOR breach the ~1,000/day Shopify cap (clamped to
+// remainingGlobal like every category). 771×2=1,542 variants → ~16 days.
+// REVERSIBLE — self-zeroes on completion; force off via DW_STROHEIM_CAP=0.
+const STROHEIM_CAP = parseInt(process.env.DW_STROHEIM_CAP || '100', 10);
+const STROHEIM_FLOOR = parseInt(process.env.DW_STROHEIM_FLOOR || String(STROHEIM_CAP), 10);
+const STROHEIM_OUT = path.join(REPO_ROOT, 'scripts', 'stroheim-onboard', 'out');
 
 const today = () => {
   const d = new Date();
@@ -151,8 +161,8 @@ function phase() {
     // slice is never pre-empted. osborne keeps its small finite floor. REVERSIBLE — restore
     // roll/sample 400/80 (rollPhase) · 250/120 (backfill) when the new-SKU push goal is met.
     caps: rollPhase
-      ? { roll: ROLL_CAP, sample: 0, upload: UPLOAD_CAP, osborne: OSBORNE_CAP, backlog: BACKLOG_CAP, maharam: MAHARAM_CAP, stout: STOUT_CAP }
-      : { roll: ROLL_CAP, sample: 0, upload: UPLOAD_CAP, osborne: OSBORNE_CAP, backlog: BACKLOG_CAP, maharam: MAHARAM_CAP, stout: STOUT_CAP },
+      ? { roll: ROLL_CAP, sample: 0, upload: UPLOAD_CAP, osborne: OSBORNE_CAP, backlog: BACKLOG_CAP, maharam: MAHARAM_CAP, stout: STOUT_CAP, stroheim: STROHEIM_CAP }
+      : { roll: ROLL_CAP, sample: 0, upload: UPLOAD_CAP, osborne: OSBORNE_CAP, backlog: BACKLOG_CAP, maharam: MAHARAM_CAP, stout: STOUT_CAP, stroheim: STROHEIM_CAP },
   };
 }
 
@@ -201,6 +211,21 @@ function stoutReservation(spentStout) {
   return Math.min(Math.max(0, STOUT_FLOOR - spentStout), remainingProducts * 2);
 }
 
+// ── stroheim hard-floor reservation (finite; auto-reverts on completion) ───────
+// Same shape as stoutReservation — 2 variants/product (Roll + Sample). The unspent floor, never
+// more than the ACTUAL remaining stroheim work. Auto-zeroes when created.jsonl reaches the payload
+// total → the reserved slice hands straight back to the pool (no calendar guess).
+function stroheimReservation(spentStroheim) {
+  let remainingProducts = 0;
+  try {
+    const total = fs.readFileSync(path.join(STROHEIM_OUT, 'payloads.jsonl'), 'utf8').split('\n').filter(Boolean).length;
+    let created = 0;
+    try { created = fs.readFileSync(path.join(STROHEIM_OUT, 'created.jsonl'), 'utf8').split('\n').filter(Boolean).length; } catch (_) {}
+    remainingProducts = Math.max(0, total - created);
+  } catch (_) { return 0; }   // no stroheim manifest → reserve nothing
+  return Math.min(Math.max(0, STROHEIM_FLOOR - spentStroheim), remainingProducts * 2);
+}
+
 // ── mkdir-mutex (atomic create; stale-break after 10s) ─────────────────────────
 function withLock(fn) {
   fs.mkdirSync(LEDGER_DIR, { recursive: true });
@@ -225,10 +250,11 @@ function load() {
       j.spent.roll = j.spent.roll || 0; j.spent.sample = j.spent.sample || 0; j.spent.upload = j.spent.upload || 0;
       j.spent.osborne = j.spent.osborne || 0; j.spent.backlog = j.spent.backlog || 0;
       j.spent.maharam = j.spent.maharam || 0; j.spent.stout = j.spent.stout || 0;
+      j.spent.stroheim = j.spent.stroheim || 0;
       return j;
     }
   } catch (e) {}
-  return { date: today(), spent: { roll: 0, sample: 0, upload: 0, osborne: 0, backlog: 0, maharam: 0, stout: 0 } };
+  return { date: today(), spent: { roll: 0, sample: 0, upload: 0, osborne: 0, backlog: 0, maharam: 0, stout: 0, stroheim: 0 } };
 }
 function save(j) {
   const tmp = `${ledgerFile()}.tmp-${process.pid}`;
@@ -248,7 +274,7 @@ function take(who, n) {
     return withLock(() => {
       const ph = phase();
       const j = load();
-      const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog + j.spent.maharam + j.spent.stout;
+      const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog + j.spent.maharam + j.spent.stout + j.spent.stroheim;
       let remainingGlobal = Math.max(0, BUDGET_TOTAL - spentTotal);
       // Hard floor: subtract osborne's unspent reserved slice from what every OTHER category may
       // draw, so roll/sample/upload can't starve the finite Osborne onboard. Osborne itself draws
@@ -260,6 +286,7 @@ function take(who, n) {
       if (who !== 'maharam') remainingGlobal = Math.max(0, remainingGlobal - maharamReservation(j.spent.maharam));
       // Same finite-floor protection for stout (owns its reservation, exactly like osborne/maharam).
       if (who !== 'stout') remainingGlobal = Math.max(0, remainingGlobal - stoutReservation(j.spent.stout));
+      if (who !== 'stroheim') remainingGlobal = Math.max(0, remainingGlobal - stroheimReservation(j.spent.stroheim));
       let remainingWho = Math.max(0, ph.caps[who] - j.spent[who]);
       // end-of-day reclaim: backlog may sweep the global unspent remainder in late slots
       if (who === 'backlog' && reclaimActive()) remainingWho = remainingGlobal;
@@ -313,11 +340,12 @@ function remaining(who) {
     return withLock(() => {
       const ph = phase();
       const j = load();
-      const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog + j.spent.maharam + j.spent.stout;
+      const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog + j.spent.maharam + j.spent.stout + j.spent.stroheim;
       let globalLeft = Math.max(0, BUDGET_TOTAL - spentTotal);
       if (who !== 'osborne') globalLeft = Math.max(0, globalLeft - osborneReservation(j.spent.osborne));
       if (who !== 'maharam') globalLeft = Math.max(0, globalLeft - maharamReservation(j.spent.maharam));
       if (who !== 'stout') globalLeft = Math.max(0, globalLeft - stoutReservation(j.spent.stout));
+      if (who !== 'stroheim') globalLeft = Math.max(0, globalLeft - stroheimReservation(j.spent.stroheim));
       if (who === 'backlog' && reclaimActive()) return globalLeft;
       return Math.min(Math.max(0, ph.caps[who] - j.spent[who]), globalLeft);
     });
@@ -327,12 +355,13 @@ function remaining(who) {
 function status() {
   const ph = phase();
   const j = load();
-  const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog + j.spent.maharam + j.spent.stout;
+  const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog + j.spent.maharam + j.spent.stout + j.spent.stroheim;
   const globalLeft = Math.max(0, BUDGET_TOTAL - spentTotal);
   const osborneReserved = osborneReservation(j.spent.osborne);   // unspent hard floor (auto-zeroes on completion)
   const maharamReserved = maharamReservation(j.spent.maharam);   // finite Maharam floor (auto-zeroes on completion)
   const stoutReserved = stoutReservation(j.spent.stout);         // finite Stout floor (auto-zeroes on completion)
-  const othersGlobal = Math.max(0, globalLeft - osborneReserved - maharamReserved - stoutReserved); // what roll/sample/upload/backlog may draw
+  const stroheimReserved = stroheimReservation(j.spent.stroheim); // finite Stroheim floor (auto-zeroes on completion)
+  const othersGlobal = Math.max(0, globalLeft - osborneReserved - maharamReserved - stoutReserved - stroheimReserved); // what roll/sample/upload/backlog may draw
   return {
     date: j.date, budgetTotal: BUDGET_TOTAL,
     phase: ph.rollPhase ? 'roll-priority' : 'backfill-priority',
@@ -340,6 +369,7 @@ function status() {
     osborneFloor: OSBORNE_FLOOR, osborneReserved,
     maharamFloor: MAHARAM_FLOOR, maharamReserved,
     stoutFloor: STOUT_FLOOR, stoutReserved,
+    stroheimFloor: STROHEIM_FLOOR, stroheimReserved,
     backlogReclaim: { hour: RECLAIM_HOUR, active: reclaimActive() },
     caps: ph.caps, spent: j.spent, spentTotal,
     remaining: { roll: Math.min(Math.max(0, ph.caps.roll - j.spent.roll), othersGlobal),
@@ -347,9 +377,10 @@ function status() {
       upload: Math.min(Math.max(0, ph.caps.upload - j.spent.upload), othersGlobal),
       backlog: reclaimActive() ? othersGlobal
         : Math.min(Math.max(0, ph.caps.backlog - j.spent.backlog), othersGlobal),
-      osborne: Math.min(Math.max(0, ph.caps.osborne - j.spent.osborne), Math.max(0, globalLeft - maharamReserved - stoutReserved)),
-      maharam: Math.min(Math.max(0, ph.caps.maharam - j.spent.maharam), Math.max(0, globalLeft - osborneReserved - stoutReserved)),
-      stout: Math.min(Math.max(0, ph.caps.stout - j.spent.stout), Math.max(0, globalLeft - osborneReserved - maharamReserved)),
+      osborne: Math.min(Math.max(0, ph.caps.osborne - j.spent.osborne), Math.max(0, globalLeft - maharamReserved - stoutReserved - stroheimReserved)),
+      maharam: Math.min(Math.max(0, ph.caps.maharam - j.spent.maharam), Math.max(0, globalLeft - osborneReserved - stoutReserved - stroheimReserved)),
+      stout: Math.min(Math.max(0, ph.caps.stout - j.spent.stout), Math.max(0, globalLeft - osborneReserved - maharamReserved - stroheimReserved)),
+      stroheim: Math.min(Math.max(0, ph.caps.stroheim - j.spent.stroheim), Math.max(0, globalLeft - osborneReserved - maharamReserved - stoutReserved)),
       global: globalLeft },
   };
 }

← b6f54b1 auto-save: 2026-07-27T17:52:34 (5 files) — scripts/sample-sp  ·  back to Designerwallcoverings  ·  chore: v0.1.3 → v0.1.4 (session close — stroheim budget lane 058a362 →