@eventmodelers/cli 2.0.1 → 2.0.2

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 (72) hide show
  1. package/README.md +26 -9
  2. package/cli.js +320 -29
  3. package/lib/build-kits.js +23 -0
  4. package/lib/scaffold-model.js +71 -0
  5. package/lib/scaffold.js +224 -0
  6. package/lib/supervise.js +60 -0
  7. package/lib/ui-baseline.js +123 -0
  8. package/package.json +1 -1
  9. package/shared/build-kit/lib/events-view.js +16 -3
  10. package/shared/build-kit/lib/ralph.js +19 -15
  11. package/shared/build-kit/lib/slice-run.js +2 -2
  12. package/shared/build-kit/lib/slice-state.js +5 -3
  13. package/shared/scaffold/LICENSE-em-scaffold +21 -0
  14. package/shared/scaffold/supabase/generate.mjs +290 -0
  15. package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
  16. package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
  17. package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
  18. package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
  19. package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
  20. package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
  21. package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
  22. package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
  23. package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
  24. package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
  25. package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
  26. package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
  27. package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
  28. package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
  29. package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
  30. package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
  31. package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
  32. package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
  33. package/shared/skills/learn-eventmodelers-api/SKILL.md +4 -4
  34. package/shared/skills/ui-contract/SKILL.md +6 -6
  35. package/shared/skills/update-slice-status/SKILL.md +1 -1
  36. package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
  37. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +1 -1
  38. package/stacks/axon/templates/build-kit/lib/prompt.md +14 -0
  39. package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
  40. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +1 -1
  41. package/stacks/blank/templates/build-kit/lib/prompt.md +14 -0
  42. package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
  43. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +1 -1
  44. package/stacks/classic/templates/build-kit/lib/prompt.md +14 -0
  45. package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
  46. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +1 -1
  47. package/stacks/kurrent/templates/build-kit/lib/prompt.md +14 -0
  48. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  49. package/stacks/modeling-kit/templates/kit/CLAUDE.md +9 -4
  50. package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
  51. package/stacks/node/templates/build-kit/lib/backend-prompt.md +1 -1
  52. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
  53. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +1 -1
  54. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +14 -0
  55. package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
  56. package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
  57. package/stacks/react/templates/build-kit/lib/backend-prompt.md +7 -7
  58. package/stacks/react/templates/build-kit/lib/kit.json +1 -1
  59. package/stacks/react/templates/build-kit/lib/prompt.md +29 -2
  60. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
  61. package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
  62. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +1 -1
  63. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
  64. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
  65. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
  66. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
  67. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +7 -7
  68. package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
  69. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +30 -3
  70. package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
  71. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +1 -1
  72. package/stacks/umadb/templates/build-kit/lib/prompt.md +14 -0
package/README.md CHANGED
@@ -137,13 +137,30 @@ Use skills in Claude Code with `/skill-name`:
137
137
 
138
138
  Which skills install depends on the chosen stack — see `stacks/<name>/templates/.claude/skills/`. `/connect`, `/learn-eventmodelers-api`, `/update-slice-status`, and `/request-feedback` have no stack-specific content and install into every stack from `shared/skills/` instead.
139
139
 
