@homie-rocks/studio 0.49.0 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/homie-studio.mjs +5 -5
  3. package/functions/FUNCTIONS.md +71 -1
  4. package/guides/app/SKILL.md +130 -1
  5. package/guides/art/SKILL.md +154 -0
  6. package/guides/art/references/RIGHTS.md +20 -0
  7. package/guides/art/scripts/art.mjs +308 -0
  8. package/guides/models/SKILL.md +198 -0
  9. package/guides/models/references/RIGHTS.md +46 -0
  10. package/guides/models/references/models.json +175 -0
  11. package/guides/models/scripts/models.mjs +483 -0
  12. package/guides/music/SKILL.md +197 -0
  13. package/guides/music/references/COMPOSITION.md +59 -0
  14. package/guides/music/references/GAME-AUDIO.md +50 -0
  15. package/guides/music/references/RIGHTS.md +43 -0
  16. package/guides/music/scripts/lib/audio.mjs +258 -0
  17. package/guides/music/scripts/lib/eleven.mjs +260 -0
  18. package/guides/music/scripts/lib/studio.mjs +147 -0
  19. package/guides/music/scripts/music.mjs +644 -0
  20. package/guides/office/SKILL.md +99 -0
  21. package/guides/plan/SKILL.md +126 -0
  22. package/guides/plan/references/CODEX.md +105 -0
  23. package/guides/plan/references/INTERVIEW.md +86 -0
  24. package/guides/playtest/SKILL.md +205 -0
  25. package/guides/playtest/references/METHOD.md +124 -0
  26. package/guides/playtest/references/REVIEWER.md +78 -0
  27. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  28. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  29. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  30. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  31. package/guides/playtest/scripts/playtest.mjs +870 -0
  32. package/guides/servers/SKILL.md +111 -0
  33. package/guides/shop/SKILL.md +4 -0
  34. package/guides/sound/SKILL.md +156 -0
  35. package/guides/sound/references/GAME-SOUND.md +100 -0
  36. package/guides/sound/references/SCORE.md +131 -0
  37. package/guides/sound/references/TRAPS.md +93 -0
  38. package/guides/sound/scripts/lib/measure.mjs +252 -0
  39. package/guides/sound/scripts/lib/score.mjs +415 -0
  40. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  41. package/guides/sound/scripts/lib/synth.mjs +509 -0
  42. package/guides/sound/scripts/sound.mjs +520 -0
  43. package/guides/style/SKILL.md +105 -0
  44. package/lib/app-needs.mjs +63 -0
  45. package/lib/check.mjs +10 -4
  46. package/lib/codex.mjs +95 -18
  47. package/lib/decisions.mjs +9 -7
  48. package/lib/functions-build.mjs +10 -0
  49. package/lib/handoff.mjs +1 -1
  50. package/lib/mcp-tools.mjs +10 -9
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/scaffold.mjs +15 -7
  53. package/lib/studio.mjs +3 -3
  54. package/lib/template-history.json +84 -0
  55. package/lib/worker-config.mjs +1 -1
  56. package/package.json +1 -1
  57. package/shop/SHOP.md +4 -0
  58. package/shop/shop.ts +4 -4
  59. package/tools/TOOLS.md +21 -0
  60. package/tools/tools.ts +1 -1
  61. package/worker/build-id.mjs +1 -1
  62. package/worker/event-resources.mjs +116 -0
  63. package/worker/functions-schema.mjs +15 -0
  64. package/worker/functions.mjs +36 -22
  65. package/worker/index.mjs +23 -3
  66. package/worker/mcp-tools.mjs +14 -3
  67. package/worker/mcp.mjs +45 -8
  68. package/worker/shop-page.mjs +1 -1
  69. package/worker/shop.mjs +6 -2
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,63 @@
1
+ /** Opt-in acceptance evidence, sourced from the person's Codex, not starter labels. */
2
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+
6
+ export function readAppNeeds(root, id) {
7
+ if (!root) return null;
8
+ const file = join(root, 'apps', id, 'CODEX.md');
9
+ if (!existsSync(file)) return null;
10
+ const source = readFileSync(file, 'utf8');
11
+ const block = /^```needs\s*\n([\s\S]*?)^```/m.exec(source);
12
+ if (!block) return null; // An existing prose Codex has not opted into executable needs.
13
+ const needs = JSON.parse(block[1]);
14
+ if (!Array.isArray(needs) || !needs.length) throw new Error('App Codex has no demonstrable needs yet');
15
+ const ids = new Set();
16
+ for (const n of needs) {
17
+ if (!n || !/^[a-zA-Z0-9_-]+$/.test(n.id ?? '') || ids.has(n.id) || typeof n.statement !== 'string' || !n.statement.trim()) throw new Error('Each Codex need requires a unique id and a statement from the request');
18
+ ids.add(n.id);
19
+ }
20
+ return { needs, hash: createHash('sha256').update(source).digest('hex') };
21
+ }
22
+
23
+ /** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
24
+ export async function proveAppNeeds({ plan, surfaces, shots, report }) {
25
+ const results = [];
26
+ const surface = name => { const s = surfaces.find(s => s.name === name); if (!s) throw new Error(`unknown surface ${name}`); return s; };
27
+ for (const need of plan.needs) {
28
+ const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
29
+ try {
30
+ if (!Array.isArray(need.assertions) || !['wall', 'phone'].every(name => need.assertions.some(a => a.surface === name))) throw new Error('needs wall and phone assertions');
31
+ for (const step of need.steps ?? []) {
32
+ const { frame, page } = surface(step.surface);
33
+ if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.__shell?.link?.state === 'online'); surface(step.surface).frame = page.frames().find(f => /\/__game\//.test(f.url())); continue; }
34
+ await frame.waitForSelector(step.selector, { visible: true });
35
+ if (step.click) await frame.click(step.selector);
36
+ else if (step.fill !== undefined) { await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }
37
+ else if (step.select !== undefined) await frame.select(step.selector, String(step.select));
38
+ else throw new Error('step needs click, fill, select or reload');
39
+ }
40
+ for (const assertion of need.assertions) {
41
+ const { frame } = surface(assertion.surface);
42
+ await frame.waitForSelector(assertion.selector, { visible: true, timeout: 10000 });
43
+ if (assertion.text !== undefined) await frame.waitForFunction((selector, text) => document.querySelector(selector)?.textContent.includes(text), { timeout: 10000 }, assertion.selector, String(assertion.text));
44
+ }
45
+ result.status = shots ? 'met' : 'partly';
46
+ result.why = shots ? 'UI assertions passed; inspect captures against the pictured scene' : 'Assertions passed but no captures saved';
47
+ } catch (error) { result.why = error.message; }
48
+ if (shots) for (const name of ['wall', 'phone']) {
49
+ const file = join(shots, `${need.id}-${name}.jpg`);
50
+ try { await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
51
+ catch (error) { result.status = 'partly'; result.why += `; capture failed: ${error.message}`; }
52
+ }
53
+ report?.check?.(`need-${need.id}`, result.status === 'met' ? 'pass' : 'fail', { label: need.statement, note: result.why });
54
+ results.push(result);
55
+ }
56
+ return results;
57
+ }
58
+
59
+ export function saveAppNeeds(root, id, plan, needs) {
60
+ if (!root) return;
61
+ const dir = join(root, '.studio', 'checks'); mkdirSync(dir, { recursive: true });
62
+ writeFileSync(join(dir, `${id}-needs.json`), JSON.stringify({ at: new Date().toISOString(), codexHash: plan.hash, needs }, null, 2));
63
+ }
package/lib/check.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
1
2
  /**
2
3
  * `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
3
4
  * when nobody says: lib/check-site.mjs; never the live site unless asked): two fresh
@@ -127,7 +128,7 @@ export function judgeRounds(browsers, missed = new Map()) {
127
128
  return null;
128
129
  }
129
130
 
130
- export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
131
+ export async function check({ root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
131
132
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
132
133
  const base = String(url).replace(/\/+$/, '');
133
134
  const play = `${base}/${game}/play`;
@@ -148,7 +149,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
148
149
  if (!chrome) return { ok: false, command: 'check', why: noChrome() };
149
150
  let puppeteer;
150
151
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
151
- if (appMeta) return checkApp({ url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
152
+ if (appMeta) return checkApp({ root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
152
153
  if (shots) mkdirSync(shots, { recursive: true });
153
154
  const profiles = [];
154
155
  const browsers = [];
@@ -343,7 +344,9 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
343
344
  }
344
345
 
345
346
  /** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
346
- export async function checkApp({ url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
347
+ export async function checkApp({ root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
348
+ let plan;
349
+ try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
347
350
  const proof = meta.check;
348
351
  if (!proof || typeof proof.action !== 'string' || typeof proof.observe !== 'string') return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
349
352
  if (shots) mkdirSync(shots, { recursive: true });
@@ -390,7 +393,10 @@ export async function checkApp({ url, game, meta, shots = null, log = () => {},
390
393
  if (shots) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });
391
394
  }
392
395
  report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
393
- return { ok: true, command: 'check', kind: 'app', room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: true };
396
+ const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report }) : null;
397
+ if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
398
+ const ok = !needs || needs.every(n => n.status === 'met');
399
+ return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: true };
394
400
  } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
395
401
  finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
396
402
  }
package/lib/codex.mjs CHANGED
@@ -68,6 +68,15 @@ export const SECTIONS = [
68
68
  { key: 'questions', title: 'Open questions', match: /^(open questions|questions|undecided|to decide)\b/i },
69
69
  ];
70
70
 
71
+ export const APP_SECTIONS = [
72
+ { key: 'dream', title: 'Their words', match: /^their words|^dream/i },
73
+ { key: 'scene', title: 'Scene', match: /^scene/i },
74
+ { key: 'people', title: 'People and surfaces', match: /^people/i },
75
+ { key: 'offers', title: 'Offers', match: /^offers/i },
76
+ { key: 'needs', title: 'Needs', match: /^needs/i },
77
+ ...SECTIONS.filter(s => ['art','sound','milestones','questions'].includes(s.key)),
78
+ ];
79
+
71
80
  /* ------------------------------------------------------------------ parsing */
