[object Object]

← back to Fix Live Board

fix-live-board: generalized broken→fixed live board (source-agnostic probe + gated fixer launch + always-on watcher); flock = reference impl

b6cba65931ebf4cac428106d117b5b8ae3e92bed · 2026-09-02 13:44:17 -0700 · Steve

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkjHuJKiariXaTtFujZxWC

Files touched

Diff

commit b6cba65931ebf4cac428106d117b5b8ae3e92bed
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 2 13:44:17 2026 -0700

    fix-live-board: generalized broken→fixed live board (source-agnostic probe + gated fixer launch + always-on watcher); flock = reference impl
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01KkjHuJKiariXaTtFujZxWC
---
 .gitignore               |   6 +++
 README.md                |  33 +++++++++++++++
 boardctl.mjs             |  46 ++++++++++++++++++++
 jobs/demo.json           |  14 ++++++
 jobs/flock.json          |  21 +++++++++
 probes/demo.mjs          |   8 ++++
 probes/shopify-flock.mjs |  68 +++++++++++++++++++++++++++++
 public/board.html        | 106 ++++++++++++++++++++++++++++++++++++++++++++++
 run.mjs                  |  21 +++++++++
 scaffold.mjs             |  41 ++++++++++++++++++
 server.js                | 108 +++++++++++++++++++++++++++++++++++++++++++++++
 watcher.mjs              |  24 +++++++++++
 12 files changed, 496 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e065140
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+.env*
+.token
+.runtime/
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..90afd59
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# fix-live-board
+
+Generalized live **"broken → fixed"** board for any fix job — the reusable pattern lifted from
+`flock-fix-viewer`. A job supplies a **source-agnostic probe** (any command that prints JSON rows);
+the board derives per-field pass/fail chips + a fixed% progress bar and polls live so cards turn
+green as fixers land. Basic auth `admin / DW2024!` on an OS-assigned free port.
+
+## Row shape (what a probe prints — a JSON array)
+```json
+[{ "id":"A-001", "title":"…", "sku":"A-001", "price":172.5, "img":null,
+   "created":"2026-09-01T10:15:00Z", "status":"ACTIVE",
+   "fields": { "price": true, "tags": false }, "fixed": false }]
+```
+`fixed` is optional — if omitted, a row is fixed when every non-`warn` field is truthy.
+
+## Job config (`jobs/<id>.json`)
+- `probe` — shell command printing the JSON rows (run from `cwd` or the project root)
+- `fields[]` — `{ key, ok, bad, warn? }` chips, in display order
+- `fixers[]` — `{ id, label, cmd, cwd?, launch }` — the board can **launch** a fixer only when
+  `launch: true`; otherwise it renders **gated** (run it manually). Every launch is appended to
+  `~/.claude/yolo-queue/executed-reversible/ledger.jsonl`.
+- `watch` — set `false` to exclude from the always-on auto-spinner (default: watched)
+
+## Use
+```
+node run.mjs --list          # registered jobs + which boards are live
+node run.mjs flock           # spin (or re-attach) the flock board, print its URL
+node watcher.mjs             # one auto-spin sweep (installed as a launchd job)
+```
+
+Scaffold a new job with the `fix-live-board` skill: `/fix-live-board <name>`.
+
+Reference impl: `jobs/flock.json` + `probes/shopify-flock.mjs` (live DW Shopify, read-only).
diff --git a/boardctl.mjs b/boardctl.mjs
new file mode 100644
index 0000000..e8c3eea
--- /dev/null
+++ b/boardctl.mjs
@@ -0,0 +1,46 @@
+// Shared board control: spawn a detached board for a job, and report whether one is live.
+import fs from 'fs';
+import path from 'path';
+import { spawn, spawnSync } from 'child_process';
+import { fileURLToPath } from 'url';
+
+export const ROOT = path.dirname(fileURLToPath(import.meta.url));
+const RT = path.join(ROOT, '.runtime');
+
+export function alive(pid) { if (!pid) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
+
+export function boardStatus(jobId) {
+  const pidF = path.join(RT, jobId + '.pid'), portF = path.join(RT, jobId + '.port');
+  const pid = fs.existsSync(pidF) ? parseInt(fs.readFileSync(pidF, 'utf8'), 10) : 0;
+  const port = fs.existsSync(portF) ? fs.readFileSync(portF, 'utf8').trim() : '';
+  return { pid, port, up: alive(pid) && !!port };
+}
+
+export function spawnBoard(jobId) {
+  const s = boardStatus(jobId);
+  if (s.up) return { ...s, started: false, url: 'http://127.0.0.1:' + s.port + '/' };
+  const log = fs.openSync(path.join(RT, jobId + '.log'), 'a');
+  const child = spawn('node', ['server.js'], { cwd: ROOT, env: { ...process.env, JOB: 'jobs/' + jobId + '.json' }, detached: true, stdio: ['ignore', log, log] });
+  child.unref();
+  // wait up to ~5s for the server to write its port file
+  const portF = path.join(RT, jobId + '.port');
+  const before = fs.existsSync(portF) ? fs.statSync(portF).mtimeMs : 0;
+  for (let i = 0; i < 50; i++) {
+    if (fs.existsSync(portF) && fs.statSync(portF).mtimeMs >= before) { break; }
+    spawnSync('sleep', ['0.1']);
+  }
+  const port = fs.existsSync(portF) ? fs.readFileSync(portF, 'utf8').trim() : '';
+  return { pid: child.pid, port, up: !!port, started: true, url: port ? 'http://127.0.0.1:' + port + '/' : null };
+}
+
+// Count how many rows are "still to follow" (not fixed) for a job, by running its probe once.
+export function brokenCount(jobId) {
+  const jobPath = path.join(ROOT, 'jobs', jobId + '.json');
+  const job = JSON.parse(fs.readFileSync(jobPath, 'utf8'));
+  const required = (job.fields || []).filter(f => !f.warn).map(f => f.key);
+  const cwd = job.cwd ? path.resolve(ROOT, job.cwd) : ROOT;
+  const r = spawnSync('/bin/sh', ['-c', job.probe], { cwd, encoding: 'utf8', timeout: job.probeTimeoutMs || 30000, env: process.env });
+  let rows = []; try { rows = JSON.parse(r.stdout); } catch (e) { return { ok: false, err: 'probe parse: ' + String(e).slice(0, 120) + (r.stderr ? ' | ' + r.stderr.slice(0, 120) : ''), total: 0, broken: 0 }; }
+  const fixed = rows.filter(row => typeof row.fixed === 'boolean' ? row.fixed : (required.length ? required.every(k => !!(row.fields || {})[k]) : false)).length;
+  return { ok: true, total: rows.length, broken: rows.length - fixed };
+}
diff --git a/jobs/demo.json b/jobs/demo.json
new file mode 100644
index 0000000..7beebb9
--- /dev/null
+++ b/jobs/demo.json
@@ -0,0 +1,14 @@
+{
+  "id": "demo",
+  "name": "Demo Fix Board",
+  "blurb": "Static demo proving the board is source-agnostic (no external calls).",
+  "probe": "node probes/demo.mjs",
+  "watch": false,
+  "fields": [
+    { "key": "price", "ok": "has price", "bad": "no price" },
+    { "key": "tags", "ok": "tags ok", "bad": "bad tags" },
+    { "key": "image", "ok": "image", "bad": "no image" },
+    { "key": "min", "ok": "min set", "bad": "no min", "warn": true }
+  ],
+  "fixers": []
+}
diff --git a/jobs/flock.json b/jobs/flock.json
new file mode 100644
index 0000000..3ba3d4f
--- /dev/null
+++ b/jobs/flock.json
@@ -0,0 +1,21 @@
+{
+  "id": "flock",
+  "name": "Flock Fix",
+  "blurb": "Phillipe Romano flocked velvet line — quotes-tag removed, real roll price, buyable.",
+  "ticket": "",
+  "watch": true,
+  "probe": "node probes/shopify-flock.mjs",
+  "probeTimeoutMs": 45000,
+  "fields": [
+    { "key": "quotes", "ok": "no quote tag", "bad": "QUOTE tag" },
+    { "key": "price", "ok": "has price", "bad": "no price" },
+    { "key": "badge", "ok": "badge ok", "bad": "54\"/yard badge" },
+    { "key": "buyable", "ok": "buyable", "bad": "not buyable" },
+    { "key": "min", "ok": "min set", "bad": "no min", "warn": true },
+    { "key": "width", "ok": "width set", "bad": "no width", "warn": true },
+    { "key": "colorway", "ok": "colorway ok", "bad": "colorway: rename", "warn": true }
+  ],
+  "fixers": [
+    { "id": "priceup", "label": "Price up + strip quotes (flock)", "cmd": "node priceup-flock.mjs --apply", "cwd": "../flock-fix-viewer", "launch": false, "blast": "≤41 flock SKUs", "undo": "node reverse-june16.mjs (snapshots in priceup-snapshots/)" }
+  ]
+}
diff --git a/probes/demo.mjs b/probes/demo.mjs
new file mode 100644
index 0000000..08c5c68
--- /dev/null
+++ b/probes/demo.mjs
@@ -0,0 +1,8 @@
+#!/usr/bin/env node
+// Static demo probe — proves the board is source-agnostic with zero external dependency.
+// Emits a JSON array of rows: {id,title,sku,price,created,status,fields:{...}}
+process.stdout.write(JSON.stringify([
+  { id: 'A-001', title: 'Fully fixed item', sku: 'A-001', price: 172.5, created: '2026-09-01T10:15:00Z', status: 'ACTIVE', fields: { price: true, tags: true, image: true, min: true } },
+  { id: 'A-002', title: 'Missing price + min', sku: 'A-002', price: 0, created: '2026-09-02T08:40:00Z', status: 'DRAFT', fields: { price: false, tags: true, image: true, min: false } },
+  { id: 'A-003', title: 'Bad tags only', sku: 'A-003', price: 149, created: '2026-09-02T12:05:00Z', status: 'ACTIVE', fields: { price: true, tags: false, image: true, min: true } }
+]));
diff --git a/probes/shopify-flock.mjs b/probes/shopify-flock.mjs
new file mode 100644
index 0000000..5152918
--- /dev/null
+++ b/probes/shopify-flock.mjs
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+// Flock probe — the reference impl. Ports flock-fix-viewer's live Shopify derivation into the
+// generalized row shape. Reads the live DW store (designer-laboratory-sandbox). READ-ONLY.
+// Token resolution: env SHOPIFY_ADMIN_TOKEN -> secrets-manager/.env -> flock-fix-viewer/.token
+import https from 'https';
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+function token() {
+  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN.trim();
+  try {
+    const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
+    const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m); if (m) return m[1].trim();
+  } catch (e) {}
+  return fs.readFileSync(path.join(os.homedir(), 'Projects/flock-fix-viewer/.token'), 'utf8').trim();
+}
+const TOKEN = token();
+const QUERY = "vendor:'Phillipe Romano' status:active (tag:'Flock Velvet' OR handle:flock OR title:flock)";
+const PRODUCT_Q = `query($c:String){
+  products(first:100, query:${JSON.stringify(QUERY)}, after:$c){
+    pageInfo{hasNextPage endCursor}
+    edges{node{ handle title status createdAt featuredImage{url} tags
+      variants(first:8){edges{node{sku title price}}}
+      mMin: metafield(namespace:"global", key:"v_prods_quantity_order_min"){value}
+      mWidth: metafield(namespace:"global", key:"width"){value}
+    }}
+  }
+}`;
+function gql(query, variables) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables: variables || {} });
+    const req = https.request({ host: SHOP, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
+      res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); });
+    req.on('error', reject); req.write(body); req.end();
+  });
+}
+(async () => {
+  let cur = null, all = [];
+  do {
+    const r = await gql(PRODUCT_Q, { c: cur });
+    const p = r && r.data && r.data.products; if (!p) break;
+    all = all.concat(p.edges);
+    cur = p.pageInfo.hasNextPage ? p.pageInfo.endCursor : null;
+  } while (cur);
+  const rows = all.map(e => {
+    const n = e.node, tags = n.tags || [];
+    const hasQuotes = tags.some(t => String(t).replace(/["{}]/g, '').trim().toLowerCase() === 'quotes');
+    const vs = (n.variants.edges || []).map(v => v.node);
+    const roll = vs.filter(v => !/sample/i.test(v.title || ''));
+    const rollPrice = roll.reduce((m, v) => Math.max(m, parseFloat(v.price) || 0), 0);
+    const sku = (roll[0] && roll[0].sku) || (vs[0] && vs[0].sku) || '?';
+    const isDup = n.handle.startsWith('copy-of-') || /-\d+$/.test(n.handle);
+    const hasPrice = rollPrice > 10;
+    return {
+      id: sku, sku, handle: n.handle, title: n.title, status: n.status, price: rollPrice,
+      img: (n.featuredImage && n.featuredImage.url) || null, created: n.createdAt,
+      fields: { quotes: !hasQuotes, price: hasPrice, badge: !hasQuotes,
+        buyable: !hasQuotes && hasPrice && n.status === 'ACTIVE',
+        min: !!(n.mMin && n.mMin.value), width: !!(n.mWidth && n.mWidth.value),
+        colorway: !isDup },
+      fixed: !hasQuotes && hasPrice
+    };
+  });
+  process.stdout.write(JSON.stringify(rows));
+})().catch(e => { process.stderr.write(String(e)); process.exit(1); });
diff --git a/public/board.html b/public/board.html
new file mode 100644
index 0000000..8cf738c
--- /dev/null
+++ b/public/board.html
@@ -0,0 +1,106 @@
+<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Fix Live Board</title><style>
+:root{--bg:#0e0f13;--card:#171922;--line:#252838;--txt:#e7e9f0;--mut:#8b90a6;--ok:#25c26e;--bad:#ff5c6c;--warn:#f2b134;--accent:#7c8cff}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}
+header{position:sticky;top:0;background:linear-gradient(#0e0f13,#0e0f13ee);padding:14px 20px;border-bottom:1px solid var(--line);z-index:5}
+h1{margin:0;font-size:17px;letter-spacing:.3px;font-weight:600}
+.sub{color:var(--mut);font-size:12px;margin-top:3px}
+.bar{height:8px;background:var(--line);border-radius:9px;margin-top:10px;overflow:hidden}
+.bar>i{display:block;height:100%;background:linear-gradient(90deg,#25c26e,#7c8cff);width:0;transition:width .5s}
+.controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-top:10px}
+.controls label{color:var(--mut);font-size:12px}
+select,input[type=range]{background:var(--card);color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:5px 8px}
+.grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:12px;padding:16px 20px}
+.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
+.card.fixed{border-color:#1f7a48;box-shadow:0 0 0 1px #1f7a4855 inset}
+.thumb{height:130px;background:#0b0c10 center/cover no-repeat;border-bottom:1px solid var(--line)}
+.body{padding:10px 11px;display:flex;flex-direction:column;gap:7px}
+.ttl{font-size:12.5px;font-weight:600;line-height:1.25;max-height:2.5em;overflow:hidden}
+.meta{display:flex;justify-content:space-between;color:var(--mut);font-size:11px}
+.price{color:#dfe3ff;font-weight:600}
+.chips{display:flex;flex-wrap:wrap;gap:5px}
+.chip{font-size:10.5px;padding:2px 7px;border-radius:999px;border:1px solid var(--line);display:inline-flex;gap:4px;align-items:center}
+.chip.ok{color:var(--ok);border-color:#1f7a48;background:#0f2a1c}
+.chip.bad{color:var(--bad);border-color:#5a2730;background:#2a1216}
+.chip.warn{color:var(--warn);border-color:#5a4a1e;background:#2a2410}
+.when{color:var(--mut);font-size:10.5px}
+.pill{font-size:10px;padding:1px 7px;border-radius:6px;border:1px solid var(--line)}
+.dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:5px}
+.err{color:var(--bad);padding:14px 20px}
+.fixers{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
+.fx{font-size:12px;padding:5px 10px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--txt);cursor:pointer}
+.fx.gated{color:var(--mut);cursor:not-allowed;border-style:dashed}
+.fx:hover:not(.gated){border-color:var(--accent)}
+</style></head><body>
+<header>
+  <h1 id="h1">🛠️ Fix Live Board</h1>
+  <div class="sub" id="sub">loading…</div>
+  <div class="bar"><i id="prog"></i></div>
+  <div class="controls">
+    <span><label>Sort</label>
+      <select id="sort">
+        <option value="broken">Broken first</option>
+        <option value="fixed">Fixed first</option>
+        <option value="id">ID</option>
+        <option value="price">Price ↓</option>
+        <option value="created">Newest</option>
+      </select></span>
+    <span><label>Density</label> <input type="range" id="dens" min="2" max="6" value="3"></span>
+    <span class="sub" id="upd"></span>
+  </div>
+  <div class="fixers" id="fixers"></div>
+</header>
+<div class="grid" id="grid"></div>
+<div class="err" id="err" style="display:none"></div>
+<script>
+let META={fields:[],fixers:[],name:'Fix Live Board'}, DATA=[];
+const g=document.getElementById('grid');
+const KEY='flb_'+location.port; // per-board localStorage namespace
+function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+function chip(f,val){var ok=!!val;var c=ok?'ok':(f.warn?'warn':'bad');var t=ok?(f.ok||f.key):(f.bad||('no '+f.key));return '<span class="chip '+c+'"><span class="dot" style="background:currentColor"></span>'+esc(t)+'</span>';}
+function fmtWhen(iso){if(!iso)return '';try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch(e){return iso}}
+function render(){
+  const s=document.getElementById('sort').value;
+  let d=DATA.slice();
+  const rank=r=>r.fixed?1:0;
+  if(s==='broken')d.sort((a,b)=>rank(a)-rank(b)||String(a.id).localeCompare(String(b.id)));
+  else if(s==='fixed')d.sort((a,b)=>rank(b)-rank(a)||String(a.id).localeCompare(String(b.id)));
+  else if(s==='id')d.sort((a,b)=>String(a.id).localeCompare(String(b.id)));
+  else if(s==='price')d.sort((a,b)=>(b.price||0)-(a.price||0));
+  else if(s==='created')d.sort((a,b)=>new Date(b.created||0)-new Date(a.created||0));
+  g.innerHTML=d.map(function(r){
+    const fields=(r.fields||{});
+    const chips=META.fields.map(f=>chip(f,fields[f.key])).join('');
+    return '<div class="card '+(r.fixed?'fixed':'')+'">'
+      +(r.img?'<div class="thumb" style="background-image:url(\''+esc(r.img)+'\')"></div>':'')
+      +'<div class="body">'
+      +'<div class="ttl" title="'+esc(r.title||r.id)+'">'+esc(r.title||r.id)+'</div>'
+      +'<div class="meta"><span>'+esc(r.sku||r.id)+'</span><span class="price">'+(r.price>0?('$'+Number(r.price).toFixed(2)):'—')+'</span></div>'
+      +'<div class="chips">'+(r.status?'<span class="pill">'+esc(r.status)+'</span>':'')+chips+'</div>'
+      +(r.created?'<div class="when" title="'+esc(r.created)+'">🕓 '+fmtWhen(r.created)+'</div>':'')
+      +'</div></div>';
+  }).join('');
+  const fixed=DATA.filter(r=>r.fixed).length;
+  document.getElementById('sub').textContent=DATA.length+' items · '+fixed+' fixed · '+(DATA.length-fixed)+' still to follow';
+  document.getElementById('prog').style.width=(DATA.length?(fixed/DATA.length*100):0)+'%';
+}
+function renderFixers(){
+  document.getElementById('fixers').innerHTML=(META.fixers||[]).map(f=>
+    '<button class="fx '+(f.launch?'':'gated')+'" data-id="'+esc(f.id)+'" '+(f.launch?'':'disabled title="Gated — run this fixer manually"')+'>'+esc(f.label||f.id)+(f.launch?'':' · gated')+'</button>').join('');
+  document.querySelectorAll('.fx:not(.gated)').forEach(b=>b.addEventListener('click',async()=>{
+    b.disabled=true;b.textContent='running…';
+    try{const r=await fetch('api/fix?id='+encodeURIComponent(b.dataset.id),{method:'POST'});const j=await r.json();b.textContent=(j.ok?'✓ ':'✗ ')+(b.dataset.id);}catch(e){b.textContent='error';}
+    setTimeout(poll,1200);setTimeout(()=>{renderFixers();},4000);
+  }));
+}
+function setCols(v){document.documentElement.style.setProperty('--cols',v);try{localStorage.setItem(KEY+'_cols',v);}catch(e){}}
+document.getElementById('dens').addEventListener('input',e=>setCols(e.target.value));
+document.getElementById('sort').addEventListener('change',()=>{try{localStorage.setItem(KEY+'_sort',document.getElementById('sort').value);}catch(e){}render();});
+(function(){try{const c=localStorage.getItem(KEY+'_cols');if(c){document.getElementById('dens').value=c;setCols(c);}else setCols(3);const s=localStorage.getItem(KEY+'_sort');if(s)document.getElementById('sort').value=s;}catch(e){setCols(3);}})();
+async function loadMeta(){try{META=await(await fetch('api/meta')).json();document.getElementById('h1').textContent='🛠️ '+META.name;document.title=META.name;renderFixers();}catch(e){}}
+async function poll(){try{const j=await(await fetch('api/status')).json();const err=document.getElementById('err');
+  if(j.err){err.style.display='block';err.textContent='probe error: '+j.err;}else{err.style.display='none';}
+  DATA=j.rows||[];render();document.getElementById('upd').textContent='updated '+new Date().toLocaleTimeString();
+}catch(e){document.getElementById('upd').textContent='refresh error';}}
+loadMeta();poll();setInterval(poll,8000);
+</script></body></html>
diff --git a/run.mjs b/run.mjs
new file mode 100644
index 0000000..bcd3043
--- /dev/null
+++ b/run.mjs
@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+// Manual launcher: `node run.mjs <jobId>` spins (or re-attaches) that job's board and prints its URL.
+// `node run.mjs --list` lists registered jobs + live boards.
+import fs from 'fs';
+import path from 'path';
+import { spawnBoard, boardStatus, brokenCount, ROOT } from './boardctl.mjs';
+
+const arg = process.argv[2];
+if (!arg || arg === '--list') {
+  const jobs = fs.readdirSync(path.join(ROOT, 'jobs')).filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
+  console.log('Registered jobs:');
+  for (const j of jobs) { const s = boardStatus(j); console.log('  ' + j.padEnd(16) + (s.up ? 'LIVE  http://127.0.0.1:' + s.port + '/' : 'stopped')); }
+  console.log('\nUsage: node run.mjs <jobId>   (admin / DW2024!)');
+  process.exit(0);
+}
+if (!fs.existsSync(path.join(ROOT, 'jobs', arg + '.json'))) { console.error('No such job: ' + arg); process.exit(1); }
+const bc = brokenCount(arg);
+const r = spawnBoard(arg);
+if (!r.url) { console.error('Board failed to start — see .runtime/' + arg + '.log'); process.exit(1); }
+console.log((r.started ? 'Started' : 'Already live') + ' board [' + arg + '] → ' + r.url + '  (admin / DW2024!)');
+if (bc.ok) console.log('  ' + bc.total + ' items · ' + bc.broken + ' still to follow');
diff --git a/scaffold.mjs b/scaffold.mjs
new file mode 100644
index 0000000..fe40e1d
--- /dev/null
+++ b/scaffold.mjs
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+// Scaffold a new fix-live-board job:  node scaffold.mjs <slug> "<Display Name>"
+// Writes jobs/<slug>.json (template) + probes/<slug>.mjs (stub) if absent, then prints next steps.
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+const ROOT = path.dirname(fileURLToPath(import.meta.url));
+
+const slug = (process.argv[2] || '').trim().toLowerCase().replace(/[^a-z0-9\-]+/g, '-').replace(/^-+|-+$/g, '');
+const name = (process.argv[3] || slug).trim();
+if (!slug) { console.error('Usage: node scaffold.mjs <slug> "<Display Name>"'); process.exit(1); }
+
+const jobPath = path.join(ROOT, 'jobs', slug + '.json');
+const probePath = path.join(ROOT, 'probes', slug + '.mjs');
+if (fs.existsSync(jobPath)) { console.error('Job already exists: ' + jobPath); process.exit(1); }
+
+const job = {
+  id: slug, name, blurb: 'TODO: one-line description of the fix set.', watch: true,
+  probe: 'node probes/' + slug + '.mjs', probeTimeoutMs: 30000,
+  fields: [
+    { key: 'field_a', ok: 'a ok', bad: 'a missing' },
+    { key: 'field_b', ok: 'b ok', bad: 'b missing', warn: true }
+  ],
+  fixers: []
+};
+const probeStub = `#!/usr/bin/env node
+// Probe for "${name}" — print a JSON array of rows to stdout. READ-ONLY.
+// Row: {id,title,sku,price,img,created,status,fields:{field_a:bool,field_b:bool},fixed?:bool}
+// 'fixed' is optional; if omitted a row is fixed when every non-warn field is true.
+const rows = [
+  // TODO: query your real source (Shopify / Postgres / API / file) and map each item to a row.
+  { id: 'TODO-1', title: 'example', sku: 'TODO-1', price: 0, created: new Date().toISOString(), status: '', fields: { field_a: false, field_b: false } }
+];
+process.stdout.write(JSON.stringify(rows));
+`;
+fs.writeFileSync(jobPath, JSON.stringify(job, null, 2) + '\n');
+if (!fs.existsSync(probePath)) fs.writeFileSync(probePath, probeStub);
+console.log('✓ Scaffolded job [' + slug + ']');
+console.log('  jobs/' + slug + '.json   — edit fields[] + fixers[]');
+console.log('  probes/' + slug + '.mjs  — make it query your real source');
+console.log('\nThen:  node run.mjs ' + slug + '   (the watcher will also auto-spin it while it has items to follow)');
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..2d00fdc
--- /dev/null
+++ b/server.js
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+// fix-live-board — GENERALIZED live "broken -> fixed" board (generalized from flock-fix-viewer).
+// One board per JOB. A job supplies a source-agnostic `probe` command that prints a JSON array
+// of rows; the board derives per-field pass/fail chips + an overall fixed% progress bar, and
+// polls live so cards turn green as reversible/ledgered fixers land. Basic auth admin/DW2024!.
+//
+// Run:  JOB=jobs/flock.json node server.js       (usually launched via run.mjs / watcher.mjs)
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const ROOT = __dirname;
+const JOB_PATH = path.resolve(ROOT, process.env.JOB || 'jobs/demo.json');
+const job = JSON.parse(fs.readFileSync(JOB_PATH, 'utf8'));
+const JOB_ID = job.id || path.basename(JOB_PATH).replace(/\.json$/, '');
+const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
+const REALM = (job.name || JOB_ID).replace(/[^\w \-]/g, '');
+const FIELDS = job.fields || [];
+// A row is "fixed" when the probe says so, else when every non-warn field is truthy.
+const REQUIRED = FIELDS.filter(f => !f.warn).map(f => f.key);
+function isFixed(row) {
+  if (typeof row.fixed === 'boolean') return row.fixed;
+  const f = row.fields || {};
+  return REQUIRED.length ? REQUIRED.every(k => !!f[k]) : false;
+}
+
+// --- probe: run the job's command, expect a JSON array on stdout ---
+let cache = { at: 0, rows: [], err: null };
+function runProbe() {
+  return new Promise(resolve => {
+    const cwd = job.cwd ? path.resolve(ROOT, job.cwd) : ROOT;
+    const p = spawn('/bin/sh', ['-c', job.probe], { cwd, env: process.env });
+    let out = '', err = '';
+    const to = setTimeout(() => { try { p.kill('SIGKILL'); } catch (e) {} }, (job.probeTimeoutMs || 30000));
+    p.stdout.on('data', c => out += c);
+    p.stderr.on('data', c => err += c);
+    p.on('close', () => {
+      clearTimeout(to);
+      try {
+        const rows = JSON.parse(out);
+        resolve({ rows: Array.isArray(rows) ? rows.map(r => ({ ...r, fixed: isFixed(r) })) : [], err: null });
+      } catch (e) {
+        resolve({ rows: [], err: 'probe parse error: ' + String(e).slice(0, 200) + (err ? ' | stderr: ' + err.slice(0, 200) : '') });
+      }
+    });
+    p.on('error', e => { clearTimeout(to); resolve({ rows: [], err: 'probe spawn error: ' + String(e) }); });
+  });
+}
+async function fetchStatus() {
+  if (Date.now() - cache.at < (job.throttleMs || 4000) && (cache.rows.length || cache.err)) return cache;
+  const r = await runProbe();
+  cache = { at: Date.now(), rows: r.rows, err: r.err };
+  return cache;
+}
+
+// --- gated fixer launch: ONLY commands the job explicitly declares with launch:true fire here ---
+function ledger(entry) {
+  try {
+    const line = JSON.stringify({ ts: new Date().toISOString(), agent: 'fix-live-board', ...entry }) + '\n';
+    fs.appendFileSync(path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl'), line);
+  } catch (e) {}
+}
+function launchFixer(id) {
+  return new Promise(resolve => {
+    const fx = (job.fixers || []).find(f => f.id === id);
+    if (!fx) return resolve({ ok: false, msg: 'unknown fixer' });
+    if (!fx.launch) return resolve({ ok: false, msg: 'GATED — run this fixer manually (launch not enabled for this job)' });
+    const cwd = fx.cwd ? path.resolve(ROOT, fx.cwd) : (job.cwd ? path.resolve(ROOT, job.cwd) : ROOT);
+    const p = spawn('/bin/sh', ['-c', fx.cmd], { cwd, env: process.env });
+    let out = '', err = '';
+    p.stdout.on('data', c => out += c); p.stderr.on('data', c => err += c);
+    p.on('close', code => {
+      ledger({ ticket: job.ticket || '', action: 'fix-live-board launch fixer ' + id + ' (' + JOB_ID + ')', blast_radius: fx.blast || 'job-declared', undo_cmd: fx.undo || 'see job fixer', verify: 'board reflects on next poll', exit: code });
+      resolve({ ok: code === 0, code, out: out.slice(-4000), err: err.slice(-2000) });
+    });
+    p.on('error', e => resolve({ ok: false, msg: String(e) }));
+  });
+}
+
+const PAGE = fs.readFileSync(path.join(ROOT, 'public', 'board.html'), 'utf8');
+const server = http.createServer(async (req, res) => {
+  if ((req.headers.authorization || '') !== AUTH) {
+    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="' + REALM + '"' }); return res.end('auth required');
+  }
+  const u = req.url || '/';
+  if (u.startsWith('/api/meta')) {
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    return res.end(JSON.stringify({ id: JOB_ID, name: job.name || JOB_ID, blurb: job.blurb || '', fields: FIELDS, fixers: (job.fixers || []).map(f => ({ id: f.id, label: f.label, launch: !!f.launch })) }));
+  }
+  if (u.startsWith('/api/status')) {
+    const s = await fetchStatus();
+    res.writeHead(s.err ? 200 : 200, { 'Content-Type': 'application/json' });
+    return res.end(JSON.stringify({ rows: s.rows, err: s.err, at: s.at }));
+  }
+  if (u.startsWith('/api/fix') && req.method === 'POST') {
+    const id = new URL(u, 'http://x').searchParams.get('id');
+    const r = await launchFixer(id);
+    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(r));
+  }
+  res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(PAGE);
+});
+server.listen(0, '127.0.0.1', () => {
+  const port = server.address().port;
+  fs.writeFileSync(path.join(ROOT, '.runtime', JOB_ID + '.port'), String(port));
+  fs.writeFileSync(path.join(ROOT, '.runtime', JOB_ID + '.pid'), String(process.pid));
+  console.log('FIX-LIVE-BOARD [' + JOB_ID + '] http://127.0.0.1:' + port + '  (admin / DW2024!)');
+});
diff --git a/watcher.mjs b/watcher.mjs
new file mode 100644
index 0000000..65f91d5
--- /dev/null
+++ b/watcher.mjs
@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+// Always-on auto-spinner. For every registered job with "watch" != false: run its probe, and if it
+// has items still to follow AND no board is live, spin one up. Boards that reach 100% are left
+// running so the finished state stays visible. Writes a heartbeat for later fleet-health use.
+// Installed as launchd com.steve.fix-live-board-watcher (StartInterval; reversible: bootout + rm).
+import fs from 'fs';
+import path from 'path';
+import { spawnBoard, boardStatus, brokenCount, ROOT } from './boardctl.mjs';
+
+const jobs = fs.readdirSync(path.join(ROOT, 'jobs')).filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
+const summary = { ts: new Date().toISOString(), jobs: [] };
+let spun = 0;
+for (const id of jobs) {
+  let job; try { job = JSON.parse(fs.readFileSync(path.join(ROOT, 'jobs', id + '.json'), 'utf8')); } catch (e) { continue; }
+  if (job.watch === false) { summary.jobs.push({ id, watch: false }); continue; }
+  const bc = brokenCount(id);
+  let s = boardStatus(id);
+  if (bc.ok && bc.broken > 0 && !s.up) { const r = spawnBoard(id); s = { pid: r.pid, port: r.port, up: r.up }; if (r.started) spun++; }
+  summary.jobs.push({ id, total: bc.total, broken: bc.broken, probeOk: bc.ok, err: bc.err || null, up: s.up, port: s.port || null, url: s.port ? 'http://127.0.0.1:' + s.port + '/' : null });
+}
+summary.spunThisRun = spun;
+summary.status = summary.jobs.some(j => j.probeOk === false) ? 'WARN' : 'PASS';
+fs.writeFileSync(path.join(ROOT, '.runtime', 'watcher-status.json'), JSON.stringify(summary, null, 2));
+console.log('[fix-live-board-watcher] ' + summary.ts + ' spun=' + spun + ' ' + summary.jobs.filter(j => j.watch !== false).map(j => j.id + ':' + (j.up ? j.port : 'off') + '(' + (j.broken ?? '?') + ' to follow)').join(' '));

(oldest)  ·  back to Fix Live Board  ·  add last5 job: last-5-days new-products go-live board (live 5f15a19 →