@homie-rocks/studio 0.48.4 → 0.50.0

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 (105) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/app-starters/gathering/README.md +4 -0
  3. package/app-starters/gathering/app.json +162 -0
  4. package/app-starters/gathering/index.html +3 -0
  5. package/app-starters/gathering/map/main.json +1 -0
  6. package/app-starters/gathering/src/move.ts +3 -0
  7. package/app-starters/gathering/src/rules.ts +21 -0
  8. package/app-starters/gathering/src/view.ts +74 -0
  9. package/app-starters/giving/README.md +5 -0
  10. package/app-starters/giving/app.json +162 -0
  11. package/app-starters/giving/index.html +3 -0
  12. package/app-starters/giving/map/main.json +1 -0
  13. package/app-starters/giving/src/move.ts +3 -0
  14. package/app-starters/giving/src/rules.ts +21 -0
  15. package/app-starters/giving/src/view.ts +74 -0
  16. package/app-starters/jobs/README.md +4 -0
  17. package/app-starters/jobs/app.json +162 -0
  18. package/app-starters/jobs/index.html +3 -0
  19. package/app-starters/jobs/map/main.json +1 -0
  20. package/app-starters/jobs/src/move.ts +3 -0
  21. package/app-starters/jobs/src/rules.ts +21 -0
  22. package/app-starters/jobs/src/view.ts +74 -0
  23. package/app-starters/quiz/README.md +4 -0
  24. package/app-starters/quiz/app.json +162 -0
  25. package/app-starters/quiz/index.html +3 -0
  26. package/app-starters/quiz/map/main.json +1 -0
  27. package/app-starters/quiz/src/move.ts +3 -0
  28. package/app-starters/quiz/src/rules.ts +21 -0
  29. package/app-starters/quiz/src/view.ts +74 -0
  30. package/app-starters/welcome/app.json +25 -2
  31. package/app-starters/welcome/index.html +1 -1
  32. package/app-starters/welcome/src/view.ts +20 -8
  33. package/bin/homie-studio.mjs +20 -16
  34. package/functions/FUNCTIONS.md +1 -1
  35. package/guides/app/SKILL.md +178 -0
  36. package/guides/art/SKILL.md +154 -0
  37. package/guides/art/references/RIGHTS.md +20 -0
  38. package/guides/art/scripts/art.mjs +308 -0
  39. package/guides/models/SKILL.md +198 -0
  40. package/guides/models/references/RIGHTS.md +46 -0
  41. package/guides/models/references/models.json +175 -0
  42. package/guides/models/scripts/models.mjs +483 -0
  43. package/guides/music/SKILL.md +197 -0
  44. package/guides/music/references/COMPOSITION.md +59 -0
  45. package/guides/music/references/GAME-AUDIO.md +50 -0
  46. package/guides/music/references/RIGHTS.md +43 -0
  47. package/guides/music/scripts/lib/audio.mjs +258 -0
  48. package/guides/music/scripts/lib/eleven.mjs +260 -0
  49. package/guides/music/scripts/lib/studio.mjs +147 -0
  50. package/guides/music/scripts/music.mjs +644 -0
  51. package/guides/parts/SKILL.md +158 -0
  52. package/guides/plan/SKILL.md +126 -0
  53. package/guides/plan/references/CODEX.md +105 -0
  54. package/guides/plan/references/INTERVIEW.md +86 -0
  55. package/guides/playtest/SKILL.md +205 -0
  56. package/guides/playtest/references/METHOD.md +124 -0
  57. package/guides/playtest/references/REVIEWER.md +78 -0
  58. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  59. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  60. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  61. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  62. package/guides/playtest/scripts/playtest.mjs +870 -0
  63. package/guides/shop/SKILL.md +153 -0
  64. package/guides/sound/SKILL.md +156 -0
  65. package/guides/sound/references/GAME-SOUND.md +100 -0
  66. package/guides/sound/references/SCORE.md +131 -0
  67. package/guides/sound/references/TRAPS.md +93 -0
  68. package/guides/sound/scripts/lib/measure.mjs +252 -0
  69. package/guides/sound/scripts/lib/score.mjs +415 -0
  70. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  71. package/guides/sound/scripts/lib/synth.mjs +509 -0
  72. package/guides/sound/scripts/sound.mjs +520 -0
  73. package/guides/studio-setup/SKILL.md +419 -0
  74. package/guides/style/SKILL.md +105 -0
  75. package/lib/app-needs.mjs +63 -0
  76. package/lib/authoring-guides.mjs +5 -4
  77. package/lib/build.mjs +5 -4
  78. package/lib/check-site.mjs +6 -6
  79. package/lib/check.mjs +18 -5
  80. package/lib/codex.mjs +95 -18
  81. package/lib/decisions.mjs +9 -7
  82. package/lib/dev.mjs +4 -2
  83. package/lib/doctor.mjs +1 -1
  84. package/lib/handoff.mjs +13 -8
  85. package/lib/mcp-tools.mjs +19 -17
  86. package/lib/mcp.mjs +1 -1
  87. package/lib/parts.mjs +6 -6
  88. package/lib/scaffold.mjs +45 -11
  89. package/lib/shop-links.mjs +2 -2
  90. package/lib/shop.mjs +5 -3
  91. package/lib/studio.mjs +7 -6
  92. package/lib/template-history.json +84 -0
  93. package/package.json +4 -4
  94. package/shop/SHOP.md +4 -0
  95. package/shop/shop.ts +4 -4
  96. package/worker/app-format.mjs +1 -1
  97. package/worker/build-id.mjs +1 -1
  98. package/worker/functions-schema.mjs +15 -0
  99. package/worker/index.mjs +2 -1
  100. package/worker/parts.mjs +4 -4
  101. package/worker/shop-page.mjs +22 -16
  102. package/worker/shop-rules.mjs +4 -3
  103. package/worker/shop.mjs +6 -2
  104. package/worker/site.mjs +4 -2
  105. package/worker/version.mjs +1 -1