140
+ ## `scaffold`: a runnable app from your board
141
+
142
+ `scaffold <dir>` reads the board and generates an app skeleton around the build kits, so slice building starts from an app that already runs. `--type` picks what gets installed and generated:
143
+
144
+ | Type | Installs | You get |
145
+ |---|---|---|
146
+ | `supabase` (default) | `supabase` at the root, `supabase-react` in `web/` | Supabase email/password sign-in, a layout with one navigation entry per timeline, a page shell per screen at `web/src/pages/<ScreenTitle>.tsx`, `501 Not Implemented` for every modeled endpoint without a slice (a built route always wins), a "not built yet" placeholder in the UI, hot reload for both halves, `npm run up` |
147
+
148
+ Needs on the machine: Node 22, Docker, Supabase CLI, Flyway + Java (the command lists what is missing). The skeleton itself needs nothing beyond Node, on macOS, Linux and Windows: `npm run up` / `down` / `db:stop` / `user -- <email> <password>` / `check` all go through one plain script, `scripts/dev.mjs`. `<dir>` must be empty or new, or an earlier skeleton, which is refreshed from the current model — generated files are rewritten, pages and anything a builder wrote are kept. Titles come from the board verbatim; a screen's route comes only from a `route /orders` line in its description, the post-sign-in page from a "home page" line, and Bulma is used when the screen mockups use it. `--no-install` stops after generating, `--no-check` skips the checks; ports are picked free automatically and a refresh keeps them (`--port api=3200` pins one; repeatable).
149
+
150
+ **Your own templates.** `npx @eventmodelers/cli init-templates [paths...]` copies the built-in templates into `./templates/` for you to edit (`--list` shows the paths; name a file such as `web/src/app/Layout.tsx` or a folder such as `web/src/app` to copy only those, and take only what you mean to change — a copied template stops following the CLI's updates; existing files are kept unless `--force`). `scaffold` uses what it finds there and the built-in template for everything else. Or put `*.tmpl` files in a `templates/` folder in the app's root (or point `--templates <dir>` at one, which also works on the first run). A file there replaces the built-in template at the same path — `templates/web/src/app/Layout.tsx.tmpl` replaces `web/src/app/Layout.tsx` — and a path with no built-in counterpart is simply added. The same tokens apply (`__APP_TITLE__`, `__API_PORT__`, `__WEB_PORT__`, `__DB_PORT__`), and every refresh uses them again, so customisations survive a model change. Only `*.tmpl` files count, so nothing in `templates/` is compiled as project code. Files the scaffold writes once (pages, `extraEndpoints.ts`) are never overwritten whatever you put there; edit those in place.
151
+
152
+ The `supabase` skeleton generator is adapted from the em-scaffold skill by Adrian Mowat (MIT, `shared/scaffold/LICENSE-em-scaffold`).
153
+
140
154
  ## Everyday commands
141
155
 
142
156
  ```bash
143
157
  npx @eventmodelers/cli init --stack <name> # scaffold a stack + install + configure (alias: install)
144
158
  npx @eventmodelers/cli init --stack <name> --demo # same, plus a ready-made demo model in the kit's .slices/ to build against
159
+ npx @eventmodelers/cli scaffold <dir> --type supabase # app skeleton from the board (sign-in, navigation, a page per screen, 501 for unbuilt slices) + both kits installed, ready for `run`
145
160
  npx @eventmodelers/cli re-init # refresh an already-installed kit's scripts/skills only — never touches the root scaffold
146
161
  npx @eventmodelers/cli run # start the agent loop (ralph-claude.js) from the installed kit dir
162
+ npx @eventmodelers/cli run --all # every build kit in the project at once (this directory and up to two levels below, e.g. a scaffolded app's root and web/), prefixed output, Ctrl+c stops all; other flags (--agent, --handover, …) go to each
163
+ npx @eventmodelers/cli run --all --modeling # the same, plus a modeling agent running alongside the build kits
147
164
  npx @eventmodelers/cli run --local-ai [target] # board work only via a local/self-hosted model (ralph-local-ai.js) — does NOT build slices; build locally with --exec
148
165
  npx @eventmodelers/cli run --agent <name> # pick the agent: claude (default), opencode, codex, gemini — bare --agent lists them with their exact commands
149
166
  npx @eventmodelers/cli run --agent <name> --model <id> # …on a given model: -m for opencode/codex/gemini, --model for Claude, LOCAL_AI_MODEL for --local-ai
@@ -153,7 +170,7 @@ npx @eventmodelers/cli run --modeling # modeling-kit: warm Claude
153
170
  npx @eventmodelers/cli run --standalone # same, plus acting on board changes unprompted — and needs no install at all
154
171
  npx @eventmodelers/cli run --standalone --board-id <uuid> # …from any directory, against any board (see Power users)
155
172
  npx @eventmodelers/cli run --id <id> --name <label> # override this run's agent identity (id + display name); `init --name` persists a name instead
156
- npx @eventmodelers/cli run --handover # backend kit, this run only: finish slices that have a UI at `Handover` instead of `Done` (see Backend → UI handover)
173
+ npx @eventmodelers/cli run --handover # backend kit, this run only: finish slices that have a UI at `Waiting` instead of `Done` (see Backend → UI handover)
157
174
  npx @eventmodelers/cli config --handover on|off # persist that choice for the project (`handover` in .eventmodelers/config.json)
158
175
  npx @eventmodelers/cli fetch --context <name> # pull full slice detail for one context on the board into <kit-dir>/.slices/
159
176
  npx @eventmodelers/cli fetch --context <name> --slice-id <id> # same, then print just that slice
@@ -198,7 +215,7 @@ npx @eventmodelers/cli run --local
198
215
 
199
216
  and the agent starts building — `--local` skips platform config and credential lookup entirely, so it works straight out of `init` with nothing connected. Every TODO you fill in gets exercised on the next iteration against real slice data covering all four slice types (state change, state view, automation, translation), which is exactly the coverage a new stack's `build-*` skills need before it's worth promoting.
200
217
 
201
- **Backend → UI handover.** Besides the usual statuses a slice can be `Ready` (modeling done, waiting to be planned) and `Handover` (backend built, UI contract published, waiting for the UI kit). The flow is `Ready → Planned → InProgress → Handover → InProgress → Done`. Install with `init --handover` (or `re-init --handover`; switch later with `eventmodelers config --handover on|off`; for a single run `run --handover` overrides the setting without saving anything), which saves `"handover": true` in `.eventmodelers/config.json` — then a backend kit ends STATE_VIEW / STATE_CHANGE slices at `Handover` instead of `Done` (the `ui-contract` skill, Step 4, owns that rule); without the flag nothing changes: slices end at `Done`, exactly as before. A kit claims work from the status named in its `lib/kit.json` — `{ "pickupStatus": "Handover" }` for the `react` / `supabase-react` UI kits, absent (= `Planned`) for backend kits. A new UI kit ships that file and sets `Done` when its half is built; a new backend kit just invokes `ui-contract`. Needs a platform with the `Ready` / `Handover` statuses.
218
+ **Backend → UI handover.** Besides the usual statuses a slice can be `Ready` (modeling done, waiting to be planned) and `Waiting` (backend built, UI contract published, waiting for the UI kit). The flow is `Ready → Planned → InProgress → Waiting → InProgress → Done`. Install with `init --handover` (or `re-init --handover`; switch later with `eventmodelers config --handover on|off`; for a single run `run --handover` overrides the setting without saving anything), which saves `"handover": true` in `.eventmodelers/config.json` — then a backend kit ends STATE_VIEW / STATE_CHANGE slices at `Waiting` instead of `Done` (the `ui-contract` skill, Step 4, owns that rule); without the flag nothing changes: slices end at `Done`, exactly as before. A kit claims work from the status named in its `lib/kit.json` — `{ "pickupStatus": "Waiting" }` for the `react` / `supabase-react` UI kits, absent (= `Planned`) for backend kits. A new UI kit ships that file and sets `Done` when its half is built; a new backend kit just invokes `ui-contract`. Needs a platform with the `Ready` / `Waiting` statuses.
202
219
 
203
220
  **UI contract (handover only).** With handover on, the scaffolded `build-state-view` / `build-state-change` skills end by invoking the shared `ui-contract` skill, which publishes each slice's read (`GET`) or write (`POST`) contract — the endpoint's OpenAPI operation, as a ```` ```yaml ui-contract ```` block — as a `## UI Contract` section in the slice's feedback-lane markdown node (exported in `slice.json` `notes[]`, so a UI build kit can look it up directly). Two things are yours to decide: *where the contract is derived from* (point the `UI Contract` section of those skills at your stack's machine-readable API description, if it has one — the `node`/`supabase` stacks use their `@openapi` blocks via a `ui-contract-from-openapi.md` reference, taking the operation verbatim — otherwise the skill writes the equivalent OpenAPI operation from the route code), and, if you need a different shape or location, a whole-skill override: ship `.claude/skills/ui-contract/SKILL.md` in the stack's templates and it replaces the shared one (stack templates are applied after `shared/skills/`).
204
221
 
@@ -652,7 +669,7 @@ Pair it with `--id`: a `--standalone`/`--global` run mints a fresh id per run, s
652
669
 
653
670
  ### Backend → UI handover (`--handover`)
654
671
 
655
- A slice with a UI is only finished once two kits have each built their half: the backend kit builds the endpoint and publishes its contract, the UI kit builds the frontend against it. `--handover` makes the first kit stop at `Handover` instead of `Done`, so the second one can claim the slice.
672
+ A slice with a UI is only finished once two kits have each built their half: the backend kit builds the endpoint and publishes its contract, the UI kit builds the frontend against it. `--handover` makes the first kit stop at `Waiting` instead of `Done`, so the second one can claim the slice.
656
673
 
