@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.
- package/CHANGELOG.md +43 -0
- package/bin/homie-studio.mjs +5 -5
- package/functions/FUNCTIONS.md +71 -1
- package/guides/app/SKILL.md +130 -1
- package/guides/art/SKILL.md +154 -0
- package/guides/art/references/RIGHTS.md +20 -0
- package/guides/art/scripts/art.mjs +308 -0
- package/guides/models/SKILL.md +198 -0
- package/guides/models/references/RIGHTS.md +46 -0
- package/guides/models/references/models.json +175 -0
- package/guides/models/scripts/models.mjs +483 -0
- package/guides/music/SKILL.md +197 -0
- package/guides/music/references/COMPOSITION.md +59 -0
- package/guides/music/references/GAME-AUDIO.md +50 -0
- package/guides/music/references/RIGHTS.md +43 -0
- package/guides/music/scripts/lib/audio.mjs +258 -0
- package/guides/music/scripts/lib/eleven.mjs +260 -0
- package/guides/music/scripts/lib/studio.mjs +147 -0
- package/guides/music/scripts/music.mjs +644 -0
- package/guides/office/SKILL.md +99 -0
- package/guides/plan/SKILL.md +126 -0
- package/guides/plan/references/CODEX.md +105 -0
- package/guides/plan/references/INTERVIEW.md +86 -0
- package/guides/playtest/SKILL.md +205 -0
- package/guides/playtest/references/METHOD.md +124 -0
- package/guides/playtest/references/REVIEWER.md +78 -0
- package/guides/playtest/scripts/lib/exit.mjs +31 -0
- package/guides/playtest/scripts/lib/judge.mjs +502 -0
- package/guides/playtest/scripts/lib/pixels.mjs +128 -0
- package/guides/playtest/scripts/lib/preflight.mjs +69 -0
- package/guides/playtest/scripts/playtest.mjs +870 -0
- package/guides/servers/SKILL.md +111 -0
- package/guides/shop/SKILL.md +4 -0
- package/guides/sound/SKILL.md +156 -0
- package/guides/sound/references/GAME-SOUND.md +100 -0
- package/guides/sound/references/SCORE.md +131 -0
- package/guides/sound/references/TRAPS.md +93 -0
- package/guides/sound/scripts/lib/measure.mjs +252 -0
- package/guides/sound/scripts/lib/score.mjs +415 -0
- package/guides/sound/scripts/lib/sfx.mjs +222 -0
- package/guides/sound/scripts/lib/synth.mjs +509 -0
- package/guides/sound/scripts/sound.mjs +520 -0
- package/guides/style/SKILL.md +105 -0
- package/lib/app-needs.mjs +63 -0
- package/lib/check.mjs +10 -4
- package/lib/codex.mjs +95 -18
- package/lib/decisions.mjs +9 -7
- package/lib/functions-build.mjs +10 -0
- package/lib/handoff.mjs +1 -1
- package/lib/mcp-tools.mjs +10 -9
- package/lib/mcp.mjs +1 -1
- package/lib/scaffold.mjs +15 -7
- package/lib/studio.mjs +3 -3
- package/lib/template-history.json +84 -0
- package/lib/worker-config.mjs +1 -1
- package/package.json +1 -1
- package/shop/SHOP.md +4 -0
- package/shop/shop.ts +4 -4
- package/tools/TOOLS.md +21 -0
- package/tools/tools.ts +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +116 -0
- package/worker/functions-schema.mjs +15 -0
- package/worker/functions.mjs +36 -22
- package/worker/index.mjs +23 -3
- package/worker/mcp-tools.mjs +14 -3
- package/worker/mcp.mjs +45 -8
- package/worker/shop-page.mjs +1 -1
- package/worker/shop.mjs +6 -2
- 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
|
-
|
|
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(
|
|
533
|
-
if (statSync(file).size > LIMITS.source) throw new Error(
|
|
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
|
|
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"
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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/functions-build.mjs
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
'
|
|
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
|
] : [
|