package/lib/build.mjs CHANGED
@@ -487,7 +487,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
487
487
  digest.update(readFileSync(join(out, 'index.html')));
488
488
  rules.build = digest.digest('hex').slice(0, 32);
489
489
  writeFileSync(join(out, 'rules.json'), `${JSON.stringify({ v: 1, host: rules.settings.host, offline: rules.settings.offline, build: rules.build, files: [bundled, ...chunks] })}\n`);
490
- if (g.netplay?.version !== undefined) log(` ${g.id}: rules games use their build hash as the revision; netplay.version is not read`);
490
+ if (g.netplay?.version !== undefined) log(` ${g.id}: rules ${g.kind === 'app' ? 'apps' : 'games'} use their build hash as the revision; netplay.version is not read`);
491
491
  }
492
492
  return { mode, warnings, metafile, bundle: bundled, chunks, rules };
493
493
  }
@@ -572,7 +572,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
572
572
  ...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
573
573
  });
574
574
  log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
575
- if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this game's rules and tunables are sent to players' devices so it can be played offline`);
575
+ if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this ${g.kind === 'app' ? 'app' : 'game'}'s rules and tunables are sent to players' devices so it can be played offline`);
576
576
  if (rules) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
577
577
  if (rules && rules.settings.host === 'server' && (rules.snapshotBytes > rules.snapshotCap || rules.checkpointBytes > rules.checkpointCap)) log(` ${g.id}: this state fits server and offline play but exceeds browser hosting limits.`);
578
578
  if (rules) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
@@ -688,7 +688,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
688
688
  const lounge = loungeConfig(studio, rows, { audience: audienceOf(studio) });
689
689
  const catalogue = {
690
690
  studio: {
691
- name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
691
+ name: studio.name, slug: studio.slug, version: STUDIO_VERSION, ...(studio.kind ? { kind: studio.kind } : {}),
692
692
  ...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
693
693
  theme,
694
694
  // studio.json "site": { "schema": { … } }: the owner's own schema.org properties on the studio (sameAs, …).
@@ -727,6 +727,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
727
727
  // shop.json, checked (nothing secret is ever in it): the Worker sells from this and checks it again.
728
728
  ...(shop ? { shop } : {}),
729
729
  };
730
+ for (const app of rows.filter(g => g.kind === 'app' && site.pages.includes(`/${g.id}/`))) log(`site/pages/${app.id}/ replaces the app landing; open /${app.id}/open and wall /${app.id}/tv`);
730
731
  for (const game of rows) {
731
732
  const image = await playerImage(catalogue, game, dist, log);
732
733
  if (image) game.playerImage = image;
@@ -760,6 +761,6 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
760
761
  ...(retired.length ? { retired: `Remix was retired, and games now build on each other through parts (pieces a studio shares; ask for a part, or see parts/PARTS.md). These settings no longer do anything and can be deleted: ${retired.join('; ')}. No game's source is served whole; a game made from another keeps its credit.` } : {}),
761
762
  songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
762
763
  posts: posts.map((p) => p.slug), postsSkipped, codexes, parts: partsBuilt, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
763
- landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length) })),
764
+ landings: rows.map((g) => ({ id: g.id, ...(g.kind === 'app' ? { kind: 'app' } : {}), hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length) })),
764
765
  };