657
674
  ```bash
658
675
  npx @eventmodelers/cli init --stack node --handover # persisted: "handover": true in .eventmodelers/config.json (also re-init)
@@ -663,18 +680,18 @@ npx @eventmodelers/cli run --handover # this run only — writes
663
680
 
664
681
  | | Handover off (default) | Handover on |
665
682
  |---|---|---|
666
- | Backend kit, STATE_VIEW / STATE_CHANGE slice | ends at `Done`, no UI contract published | publishes the UI contract, ends at `Handover` |
683
+ | Backend kit, STATE_VIEW / STATE_CHANGE slice | ends at `Done`, no UI contract published | publishes the UI contract, ends at `Waiting` |
667
684
  | Backend kit, automation / translation | `Done` | `Done` — no UI counterpart |
668
- | UI kit (`react`, `supabase-react`) | claims `Handover` only | claims `Handover` only, sets `Done` when its UI is built |
685
+ | UI kit (`react`, `supabase-react`) | claims `Waiting` only | claims `Waiting` only, sets `Done` when its UI is built |
669
686
 
670
- Flow: `Ready → Planned → InProgress → Handover → InProgress → Done`. Precedence for a run is `run --handover` over the project's `handover` setting; the runner states the effective value to the agent as a `Handover: on|off` line in its task, so the agent never has to work it out from `config.json`. `config --handover` writes to the project's config (the installed kit's project, else the nearest `.eventmodelers/config.json` up the tree — never the account-wide one in `~/`).
687
+ Flow: `Ready → Planned → InProgress → Waiting → InProgress → Done`. Precedence for a run is `run --handover` over the project's `handover` setting; the runner states the effective value to the agent as a `Handover: on|off` line in its task, so the agent never has to work it out from `config.json`. `config --handover` writes to the project's config (the installed kit's project, else the nearest `.eventmodelers/config.json` up the tree — never the account-wide one in `~/`).
671
688
 
672
689
  **What to know before relying on it**
673
690
 
674
- - **UI kits only ever claim `Handover`.** Against a board whose backend finished slices at `Done`, a `react` / `supabase-react` kit has nothing to do and replies `NO_TASKS` — the backend side must run with handover on.
691
+ - **UI kits only ever claim `Waiting`.** Against a board whose backend finished slices at `Done`, a `react` / `supabase-react` kit has nothing to do and replies `NO_TASKS` — the backend side must run with handover on.
675
692
  - **Both sides need the new kit.** An already-installed backend kit keeps its old `ralph.js` and prompts and silently ignores `--handover` (slices still end at `Done`); `re-init` it to pick the feature up.
676
- - **The platform must know the `Ready` / `Handover` statuses.**
677
- - A kit declares what it claims in `lib/kit.json` — `{ "pickupStatus": "Handover" }`; absent means `Planned`.
693
+ - **The platform must know the `Ready` / `Waiting` statuses.**
694
+ - A kit declares what it claims in `lib/kit.json` — `{ "pickupStatus": "Waiting" }`; absent means `Planned`.
678
695
  - `run --status-trigger <status>` overrides that pickup status for a single run (e.g. `--status-trigger Ready`); nothing is saved.
679
696
 
680
697
  The contract itself is the shared `ui-contract` skill's job (see above); it also owns the rule for which status a slice ends at.
package/cli.js CHANGED
@@ -20,6 +20,10 @@ import { createInterface, emitKeypressEvents, moveCursor, clearScreenDown } from
20
20
  import { homedir, tmpdir } from 'os';
21
21
  import { randomUUID } from 'crypto';
22
22
  import { runFetch, FetchAuthError } from './lib/fetch.js';
23
+ import { SCAFFOLD_TYPES, scaffoldTypeNames, missingPrerequisites, runScaffold, builtInTemplates, initTemplates } from './lib/scaffold.js';
24
+ import { supervise } from './lib/supervise.js';
25
+ import { findBuildKitDirs } from './lib/build-kits.js';
26
+ import { buildModel } from './lib/scaffold-model.js';
23
27
  import { createModelingLocalAiRunner } from './lib/modeling-local-ai.js';
24
28
  import { run as runSpecKittyAdapter } from './lib/adapters/spec-kitty-adapter.js';
25
29
  import { latestPublishedVersion, updateHints } from './lib/update-check.js';
@@ -31,13 +35,14 @@ import { modelingPermissions } from './lib/agent-permissions.js';
31
35
  // shared/build-kit/lib/adapters/realtime-adapter.js for why it lives there instead.
32
36
  import { createRealtimeAdapter, keepSubscribed } from './shared/build-kit/lib/adapters/realtime-adapter.js';
33
37
  import { recordAgentStatus } from './shared/build-kit/lib/slice-run.js';
38
+ import { findSliceDir, fingerprint, readBaseline, writeBaseline, compare } from './lib/ui-baseline.js';
34
39
  import { defaultAgentName, withInstanceSuffix } from './shared/build-kit/lib/agent-name.js';
35
40
  import { acquireAgentId } from './shared/build-kit/lib/agent-lock.js';
36
41
  import { readEvents } from './shared/build-kit/lib/slice-events.js';
37
42
  import { findPromptEvent, replayRequest } from './shared/build-kit/lib/prompt-replay.js';
38
43
  import { prunableEvents, pruneLog, rememberPruneAnswer, shouldOfferPrune } from './shared/build-kit/lib/event-prune.js';
39
44
  import { fold } from './shared/build-kit/lib/slice-state.js';
40
- import { filterEvents, formatEvent, formatPayload, sliceTitles, summarize } from './shared/build-kit/lib/events-view.js';
45
+ import { filterEvents, baselineIds, formatEvent, formatPayload, sliceTitles, summarize } from './shared/build-kit/lib/events-view.js';
41
46
  import { stableGlobalAgentId } from './lib/global-agent-id.js';
42
47
  import { ensureLearningsGitRules } from './shared/build-kit/lib/ralph.js';
43
48
  import { createCallTracker, heldPromptsRequest, journalPromptAction, shouldJournalCall, journalPromptFinished, journalPromptReceived, recoverOwnPrompts } from './shared/build-kit/lib/prompt-journal.js';
@@ -463,6 +468,30 @@ function getSharedRl() {
463
468
  return sharedRl;
464
469
  }
465
470
 
471
+ // --force on a build kit: the credentials step of `init-config` against the project-root config the
472
+ // runner reads. Interactive, so under `run --all` the parent runs it for each kit one after the other —
473
+ // the supervised children have no stdin to ask on. `shared` (another kit's answer) is applied
474
+ // instead of asking, so every kit of one run ends up on the same credentials.
475
+ async function forceBuildKitCredentials(kitDir, cwd, opts, globalOpts, shared = null) {
476
+ const projectDir = dirname(kitDir);
477
+ const configPath = globalOpts.config
478
+ ? resolve(projectDir, globalOpts.config)
479
+ : join(projectDir, '.eventmodelers', 'config.json');
480
+ const cfg = await configureCredentials({
481
+ config: loadEffectiveConfig(cwd, kitDir, globalOpts.config).config,
482
+ configPath,
483
+ targetDir: projectDir,
484
+ requiredFields: ['organizationId', 'boardId', 'token'],
485
+ overrides: shared ?? credentialOverridesFromOpts(opts),
486
+ print: false,
487
+ force: !shared,
488
+ });
489
+ ensureMcpRegistered(projectDir, cfg.baseUrl || DEFAULT_BASE_URL);
490
+ ensureEnvToken(projectDir, cfg.token);
491
+ ensureMcpEnabled(projectDir);
492
+ return cfg;
493
+ }
494
+
466
495
  // Pulls one line from the shared readline's own async iterator rather than calling
467
496
  // its `.question()` — `.question()` attaches a one-shot 'line' listener *after* the
468
497
  // prompt is issued, but when stdin is piped (a file, `<<<`, scripted/CI input) readline
@@ -3286,11 +3315,11 @@ program
3286
3315
  // dir: a modeling run falls back to the global install (see ensureGlobalKit) rather than
3287
3316
  // requiring one here, and the build-kit branch reports a better-targeted error of its own
3288
3317
  // than this generic gate can.
3289
- const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
3318
+ const NO_INIT_REQUIRED = new Set(['init', 'scaffold', 'init-templates', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
3290
3319
 
3291
3320
  // Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
3292
3321
  // front of them would only be noise.
3293
- const NO_KIT_UPDATE_HINT = new Set(['init', 're-init', 'uninstall']);
3322
+ const NO_KIT_UPDATE_HINT = new Set(['init', 'scaffold', 'init-templates', 're-init', 'uninstall']);
3294
3323
 
3295
3324
  // Retired: still recognised, so an old script or muscle memory gets an explanation instead of "unknown option".
3296
3325
  // The hidden options and the `bridge` command exist only to say so; they do nothing else.
@@ -3372,13 +3401,13 @@ const AGENT_NAME_OPTION = [
3372
3401
  ];
3373
3402
 
3374
3403
  // `--handover` is the project policy for how a backend build kit finishes a slice with a UI:
3375
- // set, it ends STATE_VIEW / STATE_CHANGE slices at `Handover` (the UI kit builds the rest and sets
3404
+ // set, it ends STATE_VIEW / STATE_CHANGE slices at `Waiting` (the UI kit builds the rest and sets
3376
3405
  // `Done`); unset, it ends at `Done`, exactly as before. Persisted as `handover: true` in the project's
3377
3406
  // config.json — the `ui-contract` skill (Step 4) reads it there. Switch it later with
3378
3407
  // `eventmodelers config --handover on|off`.
3379
3408
  const HANDOVER_OPTION = [
3380
3409
  '--handover',
3381
- 'Backend build kit: finish slices that have a UI at status `Handover` (UI build kit takes over, then sets `Done`) instead of `Done`. Saved as `handover: true` in .eventmodelers/config.json; without it slices end at `Done`. Turn it off later with `eventmodelers config --handover off`.',
3410
+ 'Backend build kit: finish slices that have a UI at status `Waiting` (UI build kit takes over, then sets `Done`) instead of `Done`. Saved as `handover: true` in .eventmodelers/config.json; without it slices end at `Done`. Turn it off later with `eventmodelers config --handover off`.',
3382
3411
  ];
3383
3412
 
3384
3413
  function identityOverridesFromOpts(opts) {
@@ -3783,6 +3812,7 @@ credentialFlags(program
3783
3812
  .option('--agent [name]', `Pick the agent that does the work by name: ${Object.keys(AGENT_PRESETS).join(', ')}. claude is the default runner; any other is a shortcut for --exec with that harness's headless command filled in — plain-text output for --modeling/--standalone/--worker, JSON output (for agent tracing) for a build kit. Bare --agent lists the agents and the exact command each runs, then stops. Mutually exclusive with --exec and --local-ai.`)