72
81
 
73
82
  /** A small YAML subset: `key: value`, quoted or bare, and one level of nested `key:` blocks or `{ a: b, c: d }`. */
@@ -130,7 +139,7 @@ export function parseCodex(source) {
130
139
  while (seen.has(id)) id = `${id}-2`;
131
140
  seen.add(id);
132
141
  s.id = id;
133
- s.key = SECTIONS.find((k) => k.match.test(s.title))?.key ?? (/^latest|^decisions|^changelog|^news/i.test(s.title) ? 'latest' : null);
142
+ s.key = [...SECTIONS, ...APP_SECTIONS].find((k) => k.match.test(s.title))?.key ?? (/^latest|^decisions|^changelog|^news/i.test(s.title) ? 'latest' : null);
134
143
  }
135
144
  return { meta, title, pitch: pitch.join('\n').trim(), sections };
136
145
  }
@@ -139,7 +148,7 @@ export function parseCodex(source) {
139
148
  export function codexGaps(parsed) {
140
149
  const has = (key) => parsed.sections.some((s) => s.key === key && s.body.join('\n').replace(/<!--[\s\S]*?-->/g, '').trim());
141
150
  const questions = parsed.sections.filter((s) => s.key === 'questions').flatMap((s) => s.body.filter((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)));
142
- return { missing: SECTIONS.filter((s) => s.key !== 'questions' && !has(s.key)).map((s) => s.title), openQuestions: questions.length };
151
+ return { missing: (parsed.meta.kind === 'app' ? APP_SECTIONS : SECTIONS).filter((s) => s.key !== 'questions' && !has(s.key)).map((s) => s.title), openQuestions: questions.length };
143
152
  }
144
153
 
145
154
  /* ------------------------------------------------------------------ files from the studio */
@@ -303,6 +312,16 @@ ${stats.length ? `<dl class="stats">${stats.map(([k, v]) => `<dt>${esc(k)}</dt><
303
312
  }
304
313
 
305
314
  function sectionHtml(section, emb, ctx) {
315
+ if (section.key === 'needs') {
316
+ const source = section.body.join('\n');
317
+ const block = /^```needs\s*\n([\s\S]*?)^```/m.exec(source);
318
+ if (block) {
319
+ try {
320
+ const needs = JSON.parse(block[1]);
321
+ if (Array.isArray(needs)) return `<section class="tab" id="${esc(section.id)}" data-key="needs"><h2>${esc(section.title)}</h2>${renderMarkdown(source.replace(block[0], '')).html}<div class="cards">${needs.map(n => `<article class="card"><h3>${esc(n.id)}</h3><p>${esc(n.statement)}</p><p class="meta">${Array.isArray(n.assertions) && n.assertions.length ? 'To demonstrate on the wall and phone' : 'Not yet demonstrable'}</p></article>`).join('')}</div></section>`;
322
+ } catch { /* Keep malformed source visible for correction. */ }
323
+ }
324
+ }
306
325
  const intro = [];
307
326
  const cards = [];
308
327
  let current = null;
@@ -510,13 +529,13 @@ function lookOf(root, game, meta, emb) {
510
529
  * sit in games/<id>/ before its game.json does: then the "game" is that planned folder. `create`: a codex is being
511
530
  * started for an id with no folder yet.
512
531
  */
513
- function codexGame(root, id, { create = false } = {}) {
532
+ function codexGame(root, id, { create = false, kind = null } = {}) {
514
533
  const game = listGames(root).find((g) => g.id === id);
515
534
  if (game) return game;
516
535
  if (!GAME_ID.test(String(id ?? ''))) return null;
517
- const dir = join(experienceDir(root, id));
536
+ const dir = kind === 'app' ? join(root, 'apps', id) : experienceDir(root, id);
518
537
  if (existsSync(join(dir, 'game.json'))) return null;
519
- return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, name: null, planned: true } : null;
538
+ return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, kind: dir === join(root, 'apps', id) ? 'app' : 'game', name: null, planned: true } : null;
520
539
  }
521
540
 
522
541
  /**
@@ -529,9 +548,10 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
529
548
  const game = codexGame(root, id);
530
549
  if (!game) throw new Error(`no game "${id}" in games/, and no codex planning it`);
531
550
  const file = join(game.dir, CODEX_FILE);
532
- if (!existsSync(file)) throw new Error(`games/${id}/${CODEX_FILE} does not exist yet: npx --no-install homie-studio codex new ${id}`);
533
- if (statSync(file).size > LIMITS.source) throw new Error(`games/${id}/${CODEX_FILE} is over ${LIMITS.source / 1024} KB`);
551
+ if (!existsSync(file)) throw new Error(`${relative(root, file)} does not exist yet: npx --no-install homie-studio codex new ${id}`);
552
+ if (statSync(file).size > LIMITS.source) throw new Error(`${relative(root, file)} is over ${LIMITS.source / 1024} KB`);
534
553
  const parsed = parseCodex(readFileSync(file, 'utf8'));
554
+ if (game.kind === 'app') parsed.meta.kind = 'app';
535
555
  const { meta } = parsed;
536
556
  let studio = {};
537
557
  try { studio = readStudio(root); } catch { studio = {}; }
@@ -555,7 +575,17 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
555
575
  tabs.splice(sections[0]?.key === 'latest' ? 1 : 0, 0, statusTab);
556
576
  const cover = typeof meta.cover === 'string' ? emb.image(meta.cover) : null;
557
577
  const pitch = parsed.pitch ? renderMarkdown(parsed.pitch).html : (game.blurb ? `<p>${esc(game.blurb)}</p>` : '');
558
- const body = sections.map((x) => sectionHtml(x, emb, ctx)).join('\n');
578
+ const needReportFile = join(root, '.studio', 'checks', `${id}-needs.json`);
579
+ let needStatus = '';
580
+ if (game.kind === 'app' && existsSync(needReportFile)) {
581
+ try {
582
+ const r = JSON.parse(readFileSync(needReportFile, 'utf8'));
583
+ const stale = r.codexHash !== createHash('sha256').update(readFileSync(file, 'utf8')).digest('hex');
584
+ tabs.push('<a href="#needs-status">Needs status</a>');
585
+ needStatus = `<section class="tab" id="needs-status"><h2>Needs status</h2><p>${stale ? 'Plan changed: this evidence is stale; check again. ' : ''}Checked ${esc(r.at)}. Screen assertions are evidence; review the captures against Their words.</p>${r.needs.map(n => `<article><h3>${esc(n.statement)}</h3><p><strong>${stale ? 'not yet rechecked' : esc(n.status)}</strong> — ${esc(n.why ?? '')}</p>${(n.captures ?? []).map(p => figure(emb, n.statement, relative(root, resolve(root, p)), p)).join('')}</article>`).join('')}</section>`;
586
+ } catch { /* The next check repairs an incomplete receipt. */ }
587
+ }
588
+ const body = needStatus + sections.map((x) => sectionHtml(x, emb, ctx)).join('\n');
559
589
  const status = statusHtml(s, ctx);