765
766
  }
@@ -16,7 +16,7 @@ import { once } from 'node:events';
16
16
  import { createServer } from 'node:net';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { runningDev } from './dev.mjs';
19
- import { listGames, siteUrl } from './studio.mjs';
19
+ import { listExperiences, siteUrl } from './studio.mjs';
20
20
 
21
21
  const CLI = fileURLToPath(new URL('../bin/homie-studio.mjs', import.meta.url));
22
22
 
@@ -64,16 +64,16 @@ export async function checkSite(root, { url = null, live = false, log = () => {}
64
64
  export function changedGames(root, { run = spawnSync } = {}) {
65
65
  const git = (...args) => { const r = run('git', args, { cwd: root, encoding: 'utf8' }); return r.status === 0 ? String(r.stdout) : null; };
66
66
  if (git('rev-parse', '--is-inside-work-tree') === null) return [];
67
- const files = new Set((git('status', '--porcelain', '--', 'games') ?? '').split('\n').map((l) => l.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()).filter(Boolean));
67
+ const files = new Set((git('status', '--porcelain', '--', 'games', 'apps') ?? '').split('\n').map((l) => l.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()).filter(Boolean));
68
68
  const head = (git('symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD') ?? '').trim();
69
69
  for (const base of [head, 'origin/main', 'main', 'origin/master', 'master'].filter(Boolean)) {
70
70
  const fork = (git('merge-base', 'HEAD', base) ?? '').trim();
71
71
  if (!fork) continue;
72
- for (const f of (git('diff', '--name-only', `${fork}..HEAD`, '--', 'games') ?? '').split('\n')) if (f) files.add(f);
72
+ for (const f of (git('diff', '--name-only', `${fork}..HEAD`, '--', 'games', 'apps') ?? '').split('\n')) if (f) files.add(f);
73
73
  break;
74
74
  }
75
- const touched = new Set([...files].map((f) => /^games\/([^/]+)\//.exec(f)?.[1]).filter(Boolean));
76
- return listGames(root).map((g) => g.id).filter((id) => touched.has(id));
75
+ const touched = new Set([...files].map((f) => /^(?:games|apps)\/([^/]+)\//.exec(f)?.[1]).filter(Boolean));
76
+ return listExperiences(root).map((g) => g.id).filter((id) => touched.has(id));
77
77
  }
78
78
 
79
79
  /** The games a check covers: the one named, else what changed, else all of them. */
@@ -81,5 +81,5 @@ export function gamesToCheck(root, named, options) {
81
81
  if (named) return { games: [named], why: 'named' };
82
82
  const changed = changedGames(root, options);
83
83
  if (changed.length) return { games: changed, why: 'changed in this checkout' };
84
- return { games: listGames(root).map((g) => g.id), why: 'nothing changed against the default branch: every game' };
84
+ return { games: listExperiences(root).map((g) => g.id), why: 'nothing changed against the default branch: every game' };
85
85
  }
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 });
@@ -381,9 +384,19 @@ export async function checkApp({ url, game, meta, shots = null, log = () => {},
381
384
  await actor.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
382
385
  const rooms = await Promise.all(surfaces.map((s) => s.page.evaluate(() => window.__shell.room)));
383
386
  if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
384
- if (shots) for (const s of surfaces) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 65 });
387
+ for (const s of surfaces) {
388
+ await s.page.waitForFunction(() => {
389
+ const cover = document.querySelector('[data-arrive]');
390
+ return window.__shell?.arrival?.phase === 'done' && (!cover || (getComputedStyle(cover).opacity === '0' && getComputedStyle(cover).visibility === 'hidden'));
391
+ }, { timeout: READY_TIMEOUT_MS });
392
+ await s.frame.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
393
+ if (shots) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });
394
+ }
385
395
  report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
386
- 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 };
387
400
  } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
388
401
  finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