3784
3813
  .option('--model <id>', 'The model the agent runs on, for this run only: `-m <id>` on the --agent opencode/codex/gemini command (e.g. opencode/big-pickle, gpt-5-codex, gemini-2.5-pro), `--model <id>` for Claude (overriding `model` from config), LOCAL_AI_MODEL for --local-ai. Not with --exec — put the harness\'s own model flag in the command there.')
3785
3814
  .option('--exec [command]', 'Fallback for any harness --agent does not cover, or other flags than its preset. Hand each prompt to an external agent command instead of the default Claude runner — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto", "gemini --yolo -p" or "opencode run". One process per prompt; the prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent. A build kit runs it via ralph-exec.js; there, with agent tracing on, include the harness\'s JSON output flag (codex exec --json, opencode run --format json, gemini --output-format stream-json) — without it no slice cost is recorded. With --modeling/--standalone/--worker it is how any agent takes board prompts and chats: the loop claims each prompt or chat message and runs the command once for it. Keep the harness in its plain-text output mode there — stdout is the turn\'s answer — the eventmodelers MCP server is registered for it on every run (opencode.json, .gemini/settings.json, or -c flags for codex; any other command logs what to register by hand), authenticating with $EVENTMODELERS_TOKEN from the env the command inherits, and `init-agents --hosts <harness>` installs the skills for a harness that does not read .claude/skills. No subagents: self-directed work is done inline.')
3815
+ .option('--all', 'Run every build kit in this project at once — the one in this directory and any up to two levels below it (a scaffolded app has `.build-kit` at the root and in `web/`, so this works from the app or from the folder holding it) — each from its own directory, output prefixed with the directory, Ctrl+c stops them all. Every other flag is passed on to each run. Not interactive: the kits\' credentials must already be configured (except with --force, which asks once per kit up front, here, before the runs start). With --modeling, the modeling agent runs alongside the build kits. Not with --standalone, --worker, --global, --local or --id (one id cannot belong to several agents).')
3786
3816
  .option('--modeling', 'Connect a modeling agent to the board: it takes prompts and chat messages and hands each to an AI — by default one Claude process kept warm across prompts, for low-latency voice/live use; --agent (or --local-ai, or --exec as the manual fallback) picks another. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
3787
3817
  .option('-f, --force', 'Ask for credentials again before the modeling loop starts, even when a config already has everything required. With a modeling kit in this directory it re-runs the same prompt as `init --modeling`, overwriting the project\'s .eventmodelers/config.json; with the global install it drops the "keep the stored credentials" choice, so a board\'s credentials have to be answered afresh. Applies to build-kit runs too, where it re-asks against the project\'s .eventmodelers/config.json. Cannot be combined with --non-interactive, --print or --local.')
3788
3818
  .option('--non-interactive', 'Never ask anything: take the board and credentials that resolve from flags, EVENTMODELERS_* env vars and the config files, and fail with the reason if they are incomplete instead of interviewing for them. A TTY was previously the only signal — right for CI or a supervisor, wrong for a loop started from a terminal, where stdin is a TTY nobody is watching and the run stops on a question. Only affects the modeling loop (--modeling/--standalone/--worker/--global).')
@@ -3794,8 +3824,8 @@ credentialFlags(program
3794
3824
  .option('--local', 'Skip platform config/credential lookup entirely and run the local-only loop (no board sync, no realtime agent) — even if .eventmodelers/config.json has credentials (build-kit stacks only)')
3795
3825
  .option('--dangerously-skip-permissions', 'Run the modeling agent\'s Claude process with --dangerously-skip-permissions, as before the lockdown: every tool call is approved — any shell command, curl to anywhere, any file the user can read or write, every MCP server the machine configures. Off by default because the agent takes chat messages and snippet clicks from any collaborator on the board; pass it only for a board whose collaborators you trust with your shell. Default Claude runner of --modeling/--standalone/--worker/--global only — not with --agent, --exec or --local-ai, which bring their own permission flags.')
3796
3826
  .option('--browser', 'Give the locked-down modeling agent a browser: the Playwright MCP server (npx @playwright/mcp) next to the eventmodelers one, for discover-storyboard. Off by default because a browser can reach any site, which the lockdown otherwise rules out. Default Claude runner only; --dangerously-skip-permissions already loads every MCP server the machine configures.')
3797
- .option('--handover', 'Backend build kit: for this run only, finish slices that have a UI at `Handover` instead of `Done` (same as the project setting `handover: true`, which `init --handover` / `config --handover on` save — this flag writes nothing). Without it the project setting applies; without both, slices end at `Done`.')
3798
- .option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned, Handover). Defaults to the kit\'s own pickup status — `Planned` for backend kits, `Handover` for the UI kits. Nothing is written to disk.')
3827
+ .option('--handover', 'Backend build kit: for this run only, finish slices that have a UI at `Waiting` instead of `Done` (same as the project setting `handover: true`, which `init --handover` / `config --handover on` save — this flag writes nothing). Without it the project setting applies; without both, slices end at `Done`.')
3828
+ .option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned, Waiting). Defaults to the kit\'s own pickup status — `Planned` for backend kits, `Handover` for the UI kits. Nothing is written to disk.')
3799
3829
  .option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
