← Commit history

Publish 0.19.0

Colby Knox ·787164c18d ·2mo ago ·parent 2b3390a
7 files changed +172−7
SKILL.md+21
@@ -29,6 +29,27 @@ one). GET /state also carries `resolved` {title, headline, subtitle, *Source}: the exact strings the surface is showing and where each came from (live-wiki | fixture | override), so a driving AI never verifies via pixels. +## Bulk heroes: the human gates the STYLE once (0.19)++For fleets of similar pages (300 component pages, platform twins), the gate+scales by gating the STYLE, not every part:++1. Pose ONE representative part; the human walks the wizard and Generates.+2. `hero-studio style save <name>`: snapshots the pose style (layout, theme,+   type, badges, brand) WITH that human approval as its gate. Refused if the+   session holds no approval.+3. `hero-studio batch --style <name> --parts parts.json --pace 5 --human+   <who> --quote "<their words>"`: applies the style to every part (each with+   its own title and render), generating locally, paced. Every export's+   provenance records the style, the gate that minted it, and the+   attestation, so a batch hero is always traceable to the human who set the+   look. parts.json rows: {ref, title, kind?, vitrine?: <render path>,+   headline?, subtitle?}.++Generation is local (no wiki calls, no rate limits). The publish loop is+where pacing matters: publish serially or 2-3 at a time and let the 429+hints set the pace if they appear.+ ## Automated mode: same-session repeats only  The FIRST Generate of a page each session is always clicked by the human in
bin/hero-studio+1−1
@@ -11,7 +11,7 @@ fi case "$1" in   pose) shift; exec node "$DIR/pose.mjs" "$@" ;;   doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;;-  vitrine|set|preset|fit|state|toast|reroll|changes|bg|compose|ls|instances|stop) exec node "$DIR/drive.mjs" "$@" ;;+  vitrine|set|preset|fit|style|batch|state|toast|reroll|changes|bg|compose|ls|instances|stop) exec node "$DIR/drive.mjs" "$@" ;;   start) shift; exec node "$DIR/server.js" "$@" ;;   -h|--help|help)     # hero-studio#36: --help used to fall through to the server catch-all and
data/pages.json+30
@@ -1,4 +1,34 @@ [+ {+  "type": "hardware",+  "title": "Ztest Batch B",+  "author": "",+  "score": 0,+  "stars": 0,+  "installs30d": 0,+  "brief": "",+  "tags": [],+  "updated_at": "2026-08-13T17:52:52.448Z",+  "aiThread": "hero wizard dogfood",+  "owner": "adom",+  "slug": "ztest-batch-b",+  "kind": "component"+ },+ {+  "type": "hardware",+  "title": "Ztest Batch A",+  "author": "",+  "score": 0,+  "stars": 0,+  "installs30d": 0,+  "brief": "",+  "tags": [],+  "updated_at": "2026-08-13T17:52:43.635Z",+  "aiThread": "hero wizard dogfood",+  "owner": "adom",+  "slug": "ztest-batch-a",+  "kind": "component"+ },  {   "type": "app",   "title": "COSMIIC Implantables",
drive.mjs+64−1
@@ -183,6 +183,69 @@ try {     if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n  hint: ' + out.hint : ''));     console.log(JSON.stringify(out));     console.error('fit requested; read state.fontSize.title in about a second');+  } else if (verb === 'style') {+    // style save <name> | style list -- the human-gated template for bulk+    const sub = args.find((a) => !a.startsWith('--'));+    if (sub === 'list' || !sub) {+      console.log(JSON.stringify(await jf('styles'), null, 2));+    } else if (sub === 'save') {+      const name = args[args.indexOf('save') + 1];+      if (!name) die('usage: hero-studio style save <name>   (after a human-approved Generate)');+      const out = await jf('style', { name });+      if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n  hint: ' + out.hint : ''));+      console.log(JSON.stringify(out));+      console.error('style "' + out.name + '" saved, gated by the ' + out.gate.page + ' approval; batch with: hero-studio batch --style ' + out.name + ' --parts <parts.json>');+    } else die('usage: hero-studio style save <name> | style list');+  } else if (verb === 'batch') {+    // The bulk half of the once-through-the-gate model: apply a human-gated+    // style to many parts, generating each locally (no wiki calls, no rate+    // limits here); --pace spaces the renders.+    const si = args.indexOf('--style');+    const pi2 = args.indexOf('--parts');+    if (si < 0 || pi2 < 0) die('usage: hero-studio batch --style <name> --parts <parts.json> [--pace SECS] [--human <who>] [--quote "their words"]\n  parts.json: [{"ref":"adom/x","title":"X","vitrine":"render.png","kind":"component"}, ...]');+    const styleName = args[si + 1];+    const fsm = await import('fs');+    const parts = JSON.parse(fsm.readFileSync(args[pi2 + 1], 'utf8'));+    const pace = Number(args[args.indexOf('--pace') + 1]) || 5;+    const human = args.includes('--human') ? args[args.indexOf('--human') + 1] : '';+    const quote = args.includes('--quote') ? args[args.indexOf('--quote') + 1] : '';+    if (!human || !quote) die('the batch carries the human\'s attestation: --human <who approved the style> --quote "their approval words"');+    const styles = await jf('styles');+    const st2 = (styles.styles || []).find((x) => x.name === styleName);+    if (!st2) die('unknown style "' + styleName + '"; hero-studio style list shows saved ones');+    const stylePatch = JSON.parse(fsm.readFileSync((await import('os')).homedir() + '/.adom/instances/hero-studio/styles/' + styleName + '.json', 'utf8')).style;+    const results = [];+    console.error('batch: ' + parts.length + ' part(s), style "' + styleName + '" (gated by ' + st2.gate.page + '), pace ' + pace + 's');+    for (let i = 0; i < parts.length; i++) {+      const part = parts[i];+      try {+        await jf('api/pages', { owner: part.ref.split('/')[0], slug: part.ref.split('/')[1], title: part.title, type: part.type || 'hardware', kind: part.kind });+        await jf('state', Object.assign({}, stylePatch, { page: part.ref, headline: part.headline || '', subtitle: part.subtitle || '', vitrine: Object.assign({ src: '', full: false }, stylePatch.vitrine || {}) }));+        if (part.vitrine) {+          const v = await jf('vitrine-file', { path: path.resolve(part.vitrine), cover: !!(stylePatch.vitrine || {}).cover, chrome: (stylePatch.vitrine || {}).chrome || '' });+          if (!v.ok) throw new Error('vitrine: ' + v.error);+        }+        const g = await jf('generate', { attest: { style: styleName, human, quote } });+        if (!g.ok) throw new Error(g.error);+        // wait for the finalize (poll state._gen)+        let done = null;+        for (let w = 0; w < 60 && !done; w++) {+          await new Promise((r) => setTimeout(r, 1000));+          const stt = await jf('state');+          if (stt._gen && stt._gen.ok && stt._gen.file && (!results.length || stt._gen.file !== results[results.length - 1].file)) done = stt._gen;+        }+        if (!done) throw new Error('generate did not finish in 60s');+        results.push({ ref: part.ref, ok: true, file: done.file });+        console.error('  [' + (i + 1) + '/' + parts.length + '] ' + part.ref + ' -> ' + done.file);+      } catch (e) {+        results.push({ ref: part.ref, ok: false, error: e.message });+        console.error('  [' + (i + 1) + '/' + parts.length + '] ' + part.ref + ' FAILED: ' + e.message);+      }+      if (i < parts.length - 1) await new Promise((r) => setTimeout(r, pace * 1000));+    }+    console.log(JSON.stringify({ style: styleName, results }, null, 1));+    const bad = results.filter((r) => !r.ok).length;+    console.error('batch done: ' + (results.length - bad) + ' ok, ' + bad + ' failed; PNGs in generated/ (copy each to its part\'s docs/hero.png before publish)');   } else if (verb === 'instances') {     // every live studio on this box (#26): scan the port neighborhood     const ports = new Set([PORT]);@@ -220,7 +283,7 @@ try {     console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length }));     console.error('composed preview written (no provenance stamp; the pose gate + Generate remain the only publish path)');   } else {-    die('verbs: pose | vitrine | set | preset | fit | state | toast | reroll | changes | bg | compose | ls | instances | stop');+    die('verbs: pose | vitrine | set | preset | fit | style | batch | state | toast | reroll | changes | bg | compose | ls | instances | stop');   } } catch (e) {   die('ERROR: ' + e.message + '\n  hint: is the studio server up? `hero-studio` starts it; `hero-studio pose <ref> --surface ...` does everything');
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.18.4",+  "version": "0.19.0",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.18.4",+  "version": "0.19.0",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [
server.js+54−3
@@ -390,6 +390,44 @@ function route(req, res, url, p) {     res.writeHead(200, { 'Content-Type': mime, 'Content-Length': buf.length, 'Cache-Control': 'public, max-age=31536000, immutable' });     return res.end(buf);   }+  if (p === '/style' && req.method === 'POST') {+    // Save the CURRENT pose style as a reusable template. Only allowed when+    // this session holds a human-approved generate: the approval IS the+    // style's gate, and it travels with every batch export made from it.+    const name = (req._body && req._body.name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 40);+    if (!name) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'style needs a name' })); }+    if (!state.approved) {+      res.writeHead(200, { 'Content-Type': 'application/json' });+      return res.end(JSON.stringify({ ok: false, error: 'no human-approved generate in this session to gate the style',+        hint: 'pose a representative part, let the human walk the gate and click Generate, then save the style: the approval is what makes the template reusable' }));+    }+    const style = {+      layout: state.layout, theme: state.theme, gradAngle: state.gradAngle,+      titleMode: state.titleMode, fontSize: state.fontSize, overlay: state.overlay,+      brand: state.brand, show: state.show, guides: false,+      vitrine: { chrome: (state.vitrine || {}).chrome || '', cover: !!(state.vitrine || {}).cover },+      mode: state.mode, comp: state.mode === 'component' ? { imgSrc: state.comp.imgSrc, layout: state.comp.layout, mpn: state.comp.mpn } : undefined,+      circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined,+    };+    const rec = { name, style, gate: Object.assign({}, state.approved, { thread: AI_THREAD, savedAt: new Date().toISOString() }) };+    const dir = path.join(INSTANCES_DIR, 'styles');+    fs.mkdirSync(dir, { recursive: true });+    fs.writeFileSync(path.join(dir, name + '.json'), JSON.stringify(rec, null, 1));+    logLine('info', 'style saved: ' + name + ' (gated by the ' + state.approved.page + ' approval)');+    res.writeHead(200, { 'Content-Type': 'application/json' });+    return res.end(JSON.stringify({ ok: true, name, gate: rec.gate }));+  }+  if (p === '/styles' && req.method === 'GET') {+    const dir = path.join(INSTANCES_DIR, 'styles');+    const rows = [];+    try {+      for (const f of fs.readdirSync(dir)) {+        try { const r = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); rows.push({ name: r.name, gate: r.gate }); } catch {}+      }+    } catch {}+    res.writeHead(200, { 'Content-Type': 'application/json' });+    return res.end(JSON.stringify({ styles: rows }));+  }   if (p === '/instances' && req.method === 'GET') {     // the live-studio roster for the wizard's Package step: one studio per     // page/thread is the multi-package model, so show the siblings@@ -768,9 +806,22 @@ function route(req, res, url, p) {         return refuse('Generate is human-gated: the human clicks it in the studio',           'a human must generate this page first (hero-studio pose opens the gate). To RE-generate a page the human already generated this session, POST /generate with attest: {human: "<who approved>", quote: "<their actual words>"}');       }-      if (!(state.approved && state.approved.page === state.page)) {+      let styleGate = null;+      if (attest.style) {+        // Batch path (bulk hardware): a saved style carries the human gate+        // that minted it; applying that style to another part inherits the+        // gate, recorded per export.+        try {+          styleGate = JSON.parse(fs.readFileSync(path.join(INSTANCES_DIR, 'styles', String(attest.style).toLowerCase().replace(/[^a-z0-9-]+/g, '-') + '.json'), 'utf8'));+        } catch {}+        if (!styleGate || !styleGate.gate) {+          return refuse('unknown style "' + attest.style + '" (or it carries no human gate)',+            'save one first: pose a representative part, let the human Generate, then hero-studio style save <name>');+        }+        req._styleGate = styleGate;+      } else if (!(state.approved && state.approved.page === state.page)) {         return refuse('automated Generate is only allowed for a page a human already generated this session',-          'pose ' + state.page + ' and let the human click Generate once; after that, attested re-generates of the same page are allowed');+          'pose ' + state.page + ' and let the human click Generate once (attest re-generates of that page), or apply a human-gated style template: attest {style: "<name>", human, quote}');       }       if (!attest.human || !String(attest.quote || '').trim()) {         return refuse('the attestation must name the human and quote their actual approval words',@@ -871,7 +922,7 @@ function route(req, res, url, p) {         return finish({ ok: false, error: 'could not read the rendered PNG: ' + e.message, hint: "run 'hero-studio doctor'" });       }       // downscale to the hero standard 2000x1250 and stamp provenance metadata-      const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });+      const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });       const py = 'import sys\n' +         'from PIL import Image\n' +         'from PIL.PngImagePlugin import PngInfo\n' +