@homie-rocks/studio 0.52.0 → 0.54.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 (61) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/apps/apps.ts +17 -0
  3. package/bin/homie-studio.mjs +9 -3
  4. package/docs/shop-migration.md +9 -0
  5. package/docs/toolkit-batch.md +49 -0
  6. package/functions/FUNCTIONS.md +15 -1
  7. package/guides/app/PLACES.md +23 -0
  8. package/guides/app/SKILL.md +2 -2
  9. package/guides/shop/SKILL.md +6 -0
  10. package/lib/app-needs.mjs +27 -11
  11. package/lib/app-ready.mjs +1 -1
  12. package/lib/build.mjs +4 -3
  13. package/lib/check.mjs +47 -15
  14. package/lib/dev.mjs +9 -1
  15. package/lib/fork.mjs +47 -0
  16. package/lib/jobs.mjs +1 -0
  17. package/lib/mcp-tools.mjs +1 -1
  18. package/lib/needs-headless.mjs +4 -3
  19. package/lib/scaffold.mjs +27 -1
  20. package/lib/shop-links.mjs +39 -21
  21. package/lib/shop-mcp.mjs +13 -1
  22. package/lib/stripe-connect.mjs +37 -2
  23. package/lib/template-history.json +84 -0
  24. package/lib/tools-cli.mjs +25 -3
  25. package/lib/worker-config.mjs +1 -0
  26. package/package.json +1 -1
  27. package/rules/rules.ts +1 -1
  28. package/rules/view.ts +1 -1
  29. package/shop/SHOP.md +13 -9
  30. package/shop/shop.ts +3 -3
  31. package/tools/tools.ts +3 -2
  32. package/worker/app-format.mjs +6 -3
  33. package/worker/app-records.mjs +20 -6
  34. package/worker/app-shell.mjs +10 -5
  35. package/worker/app-streams.mjs +26 -9
  36. package/worker/app-tools.mjs +2 -1
  37. package/worker/app-web.mjs +23 -0
  38. package/worker/booking-calendar.mjs +26 -0
  39. package/worker/booking-holds.mjs +18 -5
  40. package/worker/booking-options.mjs +23 -0
  41. package/worker/booking-resources.mjs +55 -0
  42. package/worker/build-id.mjs +1 -1
  43. package/worker/chat-store.mjs +5 -4
  44. package/worker/customer-mcp.mjs +1 -1
  45. package/worker/event-resources.mjs +6 -1
  46. package/worker/index.mjs +74 -27
  47. package/worker/local-test.mjs +10 -0
  48. package/worker/mcp-events.mjs +2 -2
  49. package/worker/mcp-tools.mjs +20 -2
  50. package/worker/office.mjs +108 -6
  51. package/worker/operational-schema.mjs +30 -0
  52. package/worker/pages.mjs +7 -1
  53. package/worker/records-page.mjs +2 -2
  54. package/worker/request-db.mjs +53 -0
  55. package/worker/servers.mjs +5 -4
  56. package/worker/shop-links.mjs +1 -1
  57. package/worker/shop-page.mjs +1 -0
  58. package/worker/shop-rules.mjs +3 -2
  59. package/worker/shop-store.mjs +4 -3
  60. package/worker/shop.mjs +68 -8
  61. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,63 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.54.0] - 2026-10-15