3800
3830
  .option('--id <id>', 'Pin the agent id this run identifies itself with on the platform. Without it an agent keeps one id: a project install per project, a --global/--standalone run per board and repo (the directory it is started in) — the same agent on every restart, with its chat, its star on the board and the prompts it holds. If another live process already runs under that id, this one gets a fresh id instead (two agents must not share a heartbeat row). Pass --id when a supervisor already knows the id you want, or for a second agent on purpose; it is used as given, with no such check. Per-run only: nothing is written to disk.')
3801
3831
  .option('--name <name>', 'A human-readable name for this agent, sent with every heartbeat so the board shows which agent is live rather than a bare uuid (e.g. "ci-builder", "martins-laptop"). Per-run only, like --id: the persistent name is `agentName` in config.json (set via `init --name` / `init-config --name`), and this overrides it for one run without writing anything.')
@@ -3803,6 +3833,49 @@ credentialFlags(program
3803
3833
  .action(async (opts, command) => {
3804
3834
  const globalOpts = command.optsWithGlobals();
3805
3835
  const cwd = process.cwd();
3836
+ if (opts.all) {
3837
+ const clashing = [['--standalone', opts.standalone], ['--worker', opts.worker], ['--global', opts.global], ['--local', opts.local], ['--id', opts.id !== undefined]]
3838
+ .filter(([, set]) => set).map(([flag]) => flag);
3839
+ if (clashing.length) {
3840
+ console.error(`❌ --all runs build kits and cannot be combined with ${clashing.join(', ')}.`);
3841
+ process.exit(1);
3842
+ }
3843
+ const kitDirNames = [...new Set(Object.values(STACKS).map((st) => st.kitDirName))];
3844
+ const dirs = findBuildKitDirs(cwd, kitDirNames);
3845
+ if (!dirs.length && !opts.modeling) {
3846
+ console.error(`❌ No build kit found in this directory or up to two levels below it (looked for ${kitDirNames.join(', ')}).`);
3847
+ process.exit(1);
3848
+ }
3849
+ // Each kit runs as its own `run` with whatever else was typed; only --all is dropped, and
3850
+ // --modeling too — that one is its own run, below, not something a build kit takes.
3851
+ // --force is the one flag that asks questions, and the children have no terminal to ask on
3852
+ // (the supervisor pipes their stdin, so the first prompt dies with ERR_USE_AFTER_CLOSE). The
3853
+ // credentials step runs here once per kit instead, then --force is not forwarded.
3854
+ if (opts.force) {
3855
+ if (opts.nonInteractive || globalOpts.print) {
3856
+ console.error('❌ --force asks for credentials again, which --non-interactive/--print rule out — pick one.');
3857
+ process.exit(1);
3858
+ }
3859
+ // Asked once, for the kit in this directory (the first one found when there is none), and
3860
+ // handed to the others — the modeling agent reads this directory's config.
3861
+ const rootDir = dirs.find((dir) => dirname(dir) === cwd) ?? dirs[0];
3862
+ console.log(`\n🔑 Credentials for ${relative(cwd, rootDir) || '.'}, shared by all`);
3863
+ const { organizationId, boardId, token, baseUrl } = await forceBuildKitCredentials(rootDir, cwd, opts, globalOpts);
3864
+ for (const dir of dirs.filter((d) => d !== rootDir)) {
3865
+ await forceBuildKitCredentials(dir, cwd, opts, globalOpts, { organizationId, boardId, token, baseUrl });
3866
+ }
3867
+ if (sharedRl) sharedRl.close();
3868
+ console.log('');
3869
+ }
3870
+ const forwarded = process.argv.slice(2).filter((a) => a !== '--all' && a !== '--modeling' && !(opts.force && a === '--force'));
3871
+ const cliJs = join(__dirname, 'cli.js');
3872
+ const specs = dirs.map((dir) => ({ name: relative(cwd, dir) || '.', file: process.execPath, args: [cliJs, ...forwarded], cwd: dir }));
3873
+ // --modeling adds the modeling agent next to the build kits: one process, from this directory
3874
+ // (its modeling kit, or the global install when there is none), like a plain `run --modeling`.
3875
+ if (opts.modeling) specs.push({ name: 'modeling', file: process.execPath, args: [cliJs, ...forwarded, '--modeling'], cwd });
3876
+ console.log(`▶ Running ${dirs.length} build kit(s)${opts.modeling ? ' + the modeling agent' : ''}: ${specs.map((sp) => sp.name).join(', ')} (Ctrl+c stops all)\n`);
3877
+ process.exit(await supervise(specs));
3878
+ }
3806
3879
  // Validated up front, before any runner is selected: a cost guard given as garbage
3807
3880
  // should fail on the spot, not once the loop is already up — and a cap passed where
3808
3881
  // nothing will read it is worth saying out loud rather than ignoring silently.
@@ -4019,22 +4092,7 @@ credentialFlags(program
4019
4092
  console.error('❌ --force asks for credentials, which --local (no platform config) never uses — pick one.');
4020
4093
  process.exit(1);
4021
4094
  }
4022
- const projectDir = dirname(kitDir);
4023
- const configPath = globalOpts.config
4024
- ? resolve(projectDir, globalOpts.config)
4025
- : join(projectDir, '.eventmodelers', 'config.json');
4026
- const cfg = await configureCredentials({
4027
- config: loadEffectiveConfig(cwd, kitDir, globalOpts.config).config,
4028
- configPath,
4029
- targetDir: projectDir,
4030
- requiredFields: ['organizationId', 'boardId', 'token'],
4031
- overrides: credentialOverridesFromOpts(opts),
4032
- print: false,
4033
- force: true,
4034
- });
4035
- ensureMcpRegistered(projectDir, cfg.baseUrl || DEFAULT_BASE_URL);
4036
- ensureEnvToken(projectDir, cfg.token);
4037
- ensureMcpEnabled(projectDir);
4095
+ await forceBuildKitCredentials(kitDir, cwd, opts, globalOpts);
4038
4096
  }
4039
4097
 
4040
4098
  // ralph-claude.js hands every turn to a `claude` that only discovers MCP servers from
@@ -4160,6 +4218,162 @@ program
4160
4218
  }
4161
4219
  });
4162
4220
 