389
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". */
package/lib/dev.mjs CHANGED
@@ -310,7 +310,7 @@ const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'si
310
310
  * options: port, remoteAi, localAi (false: --no-local-ai), timestamps (every line of Wrangler's gets a time), log.
311
311
  */
312
312
  export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
313
- const port = String(askedPort ?? 8787);
313
+ let port = String(askedPort ?? 8787);
314
314
  const stop = 'npx --no-install homie-studio dev --stop';
315
315
 
316
316
  // What is really there, before anything is built or started (the header: stale state heals itself).
@@ -327,7 +327,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
327
327
  const left = await clearLeftover(root, p);
328
328
  if (left.stopped.length) log(`Stopped a leftover local runtime of this studio on port ${p} (process ${left.stopped.join(', ')}): its dev server had been stopped another way.`);
329
329
  if (p === Number(port) && left.open) {
330
- return { ok: false, command: 'dev', needs: 'port', why: `port ${port} is already in use by another program${left.foreign.length ? ` (process ${left.foreign.join(', ')})` : ''}, and it is not this studio's dev server, so nothing was stopped or started. Use another port: npx --no-install homie-studio dev --port ${Number(port) + 1}` };
330
+ const busy = port;
331
+ do { port = String(Number(port) + 1); } while (Number(port) < 65535 && await portOpen(Number(port)));
332
+ log(`Port ${busy} is busy; using http://127.0.0.1:${port}/`);
331
333
  }
332
334
  }
333
335
 
package/lib/doctor.mjs CHANGED
@@ -297,7 +297,7 @@ export async function setupStatus({
297
297
  who: 'person',
298
298
  say: app === 'codex'
299
299
  ? 'In Codex, open /hooks and trust Homie\'s three hooks: Codex runs no plugin\'s hooks until you do. Then run this again. Until then nothing is held, so your AI asks you before each of those itself.'
300
- : 'Grok runs a plugin\'s hooks only once the plugin is trusted, and only from a Homie plugin new enough to name them for Grok. Install it again with trust (grok plugin install homie-rocks/homie#plugins/homie --trust), start a new session (/hooks lists Homie\'s three), then run this again. Until then nothing is held, so your AI asks you before each of those itself.',
300
+ : 'Grok runs a plugin\'s hooks only once the plugin is trusted, and only from a Homie plugin new enough to name them for Grok. Ask your AI to install the Homie plugin in Grok and open its trust prompt (the AI runs grok plugin install homie-rocks/homie#plugins/homie --trust), start a new session (/hooks lists Homie\'s three), then run this again. Until then nothing is held, so your AI asks you before each of those itself.',
301
301
  },
302
302
  });
303
303
  }