560
590
  const start = s?.state === 'running' ? 'build-status' : (sections[0]?.id ?? 'build-status');
561
591
  const tagline = typeof meta.tagline === 'string' ? meta.tagline : '';
@@ -567,13 +597,13 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
567
597
  <meta charset="utf-8">
568
598
  <meta name="viewport" content="width=device-width, initial-scale=1">
569
599
  <meta name="robots" content="noindex, nofollow">
570
- <title>${esc(title)} · Game Codex</title>
600
+ <title>${esc(title)} · ${game.kind === 'app' ? 'App' : 'Game'} Codex</title>
571
601
  ${look.links.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
572
602
  <style>${look.faces.join('')}${css(look.palette, look.fonts, ctx.pixel)}</style>
573
603
  </head>
574
604
  <body data-mode="${esc(mode)}" data-start="${esc(start)}"${ctx.pixel ? ' class="pixel"' : ''}>
575
605
  <header class="hero">${cover ? `<div class="bgimg" style="background-image:url(${cover})"></div>` : ''}<div class="wrap">
576
- <p class="kicker">Game Codex · ${esc(studio.name ?? '')}</p>
606
+ <p class="kicker">${game.kind === 'app' ? 'App' : 'Game'} Codex · ${esc(studio.name ?? '')}</p>
577
607
  <h1>${esc(title)}</h1>
578
608
  ${tagline ? `<p class="tagline">${esc(tagline)}</p>` : ''}
579
609
  ${pitch ? `<div class="pitch">${pitch}</div>` : ''}
@@ -583,12 +613,12 @@ ${pitch ? `<div class="pitch">${pitch}</div>` : ''}
583
613
  ${body}
584
614
  ${status}
585
615
  </main>
586
- <footer>Game Codex for ${esc(title)}: the plan your AI keeps true as decisions change. Source: games/${esc(id)}/${CODEX_FILE}. Drawn ${esc(now.toISOString().slice(0, 16).replace('T', ' '))} UTC. Made with Homie.</footer>
616
+ <footer>${game.kind === 'app' ? 'App' : 'Game'} Codex for ${esc(title)}: the plan your AI keeps true as decisions change. Source: ${esc(relative(root, file))}. Drawn ${esc(now.toISOString().slice(0, 16).replace('T', ' '))} UTC. Made with Homie.</footer>
587
617
  <script>${CODEX_SCRIPT}</script>
588
618
  </body>
589
619
  </html>
590
620
  `;
591
- return { html, title, sections: sections.map((x) => ({ id: x.id, title: x.title, key: x.key })), missing: gaps.missing, openQuestions: gaps.openQuestions, warnings: emb.warnings, build: s ? { state: s.state, percent: s.percent } : null };
621
+ return { html, kind: game.kind ?? 'game', title, sections: sections.map((x) => ({ id: x.id, title: x.title, key: x.key })), missing: gaps.missing, openQuestions: gaps.openQuestions, warnings: emb.warnings, build: s ? { state: s.state, percent: s.percent } : null };
592
622
  }
593
623
 
594
624
  /** `homie-studio codex <id>`: the page on this computer (or the artifact copy). */
@@ -598,7 +628,7 @@ export function writeCodexPage(root, id, { mode = 'file' } = {}) {
598
628
  mkdirSync(dir, { recursive: true });
599
629
  const file = join(dir, mode === 'artifact' ? `${id}.artifact.html` : `${id}.html`);
600
630
  writeFileSync(file, r.html);
601
- return { ok: true, command: 'codex', id, mode, file: relative(root, file), path: file, bytes: Buffer.byteLength(r.html), title: r.title, sections: r.sections, missing: r.missing, openQuestions: r.openQuestions, warnings: r.warnings, build: r.build };
631
+ return { ok: true, command: 'codex', kind: r.kind, id, mode, file: relative(root, file), path: file, bytes: Buffer.byteLength(r.html), title: r.title, sections: r.sections, missing: r.missing, openQuestions: r.openQuestions, warnings: r.warnings, build: r.build };
602
632
  }
603
633
 
604
634
  /** Called whenever a progress feed changes: the local page redraws itself (only when it exists already). */
@@ -632,21 +662,21 @@ export function buildCodexPages(root, dist, ids, { log = () => {} } = {}) {
632
662
  /* ------------------------------------------------------------------ a new codex */
633
663
 
634
664
  /** `homie-studio codex new <id>`: CODEX.md with every section, its look taken from the game and the studio. */
635
- export function newCodex(root, id, { name = null } = {}) {
665
+ export function newCodex(root, id, { name = null, kind = null } = {}) {
636
666
  // A game is planned before it is made: with no game of this id, the codex starts its folder (games/<id>/CODEX.md),
637
667
  // and `game new <id>` later makes the game around it.
638
- const game = codexGame(root, id, { create: true });
668
+ const game = codexGame(root, id, { create: true, kind });
639
669
  if (!game) return { ok: false, command: 'codex new', why: `"${id}" is not a game id (lowercase letters, digits and hyphens, up to 40)` };
640
670
  if (game.planned) { game.name = name ? String(name).slice(0, 60) : id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '); mkdirSync(game.dir, { recursive: true }); }
641
671
  const file = join(game.dir, CODEX_FILE);
642
- if (existsSync(file)) return { ok: false, command: 'codex new', why: `games/${id}/${CODEX_FILE} exists already: change it, never replace it` };
672
+ if (existsSync(file)) return { ok: false, command: 'codex new', why: `${relative(root, file)} exists already: change it, never replace it` };
643
673
  const theme = readTheme(root);
644
674
  const land = game.landing?.theme ?? {};
645
675
  const q = (v) => JSON.stringify(String(v));
646
676
  const cover = ['hero/wide.jpg', 'cover.jpg', 'cover.png', 'public/cover.jpg', 'public/cover.png'].find((p) => existsSync(join(game.dir, p)));
647
677
  const today = new Date().toISOString().slice(0, 10);
648
678
  const players = game.players ? `${game.players.min ?? 1} to ${game.players.max ?? 8}` : '1 to 8';
649
- const text = `---
679
+ let text = `---
650
680
  tagline: ${q(game.blurb ?? '')}
651
681
  ${cover ? `cover: ${cover}\n` : ''}palette:
652
682
  bg: ${q(theme.bg ?? '#0b0c12')}
@@ -721,9 +751,56 @@ ${game.planned ? `- [ ] The plan (this codex)
721
751
  ## Open questions
722
752
 
723
753
  -
754
+ `;
755
+ if (game.kind === 'app') text = `---
756
+ kind: app
757
+ tagline: ""
758
+ try: "Open the wall and phone. Follow a real request through the scene."
759
+ ---
760
+ # ${game.name ?? id}
761
+
762
+ ## Their words
763
+ <!-- Quote the owner's request. Preserve their dream, not your implementation summary. -->
764
+
765
+ ## Scene
766
+ <!-- Walking in: what is on the wall, what moves/fills/arrives/lights up, how it feels. -->
767
+
768
+ ## People and surfaces
769
+ <!-- Each person, what they must do, and what wall, phone, staff and kiosk show them. -->
770
+
771
+ ## Offers
772
+ <!-- What is sold or given, price and currency, once/monthly, and what payment fulfils. -->
773
+
774
+ ## Needs
775
+ <!-- Each statement is observable on a real screen. Fill this executable needs block.
776
+ Each need: id, statement, steps (surface wall/phone/phone-two; click/fill/select and selector/value),
777
+ assertions (surface, selector, optional text). Include wall and phone assertions and real flow steps.
778
+ A missing flow is reported not met. Do not replace an unmet need with an easier one. -->
779
+ \`\`\`needs
780
+ []
781
+ \`\`\`
782
+
783
+ ## Art direction
784
+ <!-- Run style init with the owner's words. Composition, materials, typography and motion. -->
785
+
786
+ ## Music and sound
787
+ <!-- Useful cues, silent wall by default, local synthesis or connected providers. -->
788
+
789
+ ## Built from
790
+ <!-- Engine packages, parts_find results and provenance, original work and media routes. -->
791
+
792
+ ## Milestones
793
+ - [ ] Their dream captured
794
+ - [ ] Their scene and workflows running
795
+ - [ ] Every need demonstrated on wall and phone and visually reviewed
796
+
797
+ ## Latest
798
+ - ${today}: Planned from the person's request. Starter plumbing is not completion.
799
+
800
+ ## Open questions
724
801
  `;
725
802
  writeFileSync(file, text);
726
- return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the request and sensible defaults (interview only when asked), then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`after recording those defaults: npx --no-install homie-studio game new ${id} --from gem-rush --name "<Name>" makes the game around it (the codex stays)`] : [])] };
803
+ return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the request and sensible defaults (interview only when asked), then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`after recording those defaults: npx --no-install homie-studio ${game.kind === 'app' ? 'app' : 'game'} new ${id} --from ${game.kind === 'app' ? 'welcome' : 'gem-rush'} --name "<Name>" makes the game around it (the codex stays)`] : [])] };
727
804
  }