4221
+ program
4222
+ .command('scaffold <target> [args...]')
4223
+ .description('`scaffold add-user <email> <password> [dir]` adds a sign-in user to a scaffolded app (run in its folder, or pass dir). Otherwise: generate a runnable app skeleton from the board — sign-in, navigation with one entry per timeline, a page shell per screen, 501 for every unbuilt endpoint, hot reload — and install the build kits of the chosen --type into it, ready for `run` to add slices. <target> is an empty or new directory, or a skeleton made earlier (which is refreshed from the current model).')
4224
+ .option('--type <name>', `Kind of app to scaffold (${scaffoldTypeNames().join(', ')})`, 'supabase')
4225
+ .option('--no-install', 'Stop after generating the skeleton: no npm install, checks or commit')
4226
+ .option('--no-check', 'Install dependencies but skip the type-check, tests and web build/lint')
4227
+ .option('--templates <dir>', 'Folder of *.tmpl files that replace the built-in skeleton template of the same path (or add files). Default: the target\'s own templates/ folder when it exists, so customisations survive a refresh')
4228
+ .option('--port <name=value>', "Pin one of the type's ports instead of picking a free one, e.g. --port api=3200 (repeatable; the names are listed in the error when one is unknown). A refresh keeps its ports unless pinned here", (v, all) => [...all, v], [])
4229
+ .action(async (target, args, opts, command) => {
4230
+ if (target === 'add-user') {
4231
+ const [email, password, dir] = args;
4232
+ if (!email || !password) {
4233
+ console.error('❌ usage: eventmodelers scaffold add-user <email> <password> [dir]');
4234
+ process.exit(1);
4235
+ }
4236
+ const addUser = SCAFFOLD_TYPES[opts.type]?.addUser;
4237
+ if (!addUser) {
4238
+ console.log(`ℹ️ The "${opts.type}" scaffold has no users — nothing to do.`);
4239
+ return;
4240
+ }
4241
+ const appDir = resolve(process.cwd(), dir ?? '.');
4242
+ if (!existsSync(join(appDir, addUser.requires))) {
4243
+ console.error(`❌ ${appDir} is not a scaffolded "${opts.type}" app (no ${addUser.requires}) — run this in its folder or pass the folder as the last argument.`);
4244
+ process.exit(1);
4245
+ }
4246
+ const [file, ...fileArgs] = addUser.command(email, password);
4247
+ try {
4248
+ execFileSync(file, fileArgs, { cwd: appDir, stdio: 'inherit' });
4249
+ } catch {
4250
+ process.exit(1);
4251
+ }
4252
+ return;
4253
+ }
4254
+ if (args.length) {
4255
+ console.error(`❌ Unexpected argument(s): ${args.join(' ')}`);
4256
+ process.exit(1);
4257
+ }
4258
+ const type = SCAFFOLD_TYPES[opts.type];
4259
+ if (!type) {
4260
+ console.error(`❌ Unknown --type "${opts.type}". Available: ${scaffoldTypeNames().join(', ')}`);
4261
+ process.exit(1);
4262
+ }
4263
+ const missing = missingPrerequisites(type);
4264
+ if (missing.length) {
4265
+ console.error(`❌ Missing on this machine:\n${missing.map((m) => ` - ${m}`).join('\n')}`);
4266
+ process.exit(1);
4267
+ }
4268
+
4269
+ const cwd = process.cwd();
4270
+ const globalOpts = command.optsWithGlobals();
4271
+ let cfg = loadEffectiveConfig(cwd, findInstalledKitDir(cwd), globalOpts.config).config;
4272
+ const explicit = Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v));
4273
+ cfg = { ...cfg, ...explicit };
4274
+ const configPath = globalOpts.config ? resolve(cwd, globalOpts.config) : join(cwd, '.eventmodelers', 'config.json');
4275
+ const requiredFields = ['organizationId', 'boardId', 'token'];
4276
+ const promptForCredentials = async () => {
4277
+ cfg = await configureCredentials({ config: cfg, configPath, targetDir: cwd, requiredFields, boardIdOptional: false, overrides: {}, print: globalOpts.print });
4278
+ if (requiredFields.some((f) => !cfg[f])) {
4279
+ console.error('❌ Still missing token/organizationId/boardId — re-run `eventmodelers scaffold` once configured.');
4280
+ process.exit(1);
4281
+ }
4282
+ };
4283
+ if (requiredFields.some((f) => !cfg[f])) await promptForCredentials();
4284
+
4285
+ const readModel = async () => {
4286
+ const baseUrl = cfg.baseUrl || 'https://api.eventmodelers.ai';
4287
+ const headers = { 'x-token': cfg.token, 'x-user-id': 'cli-scaffold', 'content-type': 'application/json' };
4288
+ const call = async (url, what, init) => {
4289
+ const res = await fetch(url, { headers, ...init }).catch((err) => {
4290
+ console.error(`❌ Request failed (${what}): ${err.message}`);
4291
+ process.exit(1);
4292
+ });
4293
+ if (res.status === 401 || res.status === 403) throw new FetchAuthError(res.status, `${what}: ${res.status === 401 ? 'invalid or expired token' : "token's organization does not match this board"}`);
4294
+ if (!res.ok) {
4295
+ console.error(`❌ ${what}: HTTP ${res.status}`);
4296
+ process.exit(1);
4297
+ }
4298
+ return res.json();
4299
+ };
4300
+ const boardBase = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}`;
4301
+ const boards = await call(`${baseUrl}/api/boards`, 'boards').catch((err) => { if (err instanceof FetchAuthError) throw err; return []; });
4302
+ const board = (Array.isArray(boards) ? boards : boards.boards ?? []).find((b) => b.id === cfg.boardId);
4303
+ return buildModel({
4304
+ appTitle: board?.name ?? board?.title,
4305
+ api: {
4306
+ get: (path) => call(`${boardBase}${path}`, path.split('?')[0]),
4307
+ post: (path, body) => call(`${boardBase}${path}`, path, { method: 'POST', body: JSON.stringify(body) }),
4308
+ },
4309
+ });
4310
+ };
4311
+
4312
+ console.log(`▶ Reading the model of board ${cfg.boardId}...`);
4313
+ let model;
4314
+ try {
4315
+ model = await readModel();
4316
+ } catch (err) {
4317
+ if (!(err instanceof FetchAuthError)) throw err;
4318
+ console.error(`❌ ${err.message}`);
4319
+ if (err.status === 404) delete cfg.boardId;
4320
+ else delete cfg.token;
4321
+ await promptForCredentials();
4322
+ model = await readModel();
4323
+ }
4324
+ console.log(` ${model.timelines.length} timeline(s), ${model.slices.length} slice(s), ${model.screens.length} screen(s), css: ${model.css}`);
4325
+
4326
+ try {
4327
+ const { root, ports } = await runScaffold({
4328
+ typeName: opts.type,
4329
+ target,
4330
+ cfg,
4331
+ model,
4332
+ cliPath: join(__dirname, 'cli.js'),
4333
+ install: opts.install,
4334
+ check: opts.check,
4335
+ templates: opts.templates ? resolve(cwd, opts.templates) : undefined,
4336
+ ports: Object.fromEntries(opts.port.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
4337
+ });
4338
+ console.log(`\n✅ Skeleton ready in ${root}`);
4339
+ console.log(` cd ${relative(cwd, root) || '.'} && npm run up # ${Object.entries(ports).map(([n, v]) => `${n} :${v}`).join(', ')}`);
4340
+ console.log(' npx @eventmodelers/cli scaffold add-user <email> <password> # a user to sign in with (no sign-up screen)');
4341
+ console.log(' npx @eventmodelers/cli run --all # then build: every kit in the project, in this terminal');
4342
+ } catch (err) {
4343
+ console.error(`❌ ${err.message}`);
4344
+ process.exit(1);
4345
+ }
4346
+ });
4347
+
4348
+ program
4349
+ .command('init-templates [paths...]')
4350
+ .description('Copy the built-in scaffold templates into ./templates/ (or --dir) so you can edit them: `scaffold` uses a template found there and falls back to the built-in one for everything else. Name paths (a file like web/src/app/Layout.tsx, or a folder like web/src/app) to copy only those — a copied template no longer follows the CLI\'s own updates, so take only what you mean to change. Existing files are kept unless --force.')
4351
+ .option('--type <name>', `Scaffold type whose templates to copy (${scaffoldTypeNames().join(', ')})`, 'supabase')
4352
+ .option('--dir <dir>', 'Where to copy them', 'templates')
4353
+ .option('--list', 'Only list the built-in template paths')
4354
+ .option('-f, --force', 'Overwrite templates already in the folder')
4355
+ .action((paths, opts) => {
4356
+ const type = SCAFFOLD_TYPES[opts.type];
4357
+ if (!type) {
4358
+ console.error(`❌ Unknown --type "${opts.type}". Available: ${scaffoldTypeNames().join(', ')}`);
4359
+ process.exit(1);
4360
+ }
4361
+ if (opts.list) {
4362
+ for (const rel of builtInTemplates(type).keys()) console.log(rel);
4363
+ return;
4364
+ }
4365
+ try {
4366
+ const dest = resolve(process.cwd(), opts.dir);
4367
+ const { copied, kept } = initTemplates(type, dest, { only: paths, force: opts.force });
4368
+ for (const rel of copied) console.log(` ✓ ${relative(process.cwd(), join(dest, `${rel}.tmpl`))}`);
4369
+ if (kept.length) console.log(` ${kept.length} already there, kept (use --force to overwrite)`);
4370
+ console.log(`\n✅ ${copied.length} template(s) copied. Edit them, then run \`eventmodelers scaffold <dir>\` — it uses ${relative(process.cwd(), dest) || '.'}/ when that is the target's templates/ folder, or pass --templates.`);
4371
+ } catch (err) {
4372
+ console.error(`❌ ${err.message}`);
4373
+ process.exit(1);
4374
+ }
4375
+ });
4376
+
4163
4377
  program