21
+
22
+ **Plugin 0.54.0**
23
+
24
+ Studios answer faster, private apps stay private and usable, shops catch every payment, and the toolkit's CI costs a quarter of what it did.
25
+
26
+ ### Changed
27
+
28
+ - Reduce pull-request CI work through shared studio fixtures, focused compatibility coverage, and nightly release checks.
29
+
30
+ ### Fixed
31
+
32
+ - **Long-running app screens:** renew role handoffs on records, live and tools responses, reconnect idle live streams before expiry, and set handoffs on records-only pages. Revoked sessions, grants and rotated links still stop access. Remove the shared role secret from the phone address bar and subsequent API URLs.
33
+ - **Tool catalogue:** load and notify once per Worker isolate and database binding, then refresh after studio mutations; static requests no longer repeatedly load tool modules and serialize schemas.
34
+ - **Test assets:** require a valid test session for the same app, role and scope whenever a test-kind asset ticket is used.
35
+
36
+ - **Request path:** remove referral cleanup and function registration from startup; clean retired referral keys in migration 0031. Reuse the deployment catalogue, reuse stable-binding metadata caches guarded by a trigger-maintained revision, avoid repeat tool-catalogue notifications, and dispatch background work after actual writes. Public static assets require no D1 reads; private app assets retain live authorization.
37
+ - **Payment Links:** menu items support `maxQuantity` (default 99), slot links accept groups within published remaining capacity, and confirmation uses the paid seat quantity atomically. Payments that cannot be fulfilled remain visible as review orders with a durable staff event and customer notice. Full connect saves public JSON link assets, retaining replaced links until deployment.
38
+ - **Stripe setup:** observe deterministic product IDs before creating missing search results; keep public checkout mode out of secret uploads and report Wrangler's redacted error text.
39
+ - **Payment review:** single-item links enforce quantity caps at Stripe and fulfillment; old menu snapshots retain their published maximum. Signed payments with missing/unknown references or a currency mismatch remain visible for staff review without granting entitlements.
40
+ - **Background delivery:** include nested background work, preserve D1 sessions and raw-write tracking, and refresh connected AIs’ tool lists on the first ordinary request after deployment.
41
+ - **App checks:** needs without assertions are pending placeholders, rather than failed checks. They remain unproven: check reports not done with a pending label while preserving a nonfailing exit for placeholders.
42
+ - **App navigation:** Big screen uses a five-minute HttpOnly same-origin role session instead of copying the shared access secret; app context URLs include only a validated public room ID.
43
+ - **App subscriptions and privacy:** tool-data revisions and app wakes are scoped to the namespaces each app uses. Private asset capabilities support invite passes, signed-in sessions and tool/test identities, conceal session hashes with encryption plus HMAC, and recheck revocation and grants in one statement.
44
+ - **Prediction:** synchronous `room.me` readers share a presentation even when CPU work crosses a simulation tick; the next microtask refreshes it. Fixes the 60 Hz browser prediction smoke flake without relaxing thresholds.
45
+
46
+ ### Upgrade notes
47
+
48
+ - Apply migrations 0031–0033 with the normal studio deploy. Rebuild/deploy after full `shop connect` to publish its public link configuration. Review group availability and resync slot manifests after confirmations, moves or refunds; a Payment Link is not an exclusive unpaid hold.
49
+
50
+ ## [0.53.0] - 2026-10-15
51
+
52
+ **Plugin 0.53.0**
53
+
54
+ Take bookings against real resources, keep private apps usable, collect deposits reliably and turn tested examples into your own place.
55
+
56
+ ### Added
57
+
58
+ - **Bookings that fit your place:** Reserve shared resources alongside booking capacity, allocate packages, and use seasonal calendars in your place's time zone. Shared phone controls let customers choose booking options and resources; common booking argument aliases work across tools.
59
+ - **Keep upcoming slots ready to sell:** Declared `booking.slots_need_payment_links` events let your studio respond when rolling slot inventory needs payment links.
60
+ - **Apps customers can install:** Public apps can offer a direct install page with a web manifest and optional service worker. Add your own icons and offline behavior; installation support depends on the browser.
61
+ - **A quicker start from an example:** Fork editable source with new studio and app identities and fresh deployment settings. A single concept flag marks previews with a TEST / CONCEPT banner and keeps them out of the directory and search indexing.
62
+ - **Turn-based games without unnecessary physics:** Participants can have private roles and timed phases without needing physics bodies. Private fields remain visible only to their owning player.
63
+ - **Simpler Stripe setup:** `shop observe` accepts incremental public responses from Stripe's official plugin and returns the next setup plan without a hand-built observations file. New Stripe sign-ins show pairing instructions in the job's progress before approval completes.
64
+
65
+ ### Fixed
66
+
67
+ - **Deposits tied to the right booking:** Supporter and deposit links keep quantity at one. Variable deposits retain the app reference and require the saved subtotal before fulfillment; booking and AI checkout also preserve the reference in the saved order.
68
+ - **Private apps that load safely:** Authorized private app frames can load their modules through expiring asset links while anonymous modules and records stay closed. Missing record IDs explain exactly where to supply the ID.
69
+ - **Checks that reflect each screen's role:** Wall and phone permissions remain separate, each need starts in a fresh room after the baseline, and saving check evidence no longer restarts development. Brief Cloudflare startup failures retry with a visible notice.
70
+ - **Smoother shop upgrades:** Existing billing portals are discovered when older verifications lack their IDs. Webhook setup fills missing account IDs after checking link ownership with the saved Stripe login; reused logins no longer show unnecessary pairing instructions.
71
+
72
+ ### Upgrade notes
73
+
74
+ - Apply `0030_booking_resources.sql` after the existing booking migrations to use resource-aware bookings.
75
+ - Concept mode labels a preview; it does not isolate payment credentials or data. Installed public app surfaces run as same-origin studio code; private app shells keep their sandbox.
76
+
20
77
  ## [0.52.0] - 2026-10-15
21
78
 
22
79
  **Plugin 0.52.0**
package/apps/apps.ts CHANGED
@@ -76,3 +76,20 @@ export function createAppContext() {
76
76
  dispose() {removeEventListener('message',receive);listeners.clear();}
77
77
  };
78
78
  }
79
+
80
+ /** Native accessible choices; submit value() to the same booking tool used by an AI. */
81
+ export function createBookingChoices({container,options=[],resources=[]}:{container:HTMLElement;options?:Array<{id:string;name:string}>;resources?:Array<{id:string;name:string}>}) {
82
+ const fields=new Map<string,HTMLSelectElement>();
83
+ const group=document.createElement('fieldset');
84
+ const legend=document.createElement('legend');legend.textContent='Choose an option';group.append(legend);
85
+ for(const [name,choices] of [['option',options],['resource',resources]] as const) {
86
+ if(!choices.length)continue;
87
+ const label=document.createElement('label');label.textContent=name==='option'?'Option':'Resource';
88
+ const select=document.createElement('select');select.name=name;
89
+ const empty=document.createElement('option');empty.value='';empty.textContent=name==='option'?'Choose an option':'Any available resource';select.append(empty);
90
+ for(const choice of choices){const option=document.createElement('option');option.value=choice.id;option.textContent=choice.name;select.append(option);}
91
+ label.append(select);group.append(label);fields.set(name,select);
92
+ }
93
+ container.append(group);
94
+ return {value:():{option?:string;resource?:string}=>Object.fromEntries([...fields].filter(([,select])=>select.value).map(([name,select])=>[name,select.value])),dispose:()=>group.remove()};
95
+ }
@@ -911,6 +911,7 @@ async function main() {
911
911
  }
912
912
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
913
913
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
914
+ if(cmd==='fork')return (await import('../lib/fork.mjs')).forkStudio(positional[1],positional[2],{name:flags.get('name'),slug:flags.get('slug'),ids:flags.get('ids')?JSON.parse(flags.get('ids')):{}});
914
915
  const root = requireStudio();
915
916
  // Keep guidance visible to AI callers using --json as well as human output.
916
917
  if (['build', 'check', 'upgrade'].includes(cmd)) {
@@ -1111,18 +1112,23 @@ async function main() {
1111
1112
  if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), giving: flags.has('giving'), currency: flags.get('currency') ?? null, price: flags.get('price') ?? 500, managed: flags.has('managed') });
1112
1113
  if(sub==='listen')return (await import('../lib/shop-listen.mjs')).shopListen(root,{profile:flags.get('profile'),port:flags.get('port')??8787,log});
1113
1114
  if (sub === 'check') return shopCheck(root);
