← back to Designerwallcoverings
auto-save: 2026-07-27T16:52:13 (2 files) — scripts/sample-split-pilot scripts/variant-budget/budget.cjs.bak.20260727-163334
e01df6e602121cc466236a5317d1a457f67af259 · 2026-07-27 16:52:15 -0700 · Steve Abrams
Files touched
A scripts/variant-budget/budget.cjs.bak.20260727-163334
Diff
commit e01df6e602121cc466236a5317d1a457f67af259
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 27 16:52:15 2026 -0700
auto-save: 2026-07-27T16:52:13 (2 files) — scripts/sample-split-pilot scripts/variant-budget/budget.cjs.bak.20260727-163334
---
.../variant-budget/budget.cjs.bak.20260727-163334 | 339 +++++++++++++++++++++
1 file changed, 339 insertions(+)
diff --git a/scripts/variant-budget/budget.cjs.bak.20260727-163334 b/scripts/variant-budget/budget.cjs.bak.20260727-163334
new file mode 100644
index 0000000..834b201
--- /dev/null
+++ b/scripts/variant-budget/budget.cjs.bak.20260727-163334
@@ -0,0 +1,339 @@
+'use strict';
+/**
+ * Shared daily Shopify variant-creation budget ledger (DTD-C, 2026-06-17).
+ *
+ * Shopify caps NEW variant creation at ~1,000/day. Two direct writers compete:
+ * roll-build (build-roll-scale.js) and sample-split (scale-execute.js). Before
+ * today they raced blindly — whoever ran first ate the cap and starved the other.
+ * This is the single coordination point both call: take(who, n) -> granted.
+ *
+ * Phase is DATA-DRIVEN and auto-reverts (the dissent's warning): while the FINITE
+ * roll backlog is unbuilt, rolls get the larger share; once it clears, the budget
+ * hands back to the long sample-split backfill. No calendar guess.
+ * roll backlog remaining > 0 -> roll 550 / sample 250
+ * roll backlog cleared -> roll 250 / sample 550
+ * Hard ceiling: total grants/day <= BUDGET_TOTAL (800, safe headroom under ~1k).
+ *
+ * Fail-OPEN: if the DB/ledger is unreachable we grant the request and log a warn,
+ * so a ledger bug can never freeze the live pipelines (status quo was no cap).
+ * Zero deps. mkdir-mutex for cross-process atomicity.
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { execFileSync } = require('child_process');
+
+// REBALANCE 2026-06-18 (Steve): the ~1,000/day Shopify ROLLING cap was saturated every
+// morning, starving the FINITE backlogs (Anna French 145, the all-day uploader) — af-build
+// + af-noroll both hit the limit at 5:30/7:00am. Ceiling lowered 800→500 so the budget
+// PARTICIPANTS (roll/sample/upload/osborne) collectively create ≤500/day, leaving ~500 of
+// Shopify's real cap free for the finite NON-budget jobs (af-build runs first at 5:30am).
+// Open-ended sample-split is throttled hard (cap below). REVERSIBLE — restore 800 + sample
+// 250/550 once Anna French + the uploader backlog clear. Tune live via DW_VARIANT_BUDGET.
+// 2026-06-19 (Steve "18/hr to fill cap, leave Anna French room"): NEW-PRODUCT UPLOAD PRIORITY.
+// Ceiling = upload 864 (18 products/hr × 2 variants × 24 slots) + osborne floor 20 = 884, so the
+// budgeted jobs take 884 of Shopify's ~1,000/day and leave ~116 for non-budget Anna French af-build.
+// roll/sample throttled to 0 (below) so they can't pre-empt the uploader's daily slice at 5:30am.
+// REVERSIBLE — restore 500 + roll/sample 400/80 (rollPhase) once the new-SKU push goal is met.
+// 2026-06-21 (Steve EVEN 50/50): the 5-field backlog (~17,177 SKUs that can't be bought) now
+// gets a guaranteed HALF of the cap via its own 'backlog' category. Ceiling raised to the full
+// Shopify ~1,000/day (af-build/Anna French finite job is done, so its old ~116 headroom is free):
+// upload 500 (250 new products/day) + backlog 500 (500 backlog SKUs/day) + osborne 20 floor.
+// Backlog clears in ~35 days; REVERSIBLE — restore DW_UPLOAD_CAP=864 + drop backlog once the
+// dw-five-field-canary reports backlog=0. Tune live via DW_VARIANT_BUDGET / DW_UPLOAD_CAP / DW_BACKLOG_CAP.
+// 2026-06-25 (Steve "I need room for testing during the day.. like 40"): ceiling 1000→960 to RESERVE
+// ~40 variants/day (≈20 products) of Shopify's rolling ~1,000 cap for Steve's manual test creations.
+// The 40 is carved from the BACKLOG residual (priority order upload 864 → osborne 20 → backlog), so
+// new arrivals keep their full 18/hr — only the background 5-field drain trims (116→~76/day).
+// REVERSIBLE — set DW_VARIANT_BUDGET=1000 (or unset) to drop the reserve.
+const BUDGET_TOTAL = parseInt(process.env.DW_VARIANT_BUDGET || '960', 10);
+const LEDGER_DIR = path.join(os.homedir(), '.claude', 'variant-budget');
+const LOCK = path.join(LEDGER_DIR, '.lock');
+const REPO_ROOT = path.resolve(__dirname, '..', '..'); // designerwallcoverings/
+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']);
+// '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
+// products loading all day without a separate budget. (Steve 2026-06-17 "upload staged items".)
+const UPLOAD_CAP = parseInt(process.env.DW_UPLOAD_CAP || '864', 10); // 2026-06-23 UPLOAD-PRIORITY restored (Steve via DTD/officer-yolo): 500→864 = 18 new products/hr (432/day, full vendor rotation). Backlog gets the residual 116. Was 500 (even 50/50 since 2026-06-21).
+// 'backlog' = the 5-field activation drain (bulk-fivefield-exec.py). Reserved HALF the cap (500/day)
+// in its OWN category so the new-product uploader can never starve it (the bug that left it ~140/day).
+// 500 backlog SKUs/day (1 variant each) → ~17,177 clears in ~35 days. Drops to 0 / reverts on completion.
+const BACKLOG_CAP = parseInt(process.env.DW_BACKLOG_CAP || '116', 10); // 2026-06-23 (Steve): residual under the 1000 cap after upload 864 + osborne 20. Backfill is a background trickle (~116 SKUs/day) until upload demand drops; raise back toward 500 once new-arrivals goal is met.
+// 'osborne' = the finite Osborne & Little onboard (761 products = ~1522 variants). Steve
+// 2026-06-18: trickle at the SAME polite ~10-products/day cadence as every other vendor — NOT
+// a 148/day blast. 10 products = 20 variants/day (Roll + Sample each). 761 → ~76 days. The big
+// 300 reservation that throttled roll/sample is gone; 20 barely dents the others.
+const OSBORNE_CAP = parseInt(process.env.DW_OSBORNE_CAP || '20', 10); // 20 variants = 10 products/day
+// 'roll' = the roll-build + Bucket-B sample-only recovery writers. DEFAULT 0 (roll is
+// throttled to 0 in both phases so it can't pre-empt the protected upload/backlog slices —
+// the 2026-06-19 upload-priority decision). A one-shot job MAY set DW_ROLL_CAP=N to land a
+// finite, Steve-approved roll cohort (e.g. the 175 Bucket-B priced rolls). Because take('roll')
+// is still clamped to remainingGlobal (BUDGET_TOTAL - spentTotal), a nonzero roll cap can NEVER
+// push the combined daily total past the 1000 ceiling — it only changes the priority order.
+// REVERSIBLE — unset the env (or let the one-shot job exit) and roll returns to 0.
+const ROLL_CAP = parseInt(process.env.DW_ROLL_CAP || '0', 10);
+// 2026-07-02 END-OF-DAY RECLAIM (Steve via DTD 2/2 verdict C): the ledger shows ~60/day expiring
+// unused at midnight (upload spends ~824 of 864, osborne 0 of 20) while the finite 5-field backlog
+// drain is capped at its 116 residual. From RECLAIM_HOUR (21:00) onward, take('backlog') may draw
+// from the GLOBAL unspent remainder instead of only its own cap — upload has had the whole day to
+// use its slice, and un-drawn budget evaporates at the midnight reset anyway. Osborne's unspent
+// floor stays subtracted (protected), and the 40/day testing reserve is outside BUDGET_TOTAL=960
+// entirely, so neither can be reclaimed. Backlog rises ~116→~176/day; runway ~134→~88 days.
+// REVERSIBLE — set DW_BACKLOG_RECLAIM_HOUR=-1 to disable (any value outside 0..23 disables).
+const RECLAIM_HOUR = parseInt(process.env.DW_BACKLOG_RECLAIM_HOUR ?? '21', 10);
+const reclaimActive = () =>
+ RECLAIM_HOUR >= 0 && RECLAIM_HOUR <= 23 && new Date().getHours() >= RECLAIM_HOUR;
+// Guaranteed floor (Steve 2026-06-18): reserve osborne's small daily slice INSIDE the shared
+// ceiling so roll/sample/upload can never draw the budget down past it — the daily 10 is
+// protected, but the reservation is tiny so nothing else is meaningfully throttled. Combined
+// with the 00:05 run-order (osborne fires first after the midnight cap reset, ahead of the
+// non-ledger cap-eaters at 5:30+) the 10/day is a true floor. Defaults to the cap.
+const OSBORNE_FLOOR = parseInt(process.env.DW_OSBORNE_FLOOR || String(OSBORNE_CAP), 10);
+const OSB_OUT = path.join(REPO_ROOT, 'scripts', 'osborne-onboard', 'out');
+// 'maharam' = the finite Maharam quote-only onboard (1,429 sample-only DRAFTS = 1 variant each).
+// TK-00060, DTD 5/5 verdict A (2026-07-27): modeled on 'osborne' — its OWN category + a floor
+// reservation (below) that auto-zeroes when created.jsonl reaches the payload total, so the daily
+// ~100/day trickle can neither be starved by the 5-field 'backlog' drain (the bug it hit) NOR breach
+// the Shopify ~1,000/day cap (clamped to remainingGlobal like every category). 1,429 → ~14 days.
+// TRADE-OFF (contrarian, TK-00060): the ~100-variant floor is subtracted from the global pool, so
+// 'upload' draws up to ~100 fewer variants/day for the ~14-day onboard window (upload is throttled,
+// never zeroed — it still keeps its own cap minus the residual). Self-ends when the reservation zeroes.
+// REVERSIBLE — the reservation self-zeroes on completion; force off via DW_MAHARAM_CAP=0.
+const MAHARAM_CAP = parseInt(process.env.DW_MAHARAM_CAP || '100', 10); // 100 variants = 100 sample-only products/day
+const MAHARAM_FLOOR = parseInt(process.env.DW_MAHARAM_FLOOR || String(MAHARAM_CAP), 10);
+const MAH_OUT = path.join(REPO_ROOT, 'scripts', 'maharam-onboard', 'out');
+
+const today = () => {
+ const d = new Date();
+ return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
+};
+const ledgerFile = () => path.join(LEDGER_DIR, `${today()}.json`);
+
+// ── data-driven phase: is the finite roll backlog still unbuilt? ───────────────
+function phase() {
+ let buildable = null, done = 0;
+ try {
+ const out = execFileSync('psql', ['-tAc',
+ "SELECT count(*) FROM product_map WHERE map_source IN " +
+ "('auth_new_map_2026','auth_new_map_2026_prefix','tier_b_retail_div085','kravet_site_2026') " +
+ "AND map_unit IS NOT NULL"], { env: PGENV, encoding: 'utf8' });
+ buildable = parseInt(out.trim(), 10);
+ } catch (e) { /* DB down → buildable stays null → backfill-phase default */ }
+ try { done = fs.readFileSync(ROLL_DONE, 'utf8').split('\n').filter(Boolean).length; } catch (e) {}
+
+ // remaining unknown (DB down) → default to backfill-phase (favor the always-present
+ // sample backfill; never over-allocate to rolls on a guess).
+ const remaining = (buildable == null) ? 0 : Math.max(0, buildable - done);
+ const rollPhase = remaining > 0;
+ return {
+ rollPhase, rollBacklogRemaining: remaining, buildable, done,
+ // DTD verdict B (3/3, 2026-06-17): trim roll-priority roll 550→400 to free a steady
+ // slice for the finite Osborne revenue onboard. Roll stays the largest share; backlog
+ // ~1–2 days slower. Caps still sum >800 (competition), so to make Osborne's slice a real
+ // floor (not leftover), upload is trimmed in roll-priority so roll+sample+osborne+upload
+ // ≈ ceiling: 400+250+150+ (osborne takes its 150 before upload's residual).
+ // 2026-06-18 rebalance: sample (the open-ended $4.25 backfill) slashed 250→80 / 550→120 so
+ // it yields the rolling-cap headroom to the FINITE jobs (Anna French af-build + uploader).
+ // Restore 250/550 when those backlogs clear.
+ // 2026-06-19 UPLOAD-PRIORITY (Steve): roll/sample throttled to 0 so the 864-variant uploader
+ // 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 }
+ : { roll: ROLL_CAP, sample: 0, upload: UPLOAD_CAP, osborne: OSBORNE_CAP, backlog: BACKLOG_CAP, maharam: MAHARAM_CAP },
+ };
+}
+
+// ── osborne hard-floor reservation (data-driven; auto-reverts on completion) ────
+// The unspent floor, but never more than the ACTUAL remaining osborne work (2 variants/
+// product). Auto-zeroes the day the 761-product onboard finishes → reserved slice hands
+// straight back to roll/sample/upload (the dissent's "never starve on a stale guess" rule).
+function osborneReservation(spentOsborne) {
+ let remainingProducts = 0;
+ try {
+ const total = fs.readFileSync(path.join(OSB_OUT, 'payloads.jsonl'), 'utf8').split('\n').filter(Boolean).length;
+ let created = 0;
+ try { created = fs.readFileSync(path.join(OSB_OUT, 'created.jsonl'), 'utf8').split('\n').filter(Boolean).length; } catch (_) {}
+ remainingProducts = Math.max(0, total - created);
+ } catch (_) { return 0; } // no osborne manifest → reserve nothing
+ return Math.min(Math.max(0, OSBORNE_FLOOR - spentOsborne), remainingProducts * 2);
+}
+
+// ── maharam hard-floor reservation (finite; auto-reverts on completion) ────────
+// Same shape as osborneReservation but 1 variant/product (sample-only). The unspent floor,
+// never more than the ACTUAL remaining maharam work. Auto-zeroes the day the 1,429-draft
+// onboard finishes → the reserved slice hands straight back to the pool (no calendar guess).
+function maharamReservation(spentMaharam) {
+ let remainingProducts = 0;
+ try {
+ const total = fs.readFileSync(path.join(MAH_OUT, 'payloads.jsonl'), 'utf8').split('\n').filter(Boolean).length;
+ let created = 0;
+ try { created = fs.readFileSync(path.join(MAH_OUT, 'created.jsonl'), 'utf8').split('\n').filter(Boolean).length; } catch (_) {}
+ remainingProducts = Math.max(0, total - created);
+ } catch (_) { return 0; } // no maharam manifest → reserve nothing
+ return Math.min(Math.max(0, MAHARAM_FLOOR - spentMaharam), remainingProducts);
+}
+
+// ── mkdir-mutex (atomic create; stale-break after 10s) ─────────────────────────
+function withLock(fn) {
+ fs.mkdirSync(LEDGER_DIR, { recursive: true });
+ const deadline = Date.now() + 3000;
+ for (;;) {
+ try { fs.mkdirSync(LOCK); break; }
+ catch (e) {
+ try { if (Date.now() - fs.statSync(LOCK).mtimeMs > 10000) { fs.rmdirSync(LOCK); continue; } } catch (_) {}
+ if (Date.now() > deadline) break; // give up waiting → proceed (fail-open); risk is tiny over-grant
+ execFileSync('sleep', ['0.05']);
+ }
+ }
+ try { return fn(); } finally { try { fs.rmdirSync(LOCK); } catch (_) {} }
+}
+
+function load() {
+ try {
+ const j = JSON.parse(fs.readFileSync(ledgerFile(), 'utf8'));
+ if (j.date === today()) {
+ j.spent = j.spent || {};
+ // backfill keys missing from pre-upload-category ledger files (avoid NaN arithmetic)
+ 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;
+ return j;
+ }
+ } catch (e) {}
+ return { date: today(), spent: { roll: 0, sample: 0, upload: 0, osborne: 0, backlog: 0, maharam: 0 } };
+}
+function save(j) {
+ const tmp = `${ledgerFile()}.tmp-${process.pid}`;
+ fs.writeFileSync(tmp, JSON.stringify(j, null, 2));
+ fs.renameSync(tmp, ledgerFile());
+}
+
+/**
+ * take(who, n) -> integer granted (0..n). Bounded by both the per-phase cap for
+ * `who` AND the global daily ceiling. Records the grant. Fail-open on any error.
+ */
+function take(who, n) {
+ if (!VALID.has(who)) throw new Error(`budget.take: who must be ${[...VALID].join('|')}, got ${who}`);
+ n = Math.max(0, parseInt(n, 10) || 0);
+ if (!n) return 0;
+ try {
+ 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;
+ 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
+ // against the full remaining (it owns the reservation).
+ if (who !== 'osborne') remainingGlobal = Math.max(0, remainingGlobal - osborneReservation(j.spent.osborne));
+ // Same finite-floor protection for maharam: its unspent reserved slice is subtracted from
+ // what every OTHER category may draw, so the ~100/day trickle can't be starved. Maharam
+ // itself draws against the full remaining (it owns its reservation), exactly like osborne.
+ if (who !== 'maharam') remainingGlobal = Math.max(0, remainingGlobal - maharamReservation(j.spent.maharam));
+ 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;
+ const grant = Math.min(n, remainingWho, remainingGlobal);
+ if (grant > 0) { j.spent[who] += grant; save(j); }
+ return grant;
+ });
+ } catch (e) {
+ process.stderr.write(`[budget] WARN fail-open (${e.message}) — granting ${n} for ${who}\n`);
+ return n; // never freeze a live pipeline on a ledger fault
+ }
+}
+
+/**
+ * refund(who, n) -> integer actually returned (0..n). The inverse of take():
+ * returns an UNUSED grant to the ledger. Used when a caller took() up front to
+ * size a batch but did not consume the whole grant — e.g. the cadence wrapper
+ * grants 36 variants to size --max, but a Shopify-429 / exit-3 slot creates 0
+ * products (refund 36) or a partial slot creates fewer (refund the remainder).
+ * Floored at j.spent[who] so a double-refund or bogus over-refund can NEVER push
+ * spent below 0 (which would synthesize budget that was never granted). Same
+ * mkdir-mutex as take(). Fail-CLOSED: on a ledger fault it no-ops (leaves the
+ * already-recorded debit in place) rather than fabricating headroom.
+ */
+function refund(who, n) {
+ if (!VALID.has(who)) throw new Error(`budget.refund: who must be ${[...VALID].join('|')}, got ${who}`);
+ n = Math.max(0, parseInt(n, 10) || 0);
+ if (!n) return 0;
+ try {
+ return withLock(() => {
+ const j = load();
+ const back = Math.min(n, j.spent[who]); // never refund more than is actually spent → spent stays >= 0
+ if (back > 0) { j.spent[who] -= back; save(j); }
+ return back;
+ });
+ } catch (e) {
+ process.stderr.write(`[budget] WARN refund fail-closed (${e.message}) — no-op for ${who}\n`);
+ return 0; // a failed refund leaves the debit in place — conservative (never over-grants)
+ }
+}
+
+/**
+ * remaining(who) -> integer headroom for `who` right now (min of per-phase cap
+ * and global ceiling), WITHOUT consuming. Use to gate a loop whose creates may
+ * no-op/skip internally (consume with take() only on a real creation). Fail-open
+ * returns Infinity so a ledger fault never freezes a live pipeline.
+ */
+function remaining(who) {
+ if (!VALID.has(who)) throw new Error(`budget.remaining: who must be ${[...VALID].join('|')}, got ${who}`);
+ try {
+ 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;
+ 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 === 'backlog' && reclaimActive()) return globalLeft;
+ return Math.min(Math.max(0, ph.caps[who] - j.spent[who]), globalLeft);
+ });
+ } catch (e) { return Infinity; }
+}
+
+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;
+ 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 othersGlobal = Math.max(0, globalLeft - osborneReserved - maharamReserved); // what roll/sample/upload/backlog may draw
+ return {
+ date: j.date, budgetTotal: BUDGET_TOTAL,
+ phase: ph.rollPhase ? 'roll-priority' : 'backfill-priority',
+ rollBacklogRemaining: ph.rollBacklogRemaining, buildable: ph.buildable, rollsDone: ph.done,
+ osborneFloor: OSBORNE_FLOOR, osborneReserved,
+ maharamFloor: MAHARAM_FLOOR, maharamReserved,
+ 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),
+ sample: Math.min(Math.max(0, ph.caps.sample - j.spent.sample), othersGlobal),
+ 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)),
+ maharam: Math.min(Math.max(0, ph.caps.maharam - j.spent.maharam), Math.max(0, globalLeft - osborneReserved)),
+ global: globalLeft },
+ };
+}
+
+module.exports = { take, refund, remaining, status, phase, BUDGET_TOTAL };
+
+// ── CLI: lets the bash/JS sample-split pipeline integrate without requiring() ──
+if (require.main === module) {
+ const [cmd, a, b] = process.argv.slice(2);
+ if (cmd === 'take') process.stdout.write(String(take(a, b)) + '\n');
+ else if (cmd === 'refund') process.stdout.write(String(refund(a, b)) + '\n');
+ else if (cmd === 'remaining') process.stdout.write(String(remaining(a)) + '\n');
+ else if (cmd === 'phase') process.stdout.write(JSON.stringify(phase(), null, 2) + '\n');
+ else if (cmd === 'status' || !cmd) process.stdout.write(JSON.stringify(status(), null, 2) + '\n');
+ else { process.stderr.write('usage: budget.cjs [status|phase|take <who> <n>|refund <who> <n>|remaining <who>]\n'); process.exit(1); }
+}
← 7cbc2df budget.cjs: add finite 'stout' category (mirrors maharam TK-
·
back to Designerwallcoverings
·
chore: v0.1.3 (session close — finite stout budget category 5cf8a90 →