4164
4378
  .command('activate-context')
4165
4379
  .description('Choose which fetched context is active — writes .slices/current_context.json, which `run` works through first; once it has no planned slices left, `run` moves on to the next context that does')
@@ -4217,7 +4431,7 @@ const SLICE_STATUSES = [
4217
4431
  { label: '🚫 Blocked', value: 'Blocked' },
4218
4432
  { label: '🧭 Ready', value: 'Ready' },
4219
4433
  { label: '📅 Planned', value: 'Planned' },
4220
- { label: '🤝 Handover', value: 'Handover' },
4434
+ { label: '🤝 Waiting', value: 'Waiting' },
4221
4435
  { label: 'ℹ️ Informational', value: 'Informational' },
4222
4436
  ];
4223
4437
 
@@ -4394,11 +4608,13 @@ function agentEventMeta(cwd, kitDir, command) {
4394
4608
 
4395
4609
  program
4396
4610
  .command('slice-status <status>')
4397
- .description("For a build agent: record the outcome of the slice the run loop is building (Done, Handover, Blocked, ...). The loop owns the slice's status on the board and pushes it when the build ends — never edit index.json or call update-slice-status for a loop build.")
4611
+ .description("For a build agent: record the outcome of the slice the run loop is building (Done, Waiting, Blocked, ...). The loop owns the slice's status on the board and pushes it when the build ends — never edit index.json or call update-slice-status for a loop build.")
4398
4612
  .option('--slice <idOrTitle>', 'The slice, when the loop has claimed more than one (default: the one it is building)')
4399
4613
  .action((status, opts, command) => {
4400
4614
  const cwd = process.cwd();
4401
- const canonical = SLICE_STATUSES.find((s) => s.value.toLowerCase() === status.toLowerCase())?.value;
4615
+ // `Handover` is the old name of `Waiting`; older skills and prompts still say it
4616
+ const wanted = status.toLowerCase() === 'handover' ? 'waiting' : status.toLowerCase();
4617
+ const canonical = SLICE_STATUSES.find((s) => s.value.toLowerCase() === wanted)?.value;
4402
4618
  if (!canonical) {
4403
4619
  console.error(`❌ "${status}" is not a slice status. Valid: ${SLICE_STATUSES.map((s) => s.value).join(', ')}`);
4404
4620
  process.exit(1);
@@ -4414,6 +4630,79 @@ program
4414
4630
  }
4415
4631
  });
4416
4632
 
4633
+ program
4634
+ .command('ui-baseline <action>')
4635
+ .alias('build-baseline')
4636
+ .description("For a build agent: decide whether a slice's UI needs work. `check` compares the board's hashes of the slice's screen and of every element of the slice and its codeGen.uiPrompts with what was recorded after the last UI build and prints NO_BASELINE, UNCHANGED or CHANGED (UNCHANGED = skip all UI work). `record` stores the current state once the UI is built — commit the resulting ui-baseline.json with the slice.")
4637
+ .option('--slice <idOrTitle>', 'The slice (default: the one the run loop has claimed)')
4638
+ .option('--scope <scope>', 'ui (default): screen, elements and UI prompts · backend: elements only, for a kit that builds no UI', 'ui')
4639
+ .action(async (action, opts, command) => {
4640
+ if (!['ui', 'backend'].includes(opts.scope)) {
4641
+ console.error('❌ --scope must be `ui` or `backend`.');
4642
+ process.exit(1);
4643
+ }
4644
+ if (!['check', 'record'].includes(action)) {
4645
+ console.error('❌ Action must be `check` or `record`.');
4646
+ process.exit(1);
4647
+ }
4648
+ const cwd = process.cwd();
4649
+ const kitDir = requireBuildKitDir(cwd);
4650
+ let wanted = opts.slice;
4651
+ if (!wanted) {
4652
+ const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
4653
+ if (claimed.length !== 1) {
4654
+ console.error(`❌ ${claimed.length ? `${claimed.length} slices are claimed` : 'No slice is claimed by the run loop'} — name the slice with --slice.`);
4655
+ process.exit(1);
4656
+ }
4657
+ wanted = claimed[0][0];
4658
+ }
4659
+ const found = findSliceDir(kitDir, wanted);
4660
+ if (!found) {
4661
+ console.error(`❌ Slice "${wanted}" not found under ${relative(cwd, join(kitDir, '.slices'))}.`);
4662
+ process.exit(1);
4663
+ }
4664
+ const globalOpts = command.optsWithGlobals();
4665
+ const cfg = loadEffectiveConfig(cwd, kitDir, globalOpts.config).config;
4666
+ const fetchNodes = async (ids) => {
4667
+ if (['organizationId', 'boardId', 'token'].some((f) => !cfg[f])) throw new Error('token/organizationId/boardId are not configured (.eventmodelers/config.json)');
4668
+ const res = await fetch(`${cfg.baseUrl || DEFAULT_BASE_URL}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/query`, {
4669
+ method: 'POST',
4670
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'cli-ui-baseline', ...agentHeaders(cfg) },
4671
+ body: JSON.stringify({ ids }),
4672
+ });
4673
+ if (!res.ok) throw new Error(`board returned HTTP ${res.status}`);
4674
+ return res.json();
4675
+ };
4676
+ let fp;
4677
+ try {
4678
+ fp = await fingerprint({ slice: found.slice, fetchNodes, scope: opts.scope });
4679
+ } catch (err) {
4680
+ // Cannot tell — the safe answer is the full check, never a skip.
4681
+ console.log('UNKNOWN');
4682
+ console.log(`Could not read the screen from the board (${err.message}) — do the full screen diff.`);
4683
+ return;
4684
+ }
4685
+ if (action === 'record') {
4686
+ writeBaseline(found.path, fp, opts.scope);
4687
+ console.log(`✅ Recorded the ${opts.scope === 'backend' ? 'build' : 'UI'} baseline for "${found.slice.title}" (${relative(cwd, join(found.path, 'ui-baseline.json'))}) — commit it with the slice.`);
4688
+ return;
4689
+ }
4690
+ const result = compare(readBaseline(found.path), fp, opts.scope);
4691
+ console.log(result.verdict);
4692
+ if (result.verdict === 'UNCHANGED') {
4693
+ console.log('Screen, elements and UI prompts are as of the last UI build — skip all UI work.');
4694
+ return;
4695
+ }
4696
+ if (result.verdict === 'NO_BASELINE') console.log('No UI build recorded for this slice yet — do one full screen diff, apply every UI prompt, then `ui-baseline record`.');
4697
+ const nodeLine = (label, list) => (list.length ? `\n ${label}: ${list.map((n) => `${n.title} (${n.kind === 'screen' ? 'screen' : n.kind.replace(/s$/, '')})`).join(', ')}` : '');
4698
+ const nodeDetail = nodeLine('changed', result.changedNodes) + nodeLine('added', result.addedNodes) + nodeLine('removed', result.removedNodes);
4699
+ if (nodeDetail) console.log(`Since the last recorded build:${nodeDetail}\nWork on exactly these; leave what matches alone.`);
4700
+ if (result.screenChanged && result.verdict === 'CHANGED') console.log('SCREEN_CHANGED: re-fetch the screen node, diff the components against it and adjust the UI.');
4701
+ if (result.elementsChanged && result.verdict === 'CHANGED') console.log('ELEMENTS_CHANGED: an element of the slice (command, event, read model, processor, table, actor, aggregate, specification, ...) changed on the board — diff slice.json against the code field by field.');
4702
+ if (result.promptsChanged) console.log(`PROMPTS_CHANGED: apply ${result.newPrompts.length ? 'these new/changed UI prompts' : 'the UI prompts (they were edited or removed — re-read them)'}:${result.newPrompts.map((p) => `\n - ${p.trim()}`).join('')}`);
4703
+ console.log('When the UI is done, run `ui-baseline record`.');
4704
+ });
4705
+
4417
4706
  program