1115
+ if(sub==='observe') {
1116
+ const {recordShopObservations}=await import('../lib/shop-mcp.mjs');
1117
+ const input=JSON.parse(readFileSync(0,'utf8'));
1118
+ return recordShopObservations(root,Array.isArray(input)?{observations:input}:input);
1119
+ }
1114
1120
  if (sub === 'plan' || sub === 'verify') {
1115
1121
  const {shopMcpPlan,saveMcpVerification}=await import('../lib/shop-mcp.mjs');
1116
1122
  const file=flags.get('have');
1117
1123
  const input=file?JSON.parse(readFileSync(file==='-'?0:file,'utf8')):{};
1118
1124
  return (sub==='verify'?saveMcpVerification:shopMcpPlan)(root,input);
1119
1125
  }
1120
- if (sub === 'webhook') return shopConnect(root,{webhookOnly:true,live:flags.has('live'),renew:flags.has('renew'),profile:flags.get('profile'),log:line=>process.stderr.write(`${line}\n`)});
1126
+ if (sub === 'webhook') return shopConnect(root,{onEvent:event=>process.stderr.write('HOMIE_EVENT '+JSON.stringify(event)+'\n'),webhookOnly:true,live:flags.has('live'),renew:flags.has('renew'),profile:flags.get('profile'),log:line=>process.stderr.write(`${line}\n`)});
1121
1127
  if (sub === 'connect' && flags.has('local')) {
1122
1128
  if(flags.has('live'))throw new Error('Local checkout requires test mode');
1123
1129
  return (await import('../lib/shop-local.mjs')).shopConnectLocal(root,{url:flags.get('url'),profile:flags.get('profile')});
1124
1130
  }