728
805
 
729
806
  /** A sign-in link that opens one game's codex on the live site (the stats page's one-time sign-in, then the codex). */
package/lib/decisions.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  * games/<id>/style.json (public, tiny) carries the palette and fonts a game, its landing and its title cards draw with.
17
17
  */
18
18
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
- import { dirname, join } from 'node:path';
19
+ import { relative, dirname, join } from 'node:path';
20
20
  import { HEX, deltaHex, hexToRgb, luminance, mix, nudge } from './colour.mjs';
21
21
  import { CAMERAS, FAMILIES, FONT_SETS, GENRES, LIGHTS, MOODS, PALETTES, PALETTE_KEYS, RENDERS, SHAPES } from './style-presets.mjs';
22
22
  import { readTheme } from './site.mjs';
@@ -39,7 +39,8 @@ export function contextOf(root, id, { prompt = '' } = {}) {
39
39
  let codex = '';
40
40
  try { codex = readFileSync(join(dir, 'CODEX.md'), 'utf8').replace(/<!--[\s\S]*?-->/g, ''); } catch { codex = ''; }
41
41
  let game = {};
42
- try { game = JSON.parse(readFileSync(join(dir, 'game.json'), 'utf8')); } catch { game = {}; }
42
+ try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
43
+ const kind = existsSync(join(dir, 'app.json')) || /^kind: app$/m.test(codex) ? 'app' : 'game';
43
44
  const fm = /^---\n([\s\S]*?)\n---/.exec(codex)?.[1] ?? '';
44
45
  const pal = {};
45
46
  for (const k of [...PALETTE_KEYS, 'panel']) { const m = new RegExp(`^\\s+${k}:\\s*"?(#[0-9a-fA-F]{3,6})"?`, 'm').exec(fm); if (m) pal[k] = m[1]; }
@@ -50,9 +51,9 @@ export function contextOf(root, id, { prompt = '' } = {}) {
50
51
  for (const [key, re] of [['characters', '(?:characters|cast|heroes|creatures|enemies)'], ['items', '(?:items|props|pickups|collectibles)'], ['places', '(?:world|places|setting|zones?|levels?)']]) {
51
52
  for (const m of section(re).matchAll(/^###\s+(.+?)\s*$/gm)) cards.push({ section: key, title: clean(m[1], 60) });
52
53
  }
53
- const words = [prompt, game.name, game.blurb, section('(?:concept|the game|pitch|overview)'), section('(?:art|look|style|visual)'), section('(?:world|setting)')].filter(Boolean).join(' \n ');
54
+ const words = [prompt, game.name, game.blurb, section('(?:concept|the game|pitch|overview)'), section('(?:art|look|style|visual)'), section('(?:world|setting)'), ...(kind === 'app' ? [section('(?:their words|dream)'), section('scene'), section('people')] : [])].filter(Boolean).join(' \n ');
54
55
  const score = (re) => (words.match(new RegExp(re.source, 'gi')) ?? []).length;
55
- const genre = Object.entries(GENRES).map(([g, v]) => [g, score(v.words)]).filter(([, n]) => n).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'party';
56
+ const genre = kind === 'app' ? 'app' : Object.entries(GENRES).map(([g, v]) => [g, score(v.words)]).filter(([, n]) => n).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'party';
56
57
  const mood = Object.fromEntries(Object.entries(MOODS).map(([m, re]) => [m, score(re)]));
57
58
  let theme = {};
58
59
  try { theme = readTheme(root); } catch { theme = {}; }
@@ -76,7 +77,7 @@ export function contextOf(root, id, { prompt = '' } = {}) {
76
77
  const hs = assets.filter((a) => a.kind === 'character' && Number(a.measured?.heightM) > 0).map((a) => Number(a.measured.heightM)).sort((a, b) => a - b);
77
78
  if (hs.length) castHeightM = Math.round(hs[Math.floor(hs.length / 2)] * 100) / 100;
78
79
  } catch { clipVerbs = []; }
79
- return { id, prompt: clean(prompt, 600), words, score, genre, mood, codexPalette: pal, codexFonts: fonts, theme, cards, players: Number(game.players?.max ?? game.netplay?.maxPlayers ?? 8) || 8, name: game.name ?? id, codexArt: clean(section('(?:art|look|style|visual)'), 400), drawn, clipVerbs, castHeightM };
80
+ return { id, kind, prompt: clean(prompt, 600), words, score, genre, mood, codexPalette: pal, codexFonts: fonts, theme, cards, players: Number(game.players?.max ?? game.netplay?.maxPlayers ?? 8) || 8, name: game.name ?? id, codexArt: clean(section('(?:art|look|style|visual)'), 400), drawn, clipVerbs, castHeightM };
80
81
  }
81
82
 
82
83
  /** The best match in a preset table by word score: [key, score] or null. */
@@ -356,7 +357,7 @@ function change(rec, patch, { by = 'ai', words = null, reason = null } = {}) {
356
357
  export function initDecisions(root, id, { prompt = '', path = null, budget = null } = {}) {
357
358
  gameDirOf(root, id);
358
359
  const ctx = contextOf(root, id, { prompt });
359
- const doc = readDecisions(root, id) ?? { v: 1, scope: 'game', game: id, path: path ?? 'automatic', budget: null, prompt: '', decisions: {}, board: null, golden: [] };
360
+ const doc = readDecisions(root, id) ?? { v: 1, scope: ctx.kind, game: id, path: path ?? 'automatic', budget: null, prompt: '', decisions: {}, board: null, golden: [] };
360
361
  if (path) doc.path = path === 'hands-on' ? 'hands-on' : 'automatic';
361
362
  if (budget !== null && budget !== undefined) doc.budget = { usd: Math.max(0, Number(budget) || 0), provider: 'fal', approvedBy: 'the person, in chat', at: now() };
362
363
  if (prompt) doc.prompt = clean(prompt, 600);
@@ -367,6 +368,7 @@ export function initDecisions(root, id, { prompt = '', path = null, budget = nul
367
368
  const kept = [];
368
369
  const made = [];
369
370
  for (const [did] of CATALOGUE) {
371
+ if (ctx.kind === 'app' && !did.startsWith('style.')) continue;
370
372
  const def = CAT.get(did);
371
373
  const rec = doc.decisions[did];
372
374
  // Kept: anything the person steered or locked, anything pinned, and what a starter's style.json already draws.
@@ -380,7 +382,7 @@ export function initDecisions(root, id, { prompt = '', path = null, budget = nul
380
382
  made.push(did);
381
383
  }
382
384
  writeDecisions(root, id, doc);
383
- return { ok: true, command: 'style init', id, picked: made, kept, genre: ctx.genre, summary: oneLine(doc), file: `games/${id}/${DECISIONS_FILE}` };
385
+ return { ok: true, command: 'style init', id, picked: made, kept, kind: ctx.kind, genre: ctx.genre, summary: oneLine(doc), file: relative(root, fileOf(root, id)) };
384
386
  }
385
387
 
386
388
  /** The one line the automatic path says: "Look: flat low-poly, autumn grove palette, high three-quarter camera". */
@@ -1,10 +1,19 @@
1
1
  import {existsSync,readdirSync,mkdirSync,writeFileSync,readFileSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
+ import {readStudio} from './studio.mjs';
3
4
  import {pathToFileURL} from 'node:url';
4
5
  import {createRequire} from 'node:module';
5
6
  const require=createRequire(import.meta.url);
6
7
  export function functionFiles(root) { const dir=join(root,'functions');return existsSync(dir)?readdirSync(dir).filter(f=>/^[a-z][a-z0-9-]*\.ts$/.test(f)).sort().map(f=>join(dir,f)):[]; }
7
8
  export async function buildFunctions(root,esbuild) {
9
+ const feeds=readStudio(root).mcp?.events??{};
10
+ if(!feeds||typeof feeds!=='object'||Array.isArray(feeds))throw new Error('mcp.events must be a map of named event feeds');
11
+ for(const [name,feed] of Object.entries(feeds)) {
12
+ if(!/^[a-z][a-z0-9-]*$/.test(name)||!feed||typeof feed.event!=='string'||!feed.event.trim())throw new Error('Event feed needs a name and event type');
13
+ if(feed.event==='record.changed'&&(!/^[a-z][a-z0-9-]*$/.test(feed.app??'')||!/^[a-z][a-z0-9-]*$/.test(feed.collection??'')))throw new Error('Record event feed needs app and collection');
14
+ if(feed.audience!==undefined&&feed.audience!=='owner'&&!(feed.audience&&typeof feed.audience==='object'&&feed.audience.app===feed.app&&typeof feed.audience.role==='string'))throw new Error('Event feed audience must be owner or an app role');
15
+ if(feed.event!=='record.changed'&&feed.audience&&feed.audience!=='owner')throw new Error('Non-record event feeds require owner audience');
16
+ }
8
17
  const files=functionFiles(root),dir=join(root,'site/src/functions');mkdirSync(dir,{recursive:true});
9
18
  if(files.length) {
10
19
  const ts=require('typescript'),options={target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,strict:true,noEmit:true,skipLibCheck:true};
@@ -31,6 +40,7 @@ export async function buildFunctions(root,esbuild) {
31
40
  export function functionDeploymentSQL(root) {
32
41
  const file=join(root,'site/src/functions/declarations.json');
33
42
  const definitions=existsSync(file)?JSON.parse(readFileSync(file,'utf8')):[];
43
+ for(const [name,feed] of Object.entries(readStudio(root).mcp?.events??{}))definitions.push({name:'@events/'+name,event:feed.event});
34
44
  const literal=value=>"'"+String(value).replaceAll("'","''")+"'";
35
45
  return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),`DELETE FROM function_subscriptions WHERE name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
36
46
  }
package/lib/handoff.mjs CHANGED
@@ -43,7 +43,7 @@ export function stepsFor(h, { games = [], client = 'chat', studioKind = 'game' }
43
43
  const q = (s) => JSON.stringify(String(s));
44
44
  const app = h.what === 'app' || ['business', 'cause', 'app'].includes(studioKind);
45
45
  const work = app && ['setup', 'make'].includes(h.kind) ? [
46
- 'Follow the new-studio checklist and app skill from the request through building and checks without waiting unless the person asks.',
46
+ 'Follow the app and shared plan skills: capture their words, scene, people, offers and demonstrable needs in apps/<id>/CODEX.md. Starter plumbing is not the deliverable. Build their scene and prove each need on wall and phone; continue without an engineering interview.',
47
47
  has ? `apps/${id} is here already: change it as the brief says.` : `npx --no-install homie-studio app new ${id ?? '<id>'} --from ${ID.test(String(h.from ?? '')) ? h.from : 'welcome'}`,
48
48
  'Use apps/<id>/app.json for the organisation’s words, roles and records; site/pages/ for public pages; shop init --giving --currency <currency> for gifts.',
49
49
  ] : {
package/lib/mcp-tools.mjs CHANGED
@@ -845,7 +845,7 @@ export function toolDefs(ctx, avail = {}) {
845
845
  }
846
846
  const r = await setupCard(ctx, made.dir, { install, made: { dir: made.dir, wrote: made.wrote } });
847
847
  const fold = earlier.length ? `\n\nAn earlier folder for this name is on this computer, and it is not a studio: ${earlier.map((e) => `${e.path} (${e.files.join(', ') || 'empty'})`).join('; ')}. ASK the person: fold its premise into ${name}? With their yes, studio_fold { "from": "<that folder>" } copies its notes into the studio (notes/earlier/) and shows them to you, so the plan can use them. Then, only with another yes, studio_fold { "from": "<that folder>", "remove": true } moves the old folder to the Trash.` : '';
848
- r.content[0].text = `Made ${name} at ${made.dir} (${!a.kind || a.kind === 'game' ? 'its home page says "First game coming soon"' : 'ready for the requested app'}).\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}${fold}\n\n${r.content[0].text}`;
848
+ r.content[0].text = `Made ${name} at ${made.dir} (${!a.kind || a.kind === 'game' ? 'its home page says "First game coming soon"' : 'ready for the requested app'}).\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}${fold}${a.kind && a.kind !== 'game' ? '\n\n' + made.next.join('\n') : ''}\n\n${r.content[0].text}`;
849
849
  if (r.structuredContent) r.structuredContent.earlier = earlier;
850
850
  return r;
851
851
  },
@@ -922,7 +922,7 @@ export function toolDefs(ctx, avail = {}) {
922
922
  if (ctx.install) { const job = runningJobs(root).find((j) => j.label.startsWith('npm install')) ?? installJob(root); install = ` Dependencies are installing (job ${job.id}); wait for studio_job.`; }
923
923
  else install = ' Run studio_install for its engine dependencies.';
924
924
  }
925
- return ok(`apps/${a.id} is ready.${install} Build, dev --lan, then check. Follow the app skill: one screen, roles and parts.`, { kind: 'app', ...r.result });
925
+ return ok(`apps/${a.id} plumbing is ready.${install} Read the app and plan guides. Capture the person’s dream in apps/${a.id}/CODEX.md, design their scene with engines, style from their words, parts_find and media. Build it; fix failures without flattening. Check and playtest each Codex need with wall and phone evidence.`, { kind: 'app', ...r.result });
926
926
  },
927
927
  },
928
928
  {
@@ -979,25 +979,26 @@ export function toolDefs(ctx, avail = {}) {
979
979
  },
980
980
  },
981
981
  {
982
- name: 'game_plan', title: 'Plan a game (its Game Codex)',
982
+ name: 'game_plan', title: 'Plan a game or app (its Codex)',
983
983
  description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and record sensible defaults from the request for genre, style, devices, rooms, art, sound and scope. Never route engineering or design questions to the person. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their request and your defaults in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it. While planning, run `parts find` (the parts_find tool) for each system the game needs, so the plan starts from pieces of existing games and the @homie-rocks/* packages rather than from nothing; the codex\'s Built from section records what was found.',
984
- inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), interview: { type: 'boolean', description: 'Only when the person explicitly requests a planning conversation' }, ...STUDIO_ARG }, required: ['id'] },
984
+ inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), kind: { type: 'string', enum: ['game', 'app'], description: 'App plans use apps/<id>/CODEX.md with the person’s scene, people, offers and needs' }, interview: { type: 'boolean', description: 'Only when the person explicitly requests a planning conversation' }, ...STUDIO_ARG }, required: ['id'] },
985
985
  annotations: { title: 'Plan a game', ...RW },
986
986
  run: async (a) => {
987
987
  const root = ctx.root(a.studio);
988
- const file = join(root, 'games', String(a.id), 'CODEX.md');
988
+ const folder = a.kind === 'app' || existsSync(join(root, 'apps', String(a.id))) ? 'apps' : 'games';
989
+ const file = join(root, folder, String(a.id), 'CODEX.md');
989
990
  let made = null;
990
991
  if (!existsSync(file)) {
991
- made = newCodex(root, String(a.id), { name: a.name ?? null });
992
+ made = newCodex(root, String(a.id), { name: a.name ?? null, kind: folder === 'apps' ? 'app' : 'game' });
992
993
  if (!made.ok) return fail(made.why);
993
994
  }
994
995
  const guide = a.interview === true && ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
995
996
  return ok([
996
- made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
997
- 'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. New games are rules plus view with room.host server; read the game guide and RULES.md. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
997
+ made ? `Wrote ${folder}/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `${folder}/${a.id}/CODEX.md is there already: change it, never replace it.`,
998
+ folder === 'apps' ? 'Fill Their words, Scene, People and surfaces, Offers and executable Needs from the person’s request. Use the app guide; starter plumbing is not their scene. Show with game_codex, build and prove every need on wall and phone.' : 'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. New games are rules plus view with room.host server; read the game guide and RULES.md. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
998
999
  '', PARTS_FIRST,
999
1000
  ...(guide ? ['', 'Optional reference for a person who explicitly requests an interview:', guide] : []),
1000
- ].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
1001
+ ].join('\n'), { kind: 'plan', id: a.id, file: `${folder}/${a.id}/CODEX.md`, created: Boolean(made) });
1001
1002
  },
1002
1003
  },
1003
1004
  {
package/lib/mcp.mjs CHANGED
@@ -144,7 +144,7 @@ function promptText(name, args = {}) {
144
144
  export async function serveMcp({ studios = null, skills = null, cwd = process.cwd(), input = process.stdin, output = process.stdout, waitMs, directory = null, install = true, desktop = inDesktopApp(), log = (line) => process.stderr.write(`[homie-studio mcp] ${line}\n`) } = {}) {
145
145
  // A GUI app starts this with a short PATH: everything it runs sees the usual places Node, npm and ffmpeg live.
146
146
  process.env.PATH = toolPath();
147
- const skillsDir = skills ?? [join(PACKAGE_ROOT, '..', '..', 'plugins', 'homie', 'skills'), join(PACKAGE_ROOT, '..', 'skills')].find((d) => existsSync(join(d, 'studio-setup', 'SKILL.md'))) ?? null;
147
+ const skillsDir = skills ?? [join(PACKAGE_ROOT, '..', '..', 'plugins', 'homie', 'skills'), join(PACKAGE_ROOT, '..', 'skills'), join(PACKAGE_ROOT, 'guides')].find((d) => existsSync(join(d, 'studio-setup', 'SKILL.md'))) ?? null;
148
148
  const ctx = new StudioContext({ studiosDir: studios, cwd, skillsDir, waitMs: waitMs ?? Number(process.env.HOMIE_MCP_WAIT_MS || 40_000), directory, install, desktop });
149
149
  let avail = availability();
150
150
  let names = '';
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
1
+ import { FUNCTIONS_REFERENCE_MIGRATION, FUNCTIONS_REFERENCE_MIGRATION_FILE, FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
2
2
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
3
3
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
4
4
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
@@ -68,7 +68,7 @@ export function slugify(name) {
68
68
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
69
69
  * the binding names and keep it linked.
70
70
  */
71
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
71
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null, eventFeeds = null }) {
72
72
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
73
73
  : { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
74
74
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }, { name: 'GATE', class_name: 'Gate' }, { name: 'CONCENTRATOR', class_name: 'Concentrator' }];
@@ -100,7 +100,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
100
100
  // Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
101
101
  // Never in Previews: they do not inherit it, so a Preview's guides answer from the game's script.
102
102
  ...(ai ? { ai: { binding: 'AI' } } : {}),
103
- vars: { STUDIO_NAME: name },
103
+ vars: { STUDIO_NAME: name, ...(eventFeeds&&Object.keys(eventFeeds).length?{STUDIO_EVENT_FEEDS:JSON.stringify(eventFeeds)}:{}) },
104
104
  observability: { enabled: true },
105
105
  previews: { vars: { STUDIO_NAME: name, HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
106
106
  };
@@ -117,7 +117,7 @@ export function agentsMd({ name, slug, kind = 'game' }) {
117
117
 
118
118
  This is **${name}** (\`${slug}\`), a ${kind === 'cause' ? 'cause' : 'business'} studio. Its apps, pages and shop run on its own Cloudflare account.
119
119
 
120
- Read \`node_modules/@homie-rocks/studio/guides/app/SKILL.md\` first. Proceed from the owner's request through building and checking; ask for an interview only when requested or when essential information is missing.
120
+ Read \`node_modules/@homie-rocks/studio/guides/app/SKILL.md\` and the shared \`guides/plan/SKILL.md\` first. Capture the owner’s exact dream, scene, people, offers and demonstrable needs in \`apps/<id>/CODEX.md\` (\`codex new <id> --kind app\`). A starter is plumbing, never the deliverable. Build their scene with the engine packages, style from their words, parts_find and appropriate art/models/sound/music. Fix build failures without flattening the scene. End with every Codex need shown on wall and phone and judged through the playtest guide. Proceed from the owner's request through building and checking; ask for an interview only when requested or when essential information is missing.
121
121
 
122
122
  - \`apps/<id>/app.json\` defines the app's words, roles, records and surfaces; \`src/view.ts\` draws it.
123
123
  - \`site/pages/<path>/index.html\` adds a page and a header link. A page at an app's id replaces its landing, while \`/<id>/open\` and \`/<id>/tv\` remain the phone and wall entrances.
@@ -173,6 +173,8 @@ Cloudflare creates DNS and TLS during deploy with the existing sign-in.
173
173
 
174
174
  ## Apps: one screen, roles and parts
175
175
 
176
+ Plan apps with the same Codex mechanism as games: \`codex new <id> --kind app\` writes \`apps/<id>/CODEX.md\`. Read the packaged plan, app and playtest guides. Preserve the person’s words, scene, people, offers and testable needs; build that scene with engines, style, parts and media, then prove each need on wall and phone. Starters are plumbing, not completion.
177
+
176
178
  An app lives in \`apps/<id>/app.json\` and \`src/\`. Make one with \`homie-studio app new <id>\` (or \`app_make\`). Use the Homie plugin's \`app\` skill for businesses, venues, causes and customer apps. No game demo, rounds, scores, bots or mandatory fun: build what the person needs with the fewest human steps.
177
179
 
178
180
  An app is ONE screen that morphs with context: camera moves, panels unfold, its roles see and do different things, and parts plug into the scene. No page-to-page navigation or menus of links inside it. Use all the same engine packages and media skills as games (camera, geom, props, render, postfx, fx, audio, input, ui-world; style, art, models, animate, music, sound, video). It can be fully 3D and wild while remaining useful and legible.
@@ -822,6 +824,7 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
822
824
  [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
823
825
  [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
824
826
  [`site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`]: FUNCTIONS_META_MIGRATION,
827
+ [`site/migrations/${FUNCTIONS_REFERENCE_MIGRATION_FILE}`]: FUNCTIONS_REFERENCE_MIGRATION,
825
828
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
826
829
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
827
830
  '.claude/skills/.gitkeep': '',
@@ -993,6 +996,11 @@ function ensurePartsStateMigration(root) {
993
996
  return `site/migrations/${PURCHASE_STATE_FILE}`;
994
997
  }
995
998
 
999
+ function ensureFunctionReference(root) {
1000
+ const file=join(root,'site','migrations',FUNCTIONS_REFERENCE_MIGRATION_FILE);
1001
+ if(existsSync(file))return null;
1002
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_REFERENCE_MIGRATION);return `site/migrations/${FUNCTIONS_REFERENCE_MIGRATION_FILE}`;
1003
+ }
996
1004
  function ensureFunctionMeta(root) {
997
1005
  const file=join(root,'site','migrations',FUNCTIONS_META_MIGRATION_FILE);
998
1006
  if(existsSync(file))return null;
@@ -1014,7 +1022,7 @@ function ensureMcpMigration(root) {
1014
1022
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
1015
1023
  }
1016
1024
  export function ensureMigrations(root) {
1017
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root)].filter(Boolean);
1025
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root)].filter(Boolean);
1018
1026
  }
1019
1027
 
1020
1028
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -1079,9 +1087,9 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
1079
1087
  const q = JSON.stringify(realpathSync(dir));
1080
1088
  return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: kind !== 'game' ? [
1081
1089
  `cd ${q}`,
1082
- 'Read the app guide. Proceed from the request through build and checks without waiting unless the person asks for an interview.',
1090
+ 'Read the app and shared plan guides. Create apps/<id>/CODEX.md with codex new <id> --kind app; capture their words, scene, people, offers and testable needs. Proceed without an engineering interview.',
1083
1091
  'npx --no-install homie-studio app new <id> --from welcome --name "<Name>"',
1084
- 'npm install && npm run build; npm run dev (use the printed address)',
1092
+ 'Starter = plumbing only. Install dependencies, style init from their words, parts find, design and build their living scene; repair failures without flattening it. npm run build; npm run dev (use the printed address)',
1085
1093
  'npx --no-install homie-studio check <id> --url <printed address> --shots .checks',
1086
1094
  'The phone opens /<id>/open; the wall /<id>/tv. Publish when requested.',
1087
1095
  ] : [