4418
4707
  .command('learn [text]')
4419
4708
  .description("For a build agent: record one reusable learning (a codebase pattern, a gotcha, a command that works or doesn't, what a failed approach tried and why it failed). .build-kit/AGENTS.md is generated from the recorded learnings — never edit it by hand. --retract withdraws a wrong or obsolete one (ids: `learnings --ids`).")
@@ -4462,7 +4751,7 @@ program
4462
4751
  .option('--tag <tag>', 'Only events carrying this tag')
4463
4752
  .option('--last <n>', 'Only the last n events (after the other filters)')
4464
4753
  .option('--json', 'Print the raw events, one JSON object per line')
4465
- .option('--verbose', 'Print each event\'s payload under its line — for a tool call, the input it was sent (recorded by runs started with --verbose)')
4754
+ .option('--verbose', 'Print each event\'s payload under its line — for a tool call, the input it was sent (recorded by runs started with --verbose). Also shows the baseline events: the kit\'s first look at each slice (a fresh kit records every slice already on the board), which are hidden by default. `--type board-observed` and `--json` show them too.')
4466
4755
  .option('--state', 'Print the state the log folds to (slices, queue, open prompts, learnings) instead of the events')
4467
4756
  .option('--prune', 'Delete the events nothing reads any more — the journal of finished prompts — and stop. Slice state, learnings and open prompts are kept; the same prune a run offers at every 1000 events.')
4468
4757
  .option('--follow', 'Keep running and print events as they are appended')
@@ -4510,6 +4799,8 @@ program
4510
4799
 
4511
4800
  const follow = opts.follow || opts.watch;
4512
4801
  const events = readEvents(kitDir);
4802
+ // The baseline (each slice's first board-observed) is noise unless asked for: --verbose, the raw --json, or the type itself.
4803
+ const hideBaseline = !opts.verbose && !opts.json && !filter.types?.includes('board-observed');
4513
4804
  const titles = sliceTitles(events);
4514
4805
  if (opts.state) {
4515
4806
  if (follow) console.error('ℹ️ --follow/--watch does not apply to --state.');
@@ -4522,7 +4813,7 @@ program
4522
4813
  if (!opts.global && !opts.kit && readEvents(globalLog).length) console.log('The global install has a log of its own: `eventmodelers events --global`.');
4523
4814
  return;
4524
4815
  }
4525
- print(filterEvents(events, { ...filter, last }, titles), titles);
4816
+ print(filterEvents(events, { ...filter, last, omit: hideBaseline ? baselineIds(events) : undefined }, titles), titles);
4526
4817
  if (!follow) return;
4527
4818
 
4528
4819
  // Follow: what has been printed is remembered by event id, and the log is read again twice a second.
@@ -4531,7 +4822,7 @@ program
4531
4822
  const all = readEvents(kitDir);
4532
4823
  const fresh = all.filter((e) => !seen.has(e.id));
4533
4824
  for (const e of fresh) seen.add(e.id);
4534
- if (fresh.length) print(filterEvents(fresh, filter, sliceTitles(all)), sliceTitles(all));
4825
+ if (fresh.length) print(filterEvents(fresh, { ...filter, omit: hideBaseline ? baselineIds(all) : undefined }, sliceTitles(all)), sliceTitles(all));
4535
4826
  }, 500);
4536
4827
  });
4537
4828
 
@@ -0,0 +1,23 @@
1
+ // Where the build kits of a project live: the root and up to two directory levels below it, so `run --all` finds
2
+ // both kits of a scaffolded app (root and web/) whether it is started in the app or in the folder holding it.
3
+ // Hidden directories and node_modules are not searched.
4
+ import { existsSync, readdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const MAX_DEPTH = 2;
8
+
9
+ export function findBuildKitDirs(root, kitDirNames) {
10
+ const has = (dir) => kitDirNames.some((name) => existsSync(join(dir, name)));
11
+ const found = [];
12
+ const visit = (dir, depth) => {
13
+ if (has(dir)) found.push(dir);
14
+ if (depth === MAX_DEPTH) return;
15
+ const children = readdirSync(dir, { withFileTypes: true })
16
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
17
+ .map((e) => join(dir, e.name))
18
+ .sort();
19
+ for (const child of children) visit(child, depth + 1);
20
+ };
21
+ visit(root, 0);
22
+ return found;
23
+ }