1125
- if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
1131
+ if (sub === 'connect') return shopConnect(root, { onEvent:event=>process.stderr.write('HOMIE_EVENT '+JSON.stringify(event)+'\n'), managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
1126
1132
  if (sub === 'disconnect') return shopDisconnect(root);
1127
1133
  if (sub === 'catalog') {
1128
1134
  const from = flags.get('have');
@@ -1388,7 +1394,7 @@ try {
1388
1394
  // A shared progress feed sends its last word before the command exits.
1389
1395
  await flushProgress();
1390
1396
  if (result && result.command !== 'help') print(result);
1391
- if (result?.ok === false) process.exitCode = 1;
1397
+ if (result?.ok === false) process.exitCode = result.command==='check' && result.pending===true && result.exitCode===0 ? 0 : 1;
1392
1398
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
1393
1399
  if (['check', 'port check', 'look', 'perf', 'shoot', 'lab check'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1394
1400
  } catch (error) {
@@ -0,0 +1,9 @@
1
+ # Keyless shop setup and migration
2
+
3
+ Use `stripe_shop_plan` with Stripe's official plugin to perform the returned operations. Supply each new response as `{method,path,params,body}` in `observations`; earlier responses are saved automatically. Product and price discovery uses metadata searches. Payment Links use Stripe's paginated list API with expanded line items, then match studio, origin and revision metadata locally (Stripe has no Payment Link search endpoint). No creation is planned before discovery completes.
4
+
5
+ For CLI workflows, pipe an array of these responses, or `{observations:[...]}`, into `homie-studio shop observe`. It saves the responses and returns the next dependency batch. `shop plan` resumes without `--have`. Never include credentials or webhook creation responses.
6
+
7
+ Old verifications without a billing portal ID discover and reuse the existing portal before planning creation. Old verifications without an account ID adopt the official CLI's signed-in account only after reading and matching their saved Payment Links. A known different account is still refused. Verification freshness and configuration checks still apply.
8
+
9
+ `stripe_webhook` returns a job. Read `studio_job`: while browser approval is pending, `events` contains `{type:"stripe.pairing",code,url}`. Show that code and URL to the owner. A saved valid Stripe login emits no pairing event and needs no new approval. Secrets never enter the event.
@@ -0,0 +1,49 @@
1
+ # App references, private frames and checks
2
+
3
+ `shop.book(item, {collection, slot}, quantity, {reference})` accepts your app's booking reference. `studio_checkout` over MCP accepts `reference` alongside `lines`, `game` and optional `booking: {collection,slot}`. The saved order and `order.paid` event retain this value. It identifies your record; it is not proof of payment. Confirm the signed payment before fulfilling it.
4
+
5
+ HTTP record creation puts the ID in `/<app>/api/app/records/<collection>/<id>` and fields in `{data:{...}}`. The `app_records` MCP tool and `ctx.records` put `id` at the top level of their arguments, alongside `app` and `collection`. A missing HTTP ID now returns the exact URL format. IDs use 1–64 letters, digits, underscores or hyphens.
6
+
7
+ Private apps authorize the owner page first, then give its sandboxed frame a signed asset path valid for five minutes. Relative modules and chunks retain that path, without the owner's cookie. Asset capabilities cannot read records, call tools or authorize room sockets. Reload the owner page for a new path after expiry. Responses are private and uncached; unsigned private modules and records stay closed.
8
+
9
+ `check` gives the baseline action and every Codex need separate browser contexts and fresh rooms. Isolated record namespaces follow those rooms. `check --url https://studio.example` uses the saved owner MCP connection to mint 15-minute app-role sessions. These sessions cannot grant global owner access or access another app, and their record writes stay in the test namespace. Test slots never emit production payment-link work. With `--no-isolate`, records are explicitly shared. A need's `role` applies to its primary surface (`surface`, the first action’s surface, or the phone/matching role in observation-only needs); other surfaces retain their declared roles. Use an individual step's `role` for an explicit override. `--shots <directory>` captures passing and failing UI needs.
10
+
11
+ `dev` ignores changes to the authoring `CODEX.md` when detecting runtime edits, so saving needs evidence does not interrupt an active check. It reports when its requested port is busy and gives the actual URL. `check` without `--url` follows the running studio's registered dev URL. MCP connections retry a brief Cloudflare 1042 startup response up to three times and report each retry. Other errors return normally. Records-only apps do not inherit game-player cost estimates.
12
+
13
+ For turn-based games, a `player:true` entity can omit `body` and movement code. Declare its hidden role with `f.text(32, {visibility:'owner'})`; use `visibility:'server'` for secrets no client should receive. Keep hidden roles out of public shared fields and effects. The server projects snapshots for the receiving seat, including spectators. Use `room.start` and `room.on` with `world.after(ticks, event, data)` for phase deadlines; timers belong to the room and require no physics body. The normal server host remains required for secrecy.
14
+
15
+ Set one studio flag, `"concept":true`, and build for a concept preview. The site returns no directory manifest, disallows indexing and displays a fixed TEST / CONCEPT banner on HTML surfaces. Remove the flag and rebuild to leave concept mode. This labels a test site; it does not sandbox payment credentials or data.
16
+
17
+ A public records-only app can opt into a direct install surface:
18
+
19
+ ```json
20
+ {"web":{"manifest":{"name":"Guest app","short_name":"Guest","icons":[]},"serviceWorker":"sw.js"}}
21
+ ```
22
+
23
+ Place the service worker in the app's public files. `/app-id/install` serves the compiled app directly, with a manifest and optional service-worker registration. Records and app-tool helpers use the direct bridge. Add suitable icons and implement the offline behaviour in your service worker. The installed surface runs as same-origin studio code; signed-in/private app shells retain their sandbox. The manifest scope is the app's address, and installation support depends on the browser.
24
+
25
+ Fork editable example source in one command:
26
+
27
+ ```sh
28
+ homie-studio fork /path/to/example /path/to/new-place --name 'New Place' --slug new-place
29
+ ```
30
+
31
+ The command renames the studio, app/game IDs, record/slot IDs declared in JSON or JavaScript/TypeScript objects and matching source references. Use `--ids '{"old-id":"new-id"}'` for explicit overrides or long names. It excludes local state, credentials, dependencies, generated output and deployment identities, then generates a fresh Worker configuration. It refuses an existing destination and leaves the example unchanged. Install dependencies and run the returned check before publishing.
32
+
33
+ Supporter and deposit Payment Links have quantity fixed at one. For a variable deposit, declare `kind:"deposit", price:"choose"`, then call `shop.buy(id, 1, amount, {reference})`. Amounts use currency minor units. The link prefills the saved amount and carries the order ID, which maps back to the app reference. A signed payment with a different subtotal does not fulfill the order. Compute and validate the amount in the app's trusted booking operation and compare the payment event to its saved booking amount before confirmation; a buyer-supplied reference alone grants no authority.
34
+
35
+ Declare a function or AI event feed for `booking.slots_need_payment_links`. For example, in `studio.json`:
36
+
37
+ ```json
38
+ {"mcp":{"events":{"slot-links":{"event":"booking.slots_need_payment_links","audience":"owner"}}}}
39
+ ```
40
+
41
+ Creating or updating a booking's slot record without a matching capacity link inserts the event in the same database transaction. Its `slots` array contains `app`, booking `collection`, slot collection `slots`, `slot`, `item` and `capacity`. The owner's AI reads the full published slot inventory, calls `stripe_shop_plan`, performs its operations through Stripe's official plugin, records responses, verifies, then builds the updated public configuration. Never create webhooks through model-visible plugin calls. Unsubscribed events are not retained. Free items require no payment link.
42
+
43
+ Resource-aware bookings declare `booking: {slots:"slots", item:"ticket", resources:"resources"}`. Resource records hold `capacity`, optional `skills` arrays and `attributes` objects. Slots hold ISO `start`/`end`, candidate `resources`, and optional required `skills`/`attributes`. `ctx.bookings.hold` accepts `resource` (preferred resource), `option` and `addons`. An option can contain `components`, each naming candidate `resources`, optional `skills`/`attributes`, `units`, `offsetMinutes` and `durationMinutes`. Every component consumes capacity; overlapping allocations are checked atomically by the database. Release frees them. Editing an allocated resource or moving an allocated booking is refused: release and allocate the replacement deliberately. Resource checkout requires the allocated hold ID, so a raw checkout cannot bypass capacity. Paid exclusive holds still require session checkout; keyless inventory cannot share an exclusive unpaid hold.
44
+
45
+ Set the app's IANA `timeZone`, then use `ctx.bookings.calendar({app,startDate,endDate,time,days,capacity})` for an inclusive season of multi-day slots. `durationMinutes` handles shorter slots; `weekdays` uses Sunday=0 through Saturday=6. Persist the returned records through `ctx.records`. DST gaps and ambiguous wall times are refused; supply an explicit offset timestamp for such exceptional slots.
46
+
47
+ The public `studio_book` MCP tool uses the same hold operation. It accepts `time` as `slot`, `preferredResource`/`preferred_resource` as `resource`, and `addons`, `addOns` or `add-ons` as either an array of IDs or an object of quantities. Conflicting aliases are refused. Supply a stable request `id`, `expiresAt` within 15 minutes (or the booking declaration’s `holdMinutes`, up to one day), and a private random hex `token` for anonymous bookings. Keep that token private for later management.
48
+
49
+ The phone helper `createBookingChoices({container,options,resources})` from `@homie-rocks/studio/apps` renders accessible native selects. Submit its `value()` (`{option,resource}`) to the app's trusted booking tool. The phone and customer AI therefore use the same option/resource IDs and capacity rules. Call `dispose()` when the surface closes.
@@ -26,7 +26,21 @@ export default defineFunction<{ order: string }>({
26
26
  ```
27
27
 
28
28
  Build discovers and type-checks `functions/*.ts`. Handlers have the studio tool
29
- context and run with the studio's authority. They can change records, send a
29
+ context and run with the studio's authority. Handlers can call `ctx.records` (or
30
+ `context.records` when the parameter is named `context`) to read, create, update
31
+ and delete declared app records. Supply `app`, `collection`, and `id` at the top
32
+ level; put the record fields inside `data`. Updates and deletes also require
33
+ the current `version`. These calls use the same validation and permissions as
34
+ the app record API.
35
+
36
+ ```ts
37
+ await context.records({
38
+ app: 'calendar', collection: 'notes', id: event.id,
39
+ operation: 'create', data: { message: 'Ready' },
40
+ });
41
+ ```
42
+
43
+ Handlers They can change records, send a
30
44
  room event through declared capabilities, call an external service, or implement
31
45
  another business workflow. Treat event data as data, not instructions.
32
46
 
@@ -121,3 +121,26 @@ Document the exact commands and results; an empty assertion list is not proof.
121
121
  and verify a clean checkout can build it. Declare only packages actually used.
122
122
  - No invented reviews, impact figures or verification. Label estimates and test
123
123
  payments honestly; remove stale workaround claims when upgrading.
124
+
125
+ ## Booking toolkit and customer-AI arguments
126
+
127
+ Use declared booking slots and resources instead of duplicating capacity logic.
128
+ `studio_book` accepts `slot` or `time`, `resource`, `preferredResource` or
129
+ `preferred_resource`, and `addons`, `addOns` or `add-ons` as an array of IDs or an
130
+ object of quantities. Contradictory aliases are refused. Use the same option and
131
+ resource IDs in `createBookingChoices` on the phone. A booking may allocate
132
+ multiple resource components, with skills, attributes and capacity checked by
133
+ the server. Use the app's IANA `timeZone` and `ctx.bookings.calendar` for dated or
134
+ multi-day seasons. Public holds need a stable ID, bounded expiry and a private
135
+ management token for anonymous customers.
136
+
137
+ Pass the app's booking reference to `shop.book` or MCP `studio_checkout`. For
138
+ variable deposits use an amount-based link and validate the paid amount against
139
+ the saved booking. Declare `booking.slots_need_payment_links` so the owner's AI
140
+ can prepare links for newly rolled dates through Stripe's official plugin.
141
+ Use `shop observe` to record plugin responses, then verify and build.
142
+
143
+ The toolkit's `docs/toolkit-batch.md` documents resource declarations, install
144
+ surfaces, private frames, concept previews, example forks and isolated live-site
145
+ checks. Record IDs belong at the top level of `ctx.records`/`app_records`
146
+ arguments, alongside `app` and `collection`.
@@ -101,9 +101,9 @@ The Codex's `needs` fenced block is a JSON array, one entry per need from the re
101
101
  ]
102
102
  ```
103
103
 
104
- Steps support `fill`, `select`, `click: true` and `reload: true`; surfaces are `wall`, `phone`, `phone-two`, `tablet`, `kiosk` and `staff` (a tablet viewport). Set a need’s `role` to the declared role to prove. Each assertion names its surface; only the named screens are required. Assertions use a visible selector with optional contained text, or an observed effect. An honestly unavailable capability remains in the block with no assertions and a plain explanation in the prose; check reports it not met. Do not delete, weaken or replace a need to get green. Static needs such as accurate prices may have no action steps. Use semantic attributes on real visible scene elements for testability, never hidden QA-only counters.
104
+ Steps support `fill`, `select`, `click: true` and `reload: true`; surfaces are `wall`, `phone`, `phone-two`, `tablet`, `kiosk` and `staff` (a tablet viewport). Set a need’s `role` to the declared role to prove. Each assertion names its surface; only the named screens are required. Assertions use a visible selector with optional contained text, or an observed effect. An honestly unavailable capability remains in the block with no assertions and a plain explanation in the prose; check reports it pending (a placeholder, not a failure or proof). Do not delete, weaken or replace a need to get green. Static needs such as accurate prices may have no action steps. Use semantic attributes on real visible scene elements for testability, never hidden QA-only counters.
105
105
 
106
- Run build and the shared-action check, then the needs step runs on those real browsers. Save captures with `--shots .checks`. The result contains each statement, met/partly/not, the assertion result and available captures of named surfaces; `.studio/checks/<id>-needs.json` drives the Codex's Needs status. Redraw `codex <id>` after checking. Passing a selector assertion is evidence, not a visual judgement: read every capture and perform the full flows as owner and customer using the playtest skill. If the picture fails the words, it is not done even with green assertions.
106
+ Run build and the shared-action check, then the needs step runs on those real browsers. Save captures with `--shots .checks`. The result contains each statement, met/partly/not/pending, the assertion result and available captures of named surfaces; `.studio/checks/<id>-needs.json` drives the Codex's Needs status. Redraw `codex <id>` after checking. Passing a selector assertion is evidence, not a visual judgement: read every capture and perform the full flows as owner and customer using the playtest skill. If the picture fails the words, it is not done even with green assertions.
107
107
 
108
108
  For staff flows, sign in through the existing local owner link and prove the transition in real Chrome. Keep evidence in the Codex alongside the automated flows. Confirm unauthorized writes are refused and records remain after all screens close. Never call an untested privileged path passed. Before final handover, show the need table with evidence and explicit remaining gaps. If the starter or a board of boxes is still visible, return to the scene design and build it.
109
109
 
@@ -210,3 +210,9 @@ and Stripe CLI `listen --all-snapshot --forward-to http://localhost:8787/api/sho
210
210
  recipe is in the installed toolkit’s `apps/APPS.md`, “Real Stripe test checkout on
211
211
  this computer”. It uses root `.dev.vars`, needs no public HTTPS or Cloudflare, and
212
212
  keeps the existing approved Stripe CLI session. Do not write a stand-in checkout.
213
+
214
+ ### Batch setup and reconciliation
215
+
216
+ Set each menu item's `maxQuantity` in `shop.json` when the studio has an order limit; the default is 99. Slot links support group quantities up to published remaining seats. Supply reviewed `confirmed` seat totals when resyncing: Stripe counts sessions, not people. The database confirms the entire paid group atomically. If a payment differs from the saved deposit/basket or a group no longer fits, inspect the `review` order and `payment.review` event, arrange fulfillment or refund, and show the customer the saved notice. Never discard a paid event.
217
+
218
+ Full `shop connect` and plugin verification both save public JSON assets; rebuild/deploy those assets before retiring replaced links. Checkout mode is a public Worker variable, not a secret. If a product search misses an existing deterministic ID, perform the planner's GET for that ID before creating it. Preserve Wrangler's diagnostic when setup fails.
package/lib/app-needs.mjs CHANGED
@@ -22,25 +22,40 @@ export function readAppNeeds(root, id) {
22
22
  return { needs, hash: createHash('sha256').update(source).digest('hex') };
23
23
  }
24
24
 
25
+ /** An action names its actor; an observation-only need prefers its phone or a matching role. */
26
+ export function primaryNeedSurface(need,roleFor=()=>undefined) {
27
+ const action=needSteps(need.steps).find(s=>s.surface)?.surface;
28
+ const assertions=(need.assertions??[]).filter(s=>s.surface);
29
+ return need.surface??action??assertions.find(s=>['phone','phone-two'].includes(s.surface))?.surface??assertions.find(s=>!need.role||roleFor(s.surface)===need.role)?.surface;
30
+ }
31
+
25
32
  /** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
26
33
  export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless, restart }) {
27
34
  const results = [];
28
35
  const appBarrier=ready;
29
36
  ready=async (s,options)=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s,options);};
30
- let activeRole;
31
- const surface = name => { const s = surfaces.find(s => s.name === name && (!activeRole || s.role === activeRole)) ?? surfaces.find(s => s.name === name); if (!s) throw new Error(`unknown surface ${name}`); return s; };
37
+ let activeRole, primarySurface;
38
+ const surface = (name, explicitRole) => { const role=explicitRole??(name===primarySurface?activeRole:undefined); const s=surfaces.find(s=>s.name===name&&(!role||s.role===role)); if(!s)throw new Error(role?`${name} requires role ${role}`:`unknown surface ${name}`);return s; };
32
39
  for (const need of plan.needs) {
33
40
  activeRole = need.role;
41
+ primarySurface = primaryNeedSurface(need,name=>surfaces.find(s=>s.name===name)?.role);
34
42
  const names = [...new Set([...needSteps(need.steps), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
35
43
  const saved = new Map();
36
44
  const run = async item => { if (!headless) throw new Error('headless MCP proof requires local dev and its test credential'); return headless(item,item.role ?? need.role); };
37
45
  let waiting = 'validating assertions';
38
46
  const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
39
47
  try {
40
- if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires at least one assertion (UI, result, records or file)');
48
+ if (need.assertions === undefined || Array.isArray(need.assertions) && !need.assertions.length) {
49
+ result.status = 'pending';
50
+ result.why = 'Placeholder: add assertions to prove this need';
51
+ report?.check?.(`need-${need.id}`, 'skip', { label: need.statement, note: result.why });
52
+ results.push(result);
53
+ continue;
54
+ }
55
+ if (!Array.isArray(need.assertions)) throw new Error('assertions must be an array');
41
56
  const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
42
- for (const name of names) { if(popupNames.has(name))continue; const s = surface(name); if (need.role && s.role !== need.role) throw new Error(`${name} requires role ${need.role}, got ${s.role ?? 'unspecified'}`); }
43
- for (const name of new Set(need.assertions.filter(a => a.effect).map(a => a.surface))) await surface(name).frame.evaluate(() => { window.__homieNeedsEffects = []; });
57
+ for(const item of [...needSteps(need.steps),...need.assertions])if(item.surface&&!popupNames.has(item.surface))surface(item.surface,item.role);
58
+ for(const target of new Set(need.assertions.filter(a=>a.effect).map(a=>surface(a.surface,a.role))))await target.frame.evaluate(()=>{window.__homieNeedsEffects=[];});
44
59
  const perform = async rawStep => {
45
60
  const step=resolveNeedResults(rawStep,saved);
46
61
  if(step.concurrent){
@@ -57,7 +72,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
57
72
  if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
58
73
  return;
59
74
  }
60
- const target=surface(step.surface);
75
+ const target=surface(step.surface,step.role);
61
76
  if(!step.reload)await ready(target);
62
77
  const { page } = target;
63
78
  if(step.capabilities){
@@ -68,7 +83,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
68
83
  if(Object.hasOwn(step,'clock')){await page.evaluate(value=>window.homieApp.setTestClock(value),step.clock);return;}
69
84
 
70
85
  waiting = `${step.surface}: ${step.reload ? 'reload and arrival readiness' : step.selector}`;
71
- if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); return; }
86
+ if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface,step.role)); return; }
72
87
  const act = fn => actInApp(target,step.selector,fn,{ready});
73
88
  if (step.click && step.popup) {
74
89
  let listener,timer;
@@ -118,7 +133,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
118
133
  continue;
119
134
  }
120
135
  waiting = `${assertion.surface}: ${assertion.effect ? assertion.effect + ' ' + (assertion.name ?? '') : assertion.selector + (assertion.text === undefined ? '' : ' containing ' + JSON.stringify(assertion.text))}`;
121
- const target=surface(assertion.surface);
136
+ const target=surface(assertion.surface,assertion.role);
122
137
  await ready(target);
123
138
  await waitInApp(target,waiting,async frame=>{
124
139
  if (assertion.effect) {
@@ -134,9 +149,10 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
134
149
  result.why = 'Assertions passed';
135
150
  } catch (error) { result.why = `Waiting for ${waiting}: ${error.message}`; }
136
151
  if (!shots) for (const name of names) result.why += `; ${name} capture not requested: rerun check with --shots .checks and inspect the scene`;
137
- if (shots) for (const name of names) {
138
- const file = join(shots, `${need.id}-${name}.jpg`);
139
- try { mkdirSync(shots,{recursive:true}); await ready(surface(name)); await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
152
+ if (shots) for (const target of surfaces.filter(s=>names.includes(s.name))) {
153
+ const name=target.name, suffix=surfaces.filter(s=>s.name===name).length>1?'-'+target.role:'';
154
+ const file = join(shots, `${need.id}-${name}${suffix}.jpg`);
155
+ try { mkdirSync(shots,{recursive:true}); await ready(target); await target.page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
140
156
  catch (error) { result.why += `; ${name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`; }
141
157
  }
142
158
  report?.check?.(`need-${need.id}`, result.status === 'met' ? 'pass' : 'fail', { label: need.statement, note: result.why });
package/lib/app-ready.mjs CHANGED
@@ -9,7 +9,7 @@ export async function appReady(surface, {timeout=60000}={}) {
9
9
  const cover=document.querySelector('[data-arrive]');
10
10
  return window.__shell?.link?.state==='online' && window.__shell?.arrival?.phase==='done' && (!cover || (getComputedStyle(cover).opacity==='0' && getComputedStyle(cover).visibility==='hidden'));
11
11
  },{timeout:Math.max(1,end-Date.now())});
12
- const frame=surface.page.frames().find(f=>/\/__game\//.test(f.url()));
12
+ const frame=surface.page.frames().find(f=>/\/(?:__game|__assets)\//.test(f.url()));
13
13
  if(!frame)throw new Error('app frame missing');
14
14
  await frame.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
15
15
  surface.frame=frame;return;
package/lib/build.mjs CHANGED
@@ -635,7 +635,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
635
635
  const bundle = bundleOf(dir);
636
636
  builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
637
637
  return {
638
- ...(g.kind === 'app' ? { kind: 'app', roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
638
+ ...(g.kind === 'app' ? { kind: 'app', ...(g.web?{web:g.web}:{}), ...(g.timeZone?{timeZone:g.timeZone}:{}), roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
639
639
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
640
640
  ...(ui ? { ui } : {}),
641
641
  // A rules game's round is its rules' own (`room.rounds`), and its movement a per-kind choice there: game.json's
@@ -689,7 +689,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
689
689
  mkdirSync(join(dist, '_site'), { recursive: true });
690
690
  if(publicMcpSetup(root)) {
691
691
  const verified=verifiedMcpConfig(root);
692
- writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
692
+ writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,purchases:existsSync(join(root,'.studio','stripe-purchase-links.json'))?JSON.parse(readFileSync(join(root,'.studio','stripe-purchase-links.json'),'utf8')):null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
693
693
  }
694
694
  writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
695
695
  const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
@@ -698,6 +698,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
698
698
  const lounge = loungeConfig(studio, rows, { audience: audienceOf(studio) });
699
699
  const catalogue = {
700
700
  studio: {
701
+ ...(studio.concept===true?{concept:true}:{}),
701
702
  name: studio.name, slug: studio.slug, version: STUDIO_VERSION, ...(studio.kind ? { kind: studio.kind } : {}),
702
703
  ...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
703
704
  theme,
@@ -716,7 +717,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
716
717
  ...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
717
718
  // The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
718
719
  // HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
719
- directory: directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
720
+ directory: studio.concept===true?false:directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
720
721
  canonical: domainOrigin(studio.cloudflare?.domain) ?? domainOrigin(studio.cloudflare?.url),
721
722
  // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
722
723
  ...(studio.template === true ? { template: true } : {}),
package/lib/check.mjs CHANGED
@@ -2,7 +2,7 @@ import {needsHeadless} from './needs-headless.mjs';
2
2
  import {randomBytes} from 'node:crypto';
3
3
  import {actInApp,appReady,observeEffects,forceWebglFailure,waitInApp,answerDialogs} from './app-ready.mjs';
4
4
  import {localCredential,localCookie} from './local-test.mjs';
5
- import { readAppNeeds, proveAppNeeds, saveAppNeeds, needSteps } from './app-needs.mjs';
5
+ import { readAppNeeds, proveAppNeeds, saveAppNeeds, needSteps, primaryNeedSurface } from './app-needs.mjs';
6
6
  /**
7
7
  * `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
8
8
  * when nobody says: lib/check-site.mjs; never the live site unless asked): two fresh
@@ -359,7 +359,7 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
359
359
  let plan;
360
360
  try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
361
361
  const secret=localCredential(root,url);
362
- if(isolate && !secret)return {ok:false,command:'check',why:'Fresh isolated records are on by default. Run dev in this studio and check its localhost URL, or explicitly use --no-isolate.'};
362
+
363
363
  log(isolate ? 'Fresh isolated records: on (this check only).' : 'Fresh isolated records: off; using existing records.');
364
364
  const dialogs=[];
365
365
  const proof = meta.check??{};
@@ -368,19 +368,34 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
368
368
  if(webglFailure&&!baseline&&!plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface)))return {ok:false,command:'check',why:'--webgl-failure requires UI steps or assertions'};
369
369
  const captureWarnings=[];
370
370
  const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
371
- let browser;
371
+ let browser, ownerClient;
372
372
  const surfaces = [];
373
- const room = `check-${randomBytes(10).toString('hex')}`;
373
+ let room = `check-${randomBytes(10).toString('hex')}`;
374
374
  try {
375
+ const remoteCookies=new Map();
376
+ const sessionFor=async role=>{
377
+ if(secret)return {name:'homie_test',value:localCookie(secret,{app:game,role,scope:isolate?room:null})};
378
+ if(!isolate)return null;
379
+ ownerClient??=await (await import('./tools-cli.mjs')).connectToolClient(root,url,{log});
380
+ const key=room+':'+role;
381
+ if(!remoteCookies.has(key)) {
382
+ const result=await ownerClient.callTool({name:'studio_check_session',arguments:{app:game,role,scope:room}});
383
+ if(result.isError)throw new Error(result.content?.[0]?.text??'Cannot create isolated check session');
384
+ const parsed=result.structuredContent??JSON.parse(result.content.find(c=>c.type==='text').text),data=parsed.data??parsed;
385
+ if(!data.token)throw new Error('Studio did not return its test-session token');
386
+ remoteCookies.set(key,{name:'homie_check',value:data.token});
387
+ }
388
+ return remoteCookies.get(key);
389
+ };
375
390
  if(baseline || plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
376
391
  const openSurface = async (name, role) => {
377
392
  const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
378
393
  if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
379
394
  role ??= meta.surfaces?.[name==='phone-two'?'phone':name] ?? (name==='staff'?'staff':undefined);
380
395
  if(!meta.roles?.[role])throw new Error(`${name}: unknown role ${role}`);
381
- if(meta.roles[role].signIn && !secret)throw new Error(`${name}: local staff proof needs dev's test session`);
396
+ if(meta.roles[role].signIn && !secret && !isolate)throw new Error(`${name}: local staff proof needs dev's test session`);
382
397
  const context=await browser.createBrowserContext();
383
- if(secret)await context.setCookie({name:'homie_test',value:localCookie(secret,{app:game,role,scope:isolate?room:null}),url,httpOnly:true,sameSite:'Strict'});
398
+ const cookie=await sessionFor(role);if(cookie)await context.setCookie({...cookie,url,httpOnly:true,sameSite:'Strict',secure:new URL(url).protocol==='https:'});
384
399
  const page=await context.newPage();
385
400
  answerDialogs(page,name,dialogs,report);
386
401
  await page.evaluateOnNewDocument(observeEffects);
@@ -417,16 +432,33 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
417
432
  }
418
433
  report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
419
434
  }
420
- for(const need of plan?.needs??[])for(const item of [...needSteps(need.steps),...(need.assertions??[])]) {
421
- if(!item.surface)continue;
422
- if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
423
- const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
424
- if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
435
+ const needs=plan?[]:null;
436
+ for(const need of plan?.needs??[]) {
437
+ // Baseline and every need get separate contexts, rooms and record namespaces.
438
+ for(const context of new Set(surfaces.map(surface=>surface.page.browserContext())))await context.close();
439
+ surfaces.length=0;
440
+ room=`check-${randomBytes(10).toString('hex')}`;
441
+ const declaredRole=name=>meta.surfaces?.[name==='phone-two'?'phone':name]??(name==='staff'?'staff':undefined);
442
+ const primary=primaryNeedSurface(need,declaredRole);
443
+ log(`Need ${need.id}: fresh room ${room}; records ${isolate?'isolated':'shared (--no-isolate)'}.`);
444
+ for(const item of [...needSteps(need.steps),...(need.assertions??[])]) {
445
+ if(!item.surface || (need.steps??[]).some(s=>s.popup===item.surface))continue;
446
+ const role=item.role??(item.surface===primary?need.role:undefined)??declaredRole(item.surface);
447
+ if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
448
+ }
449
+ needs.push(...await proveAppNeeds({plan:{...plan,needs:[need]},surfaces,shots,report,ready:appReady,root,restart:()=>import('./dev-restart.mjs').then(m=>m.requestDevRestart(root,url)),headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null,sessionFor})}));
425
450
  }
426
- const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, restart:()=>import('./dev-restart.mjs').then(m=>m.requestDevRestart(root,url)), headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
427
451
  if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
428
- const ok = !needs || needs.every(n => n.status === 'met');
429
- 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', webglFailure, isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
452
+ const outcome = appNeedsOutcome(needs);
453
+ return { ...outcome, ...(needs ? { needs } : {}), command: 'check', kind: 'app', webglFailure, isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
430
454
  } catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
431
- finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
455
+ finally { await ownerClient?.close().catch(()=>{}); await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
456
+ }
457
+
458
+ /** Pending is unfinished proof, not a failure of the baseline checks. */
459
+ export function appNeedsOutcome(needs) {
460
+ const failed=needs?.some(n=>!['met','pending'].includes(n.status));
461
+ if(failed)return {ok:false,exitCode:1,why:'Some needs from the person’s Codex are not yet met; see needs and captures'};
462
+ if(needs?.some(n=>n.status==='pending'))return {ok:false,pending:true,exitCode:0,why:'Pending needs are not yet proved.'};
463
+ return {ok:true,exitCode:0};
432
464
  }
package/lib/dev.mjs CHANGED
@@ -200,6 +200,7 @@ export function devConfig(root, remoteAi) {
200
200
  // Always retain the exact local binding configuration for CLI commands after a deploy.
201
201
  const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
202
202
  delete json.$schema;
203
+ json.vars={...json.vars,HOMIE_DEV:'1'};
203
204
  json.main = entry ?? abs(json.main);
204
205
  if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
205
206
  json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
@@ -300,6 +301,7 @@ export function rulesStamp(g) {
300
301
  const walk=dir=>{for(const entry of readdirSync(dir,{withFileTypes:true})) {
301
302
  if(['node_modules','.git','.wrangler','.studio','.port','.cache','.parcel-cache','.vite','.DS_Store','coverage','dist','codex'].includes(entry.name))continue;
302
303
  const file=join(dir,entry.name);
304
+ if(file===join(g.dir,'CODEX.md'))continue; // check writes authoring evidence, never runtime inputs
303
305
  if(entry.isDirectory())walk(file);
304
306
  else if(entry.isFile()){const st=statSync(file);rows.push(`${file}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`);}
305
307
  }};
@@ -350,6 +352,12 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
350
352
  m.on('close', done);
351
353
  m.on('error', done);
352
354
  });
355
+ const {functionDeploymentSQL}=await import('./functions-build.mjs');
356
+ const registerFunctions=()=>new Promise(resolve=>{
357
+ const child=spawn(bin,['d1','execute','DB','--local','--command',functionDeploymentSQL(root)],{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
358
+ child.on('close',code=>resolve(code===0));child.on('error',()=>resolve(false));
359
+ });
360
+ if(!await registerFunctions())return {ok:false,command:'dev',why:'Local function registration failed; fix the D1 error above before serving requests.'};
353
361
  const origin = `http://127.0.0.1:${port}`;
354
362
  const printedOrigin = `http://localhost:${port}`;
355
363
  log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
@@ -479,7 +487,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
479
487
  if (rebuilding || ending) return;
480
488
  const stamp = toolsStamp(); if (stamp === lastTools) return;
481
489
  lastTools = stamp; rebuilding = true;
482
- try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); }
490
+ try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); if(!await registerFunctions())throw new Error('Local function registration failed'); }
483
491
  catch (error) { log(`Studio tools did not build: ${error.message}`); }
484
492
  finally { rebuilding = false; }
485
493
  };