package/lib/handoff.mjs CHANGED
@@ -37,13 +37,18 @@ const line = (v, max) => String(v ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').repla
37
37
  const lines = (v, max) => String(v ?? '').split(/\r?\n/).map((l) => line(l, max)).filter(Boolean).join('\n').slice(0, max);
38
38
 
39
39
  /** The steps for a kind of build, in this toolkit's commands (the brief says what; these say how). */
40
- export function stepsFor(h, { games = [], client = 'chat' } = {}) {
40
+ export function stepsFor(h, { games = [], client = 'chat', studioKind = 'game' } = {}) {
41
41
  const id = ID.test(String(h.id ?? '')) ? h.id : null;
42
42
  const has = id && games.includes(id);
43
43
  const q = (s) => JSON.stringify(String(s));
44
- const work = {
44
+ const app = h.what === 'app' || ['business', 'cause', 'app'].includes(studioKind);
45
+ const work = app && ['setup', 'make'].includes(h.kind) ? [
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
+ 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
+ '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
+ ] : {
45
50
  setup: [
46
- `This studio was just set up from ${client === 'grok' ? 'Grok' : client === 'codex' ? 'Codex' : client === 'claude' ? 'the Claude app' : 'this chat'}. Go through the new-studio checklist with the person, one step at a time and never ahead (the plugin's studio-setup skill): it has no game yet, and its home page says "First game coming soon".`,
51
+ `This studio was just set up from ${client === 'grok' ? 'Grok' : client === 'codex' ? 'Codex' : client === 'claude' ? 'the Claude app' : 'this chat'}. Proceed through the new-studio checklist, building and checks from the request without waiting unless the person asks (the plugin's studio-setup skill): for a business or cause, start with the app skill and app new; choose the starter that fits the requested work.`,
47
52
  'See a working game: `npx --no-install homie-studio demo` names a live one on Homie Arcade; copy a starter in only if they ask.',
48
53
  'One small change from one sentence of theirs (the studio\'s colours in site/theme.json, a tagline in studio.json, or a first post in posts/), then plan their game into its Game Codex (`npx --no-install homie-studio codex new <id> --name "<Name>"`) before any game is made.',
49
54
  ],
@@ -58,7 +63,7 @@ export function stepsFor(h, { games = [], client = 'chat' } = {}) {
58
63
  }[h.kind] ?? ['Do what the brief asks.'];
59
64
  return [
60
65
  ...work,
61
- 'npm run build; npm run dev in the background; npx --no-install homie-studio check <id> --url http://127.0.0.1:8787 (on Linux without Chrome, first: npx --no-install homie-studio chrome install).',
66
+ 'npm run build; npm run dev in the background; npx --no-install homie-studio check <id> --url <the printed dev address> (on Linux without Chrome, first: npx --no-install homie-studio chrome install).',
62
67
  'npx --no-install homie-studio progress change "<what it does, one line>"; commit on a new branch, push, gh pr create; then npx --no-install homie-studio progress pr --url <the pull request>.',
63
68
  'Never merge the pull request: the person publishes it from the chat\'s card, in GitHub.',
64
69
  ];
@@ -71,8 +76,8 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
71
76
  if (SETUP.test(String(build ?? ''))) {
72
77
  const r = await checkIn(root, build, { homie, client: who.id === 'chat' ? null : who.id });
73
78
  if (!r.ok) return { ok: false, command: 'handoff', setup: build, why: r.why, ...(r.needs ? { needs: r.needs } : {}) };
74
- const h = { kind: 'setup', what: 'game', title: 'first steps', studio: r.name ?? studio.name, brief: '', setup: build };
75
- return { ok: true, command: 'handoff', build: null, ...h, client: who.id, setup: { ok: true, message: r.message, renamed: r.renamed ?? [] }, attached: null, attachWhy: 'no build was opened in the chat yet (the setup card\'s Start building opens one)', steps: stepsFor(h, { client: who.id }) };
79
+ const h = { kind: 'setup', what: ['business', 'cause', 'app'].includes(studio.kind) ? 'app' : 'game', title: 'first steps', studio: r.name ?? studio.name, brief: '', setup: build };
80
+ return { ok: true, command: 'handoff', build: null, ...h, client: who.id, setup: { ok: true, message: r.message, renamed: r.renamed ?? [] }, attached: null, attachWhy: 'no build was opened in the chat yet (the setup card\'s Start building opens one)', steps: stepsFor(h, { client: who.id, studioKind: studio.kind }) };
76
81
  }
77
82
  if (!BUILD.test(String(build ?? ''))) return { ok: false, command: 'handoff', why: `the id from the one line the chat gave: hb_ (a build) or hs_ (a setup) and 32 hex digits` };
78
83
  const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
@@ -88,7 +93,7 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
88
93
  if (want && here && here !== want) return { ok: false, command: 'handoff', build, why: `this build is for ${body.repo}, but this session is in ${here}: open it in ${body.repo} (the card's Build it does), and change nothing here` };
89
94
  const h = {
90
95
  kind: ['make', 'port', 'change', 'setup'].includes(body.kind) ? body.kind : 'change',
91
- what: ['game', 'song', 'video'].includes(body.what) ? body.what : 'game',
96
+ what: ['app', 'game', 'song', 'video'].includes(body.what) ? body.what : 'game',
92
97
  title: line(body.title, 120), studio: line(body.studio, 60) || studio.name, brief: lines(body.brief, 1200),
93
98
  id: ID.test(String(body.id ?? '')) ? body.id : null, name: line(body.name, 60) || null, from: ID.test(String(body.from ?? '')) ? body.from : null,
94
99
  folder: line(body.folder, 200) || null,
@@ -106,7 +111,7 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
106
111
  ok: true, command: 'handoff', build, ...h, setup,
107
112
  attached: taken.ok ? { feed: taken.build, shared: taken.shared?.build ?? build } : null,
108
113
  attachWhy: taken.ok ? null : taken.why,
109
- steps: stepsFor(h, { games, client: who.id }),
114
+ steps: stepsFor(h, { games, client: who.id, studioKind: studio.kind }),
110
115
  };
111
116
  }
112
117