@eventmodelers/cli 1.0.153 → 1.0.155

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 (57) hide show
  1. package/README.md +35 -1
  2. package/cli.js +51 -8
  3. package/package.json +1 -1
  4. package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +46 -20
  5. package/shared/build-kit/lib/ralph.js +31 -8
  6. package/shared/skills/connect/SKILL.md +1 -1
  7. package/shared/skills/learn-eventmodelers-api/SKILL.md +10 -4
  8. package/shared/skills/ui-contract/SKILL.md +128 -0
  9. package/shared/skills/update-slice-status/SKILL.md +3 -1
  10. package/stacks/axon/templates/.claude/skills/build-state-change/SKILL.md +6 -0
  11. package/stacks/axon/templates/.claude/skills/build-state-view/SKILL.md +6 -0
  12. package/stacks/axon/templates/build-kit/CLAUDE.md +1 -1
  13. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +2 -2
  14. package/stacks/blank/templates/.claude/skills/build-state-change/SKILL.md +12 -0
  15. package/stacks/blank/templates/.claude/skills/build-state-view/SKILL.md +12 -0
  16. package/stacks/blank/templates/build-kit/CLAUDE.md +8 -1
  17. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +2 -2
  18. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +6 -0
  19. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +6 -0
  20. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +2 -2
  21. package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +6 -0
  22. package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +6 -0
  23. package/stacks/kurrent/templates/build-kit/CLAUDE.md +1 -1
  24. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +2 -2
  25. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  26. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +1 -1
  27. package/stacks/modeling-kit/templates/kit/CLAUDE.md +4 -4
  28. package/stacks/node/templates/.claude/skills/build-state-change/SKILL.md +8 -0
  29. package/stacks/node/templates/.claude/skills/build-state-change/references/ui-contract-from-openapi.md +13 -0
  30. package/stacks/node/templates/.claude/skills/build-state-view/SKILL.md +8 -0
  31. package/stacks/node/templates/build-kit/CLAUDE.md +1 -1
  32. package/stacks/node/templates/build-kit/lib/backend-prompt.md +2 -2
  33. package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +6 -0
  34. package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +6 -0
  35. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +1 -1
  36. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +2 -2
  37. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +5 -0
  38. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +5 -0
  39. package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
  40. package/stacks/react/templates/build-kit/lib/backend-prompt.md +11 -11
  41. package/stacks/react/templates/build-kit/lib/kit.json +3 -0
  42. package/stacks/react/templates/build-kit/lib/prompt.md +2 -2
  43. package/stacks/supabase/templates/.claude/skills/build-state-change/SKILL.md +8 -0
  44. package/stacks/supabase/templates/.claude/skills/build-state-change/references/ui-contract-from-openapi.md +13 -0
  45. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +8 -0
  46. package/stacks/supabase/templates/build-kit/CLAUDE.md +1 -1
  47. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +2 -2
  48. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +10 -1
  49. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +4 -0
  50. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
  51. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +11 -11
  52. package/stacks/supabase-react/templates/build-kit/lib/kit.json +3 -0
  53. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +2 -2
  54. package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +6 -0
  55. package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +6 -0
  56. package/stacks/umadb/templates/build-kit/CLAUDE.md +1 -1
  57. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +2 -2
package/README.md CHANGED
@@ -155,6 +155,8 @@ npx @eventmodelers/cli run --modeling # modeling-kit: warm Claude
155
155
  npx @eventmodelers/cli run --standalone # same, plus acting on board changes unprompted — and needs no install at all
156
156
  npx @eventmodelers/cli run --standalone --board-id <uuid> # …from any directory, against any board (see Power users)
157
157
  npx @eventmodelers/cli run --id <id> --name <label> # override this run's agent identity (id + display name); `init --name` persists a name instead
158
+ 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)
159
+ npx @eventmodelers/cli config --handover on|off # persist that choice for the project (`handover` in .eventmodelers/config.json)
158
160
  npx @eventmodelers/cli fetch --context <name> # pull full slice detail for one context on the board into <kit-dir>/.slices/
159
161
  npx @eventmodelers/cli fetch --context <name> --slice-id <id> # same, then print just that slice
160
162
  npx @eventmodelers/cli fetch --context <name> --slice-title <title> # same, then print just the slice matching this title
@@ -198,6 +200,10 @@ npx @eventmodelers/cli run --local
198
200
 
199
201
  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
202
 
203
+ **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.
204
+
205
+ **UI contract.** The scaffolded `build-state-view` / `build-state-change` skills already 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/`).
206
+
201
207
  Installing both a build stack and `init-modeling` into the same project reuses this one `.eventmodelers/config.json` — run whichever `init` command second and it finds the existing config already satisfies the required fields and skips straight past the credential prompt.
202
208
 
203
209
  ### The modeling agent — `run --modeling` and `--standalone`
@@ -649,6 +655,34 @@ Only prompts carrying this agent's id are worked. Anything untargeted is handed
649
655
 
650
656
  Pair it with `--id`: a `--standalone`/`--global` run mints a fresh id per run, so prompts addressed to the previous run's id are never claimed. `--exclusive` applies to the prompt queue only — a `--standalone` agent's self-directed turns are nobody's prompt, and it keeps taking them.
651
657
 
658
+ ### Backend → UI handover (`--handover`)
659
+
660
+ 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.
661
+
662
+ ```bash
663
+ npx @eventmodelers/cli init --stack node --handover # persisted: "handover": true in .eventmodelers/config.json (also re-init)
664
+ npx @eventmodelers/cli config --handover on # switch it on later…
665
+ npx @eventmodelers/cli config --handover off # …or back off
666
+ npx @eventmodelers/cli run --handover # this run only — writes nothing
667
+ ```
668
+
669
+ | | Handover off (default) | Handover on |
670
+ |---|---|---|
671
+ | Backend kit, STATE_VIEW / STATE_CHANGE slice | ends at `Done` | publishes the UI contract, ends at `Handover` |
672
+ | Backend kit, automation / translation | `Done` | `Done` — no UI counterpart |
673
+ | UI kit (`react`, `supabase-react`) | claims `Handover` only | claims `Handover` only, sets `Done` when its UI is built |
674
+
675
+ 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 `~/`).
676
+
677
+ **What to know before relying on it**
678
+
679
+ - **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.
680
+ - **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.
681
+ - **The platform must know the `Ready` / `Handover` statuses.**
682
+ - A kit declares what it claims in `lib/kit.json` — `{ "pickupStatus": "Handover" }`; absent means `Planned`.
683
+
684
+ 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.
685
+
652
686
  ### Env vars and `--config` (scripted/CI installs)
653
687
 
654
688
  Every config field can be set via an `EVENTMODELERS_*` env var instead of the interactive prompts — these always win over whatever's in `config.json`, so a fully env-driven install never prompts for credentials or Claude execution settings:
@@ -759,7 +793,7 @@ npx @eventmodelers/cli uninstall --modeling-kit # remove .agent-modeling-ki
759
793
 
760
794
  ### Adding a stack
761
795
 
762
- Each stack lives under `stacks/<name>/templates/` with `.claude/` (skills), `root/` (spread into the project root), and either `build-kit/` (backend stacks) or `kit/` (modeling-only) for the agent runner. Files identical across all backend stacks live once in `shared/build-kit/` and get layered in automatically — only put stack-specific overrides under `stacks/<name>/templates/build-kit/`. Skills with no stack-specific content (`connect`, `learn-eventmodelers-api`, `update-slice-status`, `request-feedback`) work the same way via `shared/skills/` — a new stack gets them for free without copying anything; add a skill there only once it needs a stack-specific fork.
796
+ Each stack lives under `stacks/<name>/templates/` with `.claude/` (skills), `root/` (spread into the project root), and either `build-kit/` (backend stacks) or `kit/` (modeling-only) for the agent runner. Files identical across all backend stacks live once in `shared/build-kit/` and get layered in automatically — only put stack-specific overrides under `stacks/<name>/templates/build-kit/`. Skills with no stack-specific content (`connect`, `learn-eventmodelers-api`, `update-slice-status`, `request-feedback`, `ui-contract`) work the same way via `shared/skills/` — a new stack gets them for free without copying anything; add a skill there only once it needs a stack-specific fork. A stack overrides a shared skill by shipping one of the same name under its own `.claude/skills/` (e.g. a custom `ui-contract`).
763
797
 
764
798
  The demo model `init --demo` installs is shared the same way, in `shared/demo-slices/` — a verbatim `fetch --format json` tree (`current_context.json` plus `<context>/{context,index,config}.json` and `<slice>/slice.json`), copied as-is into the kit's `.slices/`. It's stack-agnostic board data, so a new stack gets `--demo` for free. To replace or extend it, `fetch` a context into an empty directory and copy the result in, then update `DEMO_CONTEXT_NAME`/`DEMO_SLICE_COUNT` in `cli.js` (used only for the line `init` prints).
765
799
 
package/cli.js CHANGED
@@ -710,6 +710,18 @@ function findConfigInParents(startDir) {
710
710
  return existsSync(globalCandidate) ? globalCandidate : null;
711
711
  }
712
712
 
713
+ // Where a project-level setting (`config --handover`, `--agent-tracing`) is written: the explicit
714
+ // --config, else the installed kit's project, else the nearest project config up the tree — so it
715
+ // lands in the file `run` merges in, even from a subdirectory — else this directory. The
716
+ // account-wide ~/.eventmodelers/config.json is never a target: these are per-project policies.
717
+ function projectConfigPath(cwd, kitDir, explicitConfig) {
718
+ if (explicitConfig) return resolve(explicitConfig);
719
+ if (kitDir) return join(dirname(kitDir), '.eventmodelers', 'config.json');
720
+ const found = findConfigInParents(cwd);
721
+ const globalPath = join(homedir(), '.eventmodelers', 'config.json');
722
+ return found && found !== globalPath ? found : join(cwd, '.eventmodelers', 'config.json');
723
+ }
724
+
713
725
  function readJsonSafe(path) {
714
726
  if (!path || !existsSync(path)) return {};
715
727
  try {
@@ -983,9 +995,11 @@ async function installStack(stackKey, stackCfg, options = {}) {
983
995
  copyDirContents(claudeSkillsSrc, globalSkillsDir);
984
996
  } else {
985
997
  console.log('📦 Installing skills...');
986
- copyDirContents(join(templatesSource, '.claude'), join(targetDir, '.claude'));
998
+ // Shared skills first, the stack's own templates last — so a build kit can override
999
+ // a shared skill (e.g. `ui-contract`) just by shipping a skill of the same name.
987
1000
  copyDirContents(sharedSkills, join(targetDir, '.claude', 'skills'));
988
1001
  if (isBridge) copyDirContents(sharedBridgeSkills, join(targetDir, '.claude', 'skills'));
1002
+ copyDirContents(join(templatesSource, '.claude'), join(targetDir, '.claude'));
989
1003
  claudeExtras = existsSync(join(templatesSource, '.claude'))
990
1004
  ? readdirSync(join(templatesSource, '.claude')).filter((f) => f !== 'skills')
991
1005
  : [];
@@ -1357,6 +1371,10 @@ async function configureCredentials({ config, configPath, targetDir, requiredFie
1357
1371
  if (!skipWrite) {
1358
1372
  writeFileSync(configPath, JSON.stringify(config, null, 2));
1359
1373
  console.log(`\n ✓ Saved to ${relative(targetDir, configPath)}`);
1374
+ } else if (overrides.handover) {
1375
+ // A policy flag, not a credential — it must stick even when credentials were skipped.
1376
+ writeFileSync(configPath, JSON.stringify({ ...readJsonSafe(configPath), handover: true }, null, 2));
1377
+ console.log(` ✓ Handover on — saved to ${relative(targetDir, configPath)}`);
1360
1378
  }
1361
1379
  return config;
1362
1380
  }
@@ -3241,9 +3259,19 @@ const AGENT_NAME_OPTION = [
3241
3259
  'Human-readable name for the agent this kit runs (e.g. "ci-builder", "martins-laptop"), saved to config.json as `agentName` and sent with every heartbeat so the board shows a name instead of a bare uuid. Also settable via EVENTMODELERS_AGENT_NAME, and overridable for a single run with `run --name`.',
3242
3260
  ];
3243
3261
 
3262
+ // `--handover` is the project policy for how a backend build kit finishes a slice with a UI:
3263
+ // set, it ends STATE_VIEW / STATE_CHANGE slices at `Handover` (the UI kit builds the rest and sets
3264
+ // `Done`); unset, it ends at `Done`, exactly as before. Persisted as `handover: true` in the project's
3265
+ // config.json — the `ui-contract` skill (Step 4) reads it there. Switch it later with
3266
+ // `eventmodelers config --handover on|off`.
3267
+ const HANDOVER_OPTION = [
3268
+ '--handover',
3269
+ '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`.',
3270
+ ];
3271
+
3244
3272
  function identityOverridesFromOpts(opts) {
3245
3273
  const agentName = typeof opts.name === 'string' ? opts.name.trim() : undefined;
3246
- return agentName ? { agentName } : {};
3274
+ return { ...(agentName ? { agentName } : {}), ...(opts.handover ? { handover: true } : {}) };
3247
3275
  }
3248
3276
 
3249
3277
  credentialFlags(program
@@ -3262,7 +3290,8 @@ credentialFlags(program
3262
3290
  .option('--demo', 'Install a ready-made demo model into the kit\'s .slices/ — the "Understanding Eventsourcing" context (16 shopping-cart slices covering every slice type), in exactly the shape `fetch` writes, so the build skills and the agent loop have something real to work on before this project is connected to a board. Skipped if .slices/ already holds fetched slices. Off by default.')
3263
3291
  .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
3264
3292
  .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json')
3265
- .option(...AGENT_NAME_OPTION))
3293
+ .option(...AGENT_NAME_OPTION)
3294
+ .option(...HANDOVER_OPTION))
3266
3295
  .action(async (opts, command) => {
3267
3296
  const globalOpts = command.optsWithGlobals();
3268
3297
 
@@ -3384,7 +3413,8 @@ credentialFlags(program
3384
3413
  .option('--hooks', 'Install the slice commit-scope guard (.githooks/pre-commit) and wire it up via `git config core.hooksPath .githooks` — same as `init --hooks`, for turning it on after the fact without a full re-scaffold. Off by default.')
3385
3414
  .option('--global', 'Re-install skills into ~/.claude/skills/ instead of the project — defaults to however they were originally installed')
3386
3415
  .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json')
3387
- .option(...AGENT_NAME_OPTION))
3416
+ .option(...AGENT_NAME_OPTION)
3417
+ .option(...HANDOVER_OPTION))
3388
3418
  .action(async (opts, command) => {
3389
3419
  const globalOpts = command.optsWithGlobals();
3390
3420
  const targetDir = process.cwd();
@@ -3686,6 +3716,7 @@ credentialFlags(program
3686
3716
  .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)')
3687
3717
  .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.')
3688
3718
  .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.')
3719
+ .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`.')
3689
3720
  .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.')
3690
3721
  .option('--id <id>', 'Pin the agent id this run identifies itself with on the platform. A project install otherwise mints one id per project and reuses it on every restart; --global/--standalone mints a fresh one per run, since two ad-hoc agents for one board must not share a row (the heartbeat is keyed on token + agent_id + agent_type, so the second would replace the first). Pass this when an agent has to keep ONE identity across restarts — a supervisor that already knows the id, or a board where it is the starred "preferred agent". Per-run only: nothing is written to disk.')
3691
3722
  .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.')
@@ -3956,7 +3987,7 @@ credentialFlags(program
3956
3987
  // local-only branch even when .eventmodelers/config.json has valid credentials.
3957
3988
  // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read in ralph.js's startRalph, so
3958
3989
  // they reach both node runners but not ralph.sh, which has no heartbeat to identify.
3959
- execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
3990
+ execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
3960
3991
  } catch (err) {
3961
3992
  process.exit(err.status || 1);
3962
3993
  }
@@ -4174,7 +4205,9 @@ const SLICE_STATUSES = [
4174
4205
  { label: '🔄 InProgress', value: 'InProgress' },
4175
4206
  { label: '🔍 Review', value: 'Review' },
4176
4207
  { label: '🚫 Blocked', value: 'Blocked' },
4208
+ { label: '🧭 Ready', value: 'Ready' },
4177
4209
  { label: '📅 Planned', value: 'Planned' },
4210
+ { label: '🤝 Handover', value: 'Handover' },
4178
4211
  { label: 'ℹ️ Informational', value: 'Informational' },
4179
4212
  ];
4180
4213
 
@@ -4504,21 +4537,31 @@ program
4504
4537
  program
4505
4538
  .command('config')
4506
4539
  .description('Print the fully resolved config (merged across the directory hierarchy + EVENTMODELERS_* env vars), with the token masked')
4540
+ .option('--handover <on|off>', "Turn the backend→UI handover on or off for this project (saved as `handover` in the project's .eventmodelers/config.json). On: a backend build kit ends slices that have a UI at `Handover`; off: at `Done`.")
4507
4541
  .option('--agent-tracing <on|off>', "Turn agent tracing on or off for this project (saved as `agentTracing` in the project's .eventmodelers/config.json). Off: the build agent sends no slice cost data to the platform at all. A non-Claude agent via run --exec is only traced when its command includes the harness's JSON output flag, e.g. \"codex exec --json --full-auto\".")
4508
4542
  .action((opts, command) => {
4509
4543
  const cwd = process.cwd();
4510
4544
  const kitDir = findInstalledKitDir(cwd);
4511
4545
  const explicitConfig = command.optsWithGlobals().config;
4512
4546
 
4547
+ if (opts.handover !== undefined) {
4548
+ const value = String(opts.handover).toLowerCase();
4549
+ if (!['on', 'off'].includes(value)) {
4550
+ console.error('❌ --handover takes "on" or "off"');
4551
+ process.exit(1);
4552
+ }
4553
+ const configPath = projectConfigPath(cwd, kitDir, explicitConfig);
4554
+ mkdirSync(dirname(configPath), { recursive: true });
4555
+ writeFileSync(configPath, JSON.stringify({ ...readJsonSafe(configPath), handover: value === 'on' }, null, 2));
4556
+ console.log(`✓ Handover ${value} — saved to ${relative(cwd, configPath) || configPath}\n`);
4557
+ }
4513
4558
  if (opts.agentTracing !== undefined) {
4514
4559
  const value = String(opts.agentTracing).toLowerCase();
4515
4560
  if (!['on', 'off'].includes(value)) {
4516
4561
  console.error('❌ --agent-tracing takes "on" or "off"');
4517
4562
  process.exit(1);
4518
4563
  }
4519
- const configPath = explicitConfig
4520
- ? resolve(explicitConfig)
4521
- : join(kitDir ? dirname(kitDir) : cwd, '.eventmodelers', 'config.json');
4564
+ const configPath = projectConfigPath(cwd, kitDir, explicitConfig);
4522
4565
  mkdirSync(dirname(configPath), { recursive: true });
4523
4566
  writeFileSync(configPath, JSON.stringify({ ...readJsonSafe(configPath), agentTracing: value === 'on' }, null, 2));
4524
4567
  console.log(`✓ Agent tracing ${value} — saved to ${relative(cwd, configPath) || configPath}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.153",
3
+ "version": "1.0.155",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,33 +24,59 @@ export async function createPocketBaseRealtimeAdapter(cfg, initialToken, deps =
24
24
  const pb = new PocketBase(cfg.pocketbaseUrl);
25
25
  pb.authStore.save(initialToken, null);
26
26
 
27
- // One listener per topic; subscribe() replaces the topic's previous one (see realtime-adapter.js).
28
- const listeners = new Map();
27
+ // The SDK adds one EventSource listener per subscribe() call — and keeps it when the call
28
+ // fails — so a flaky connection retrying per topic piles listeners up (Node warns at 11).
29
+ // Hence a single SDK subscription for the whole adapter, fanned out to topics here.
30
+ // subscribe() replaces the topic's previous handlers (see realtime-adapter.js).
31
+ const topics = new Map();
32
+ let sdkUnsubscribe = null;
33
+ let connecting = null;
34
+
35
+ const connect = async () => {
36
+ for (let attempt = 0; ; attempt++) {
37
+ try {
38
+ sdkUnsubscribe = await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
39
+ if (e.action !== 'create') return;
40
+ const entry = topics.get(e.record.topic);
41
+ entry?.handlers[e.record.event]?.(e.record.payload);
42
+ });
43
+ return;
44
+ } catch (err) {
45
+ // Drop the listener the failed call left behind before trying again.
46
+ await pb.collection(REALTIME_EVENTS_COLLECTION).unsubscribe('*').catch(() => {});
47
+ if (attempt >= RECONNECT_INTERVALS_MS.length) throw err;
48
+ topics.forEach((t) => t.onStatus?.(`RECONNECTING (attempt ${attempt + 1}/${RECONNECT_INTERVALS_MS.length}): ${err.message}`));
49
+ await new Promise((resolve) => setTimeout(resolve, RECONNECT_INTERVALS_MS[attempt]));
50
+ }
51
+ }
52
+ };
53
+
54
+ // Concurrent subscribe() calls share one in-flight connect — sdkUnsubscribe is only set
55
+ // after the await, so without this each would open its own SDK subscription.
56
+ const ensureSdkSubscription = () => {
57
+ if (sdkUnsubscribe) return Promise.resolve();
58
+ connecting ??= connect().finally(() => { connecting = null; });
59
+ return connecting;
60
+ };
61
+
29
62
  const remove = async (topic) => {
30
- const unsubscribe = listeners.get(topic);
31
- if (!unsubscribe) return;
32
- listeners.delete(topic);
63
+ topics.delete(topic);
64
+ if (topics.size || !sdkUnsubscribe) return;
65
+ const unsubscribe = sdkUnsubscribe;
66
+ sdkUnsubscribe = null;
33
67
  await unsubscribe().catch(() => {});
34
68
  };
35
69
 
36
70
  return {
37
71
  async subscribe(topic, handlers, onStatus) {
38
- await remove(topic);
39
- for (let attempt = 0; ; attempt++) {
40
- try {
41
- const unsubscribe = await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
42
- if (e.action !== 'create' || e.record.topic !== topic) return;
43
- handlers[e.record.event]?.(e.record.payload);
44
- });
45
- listeners.set(topic, unsubscribe);
46
- onStatus?.('SUBSCRIBED');
47
- return;
48
- } catch (err) {
49
- if (attempt >= RECONNECT_INTERVALS_MS.length) throw err;
50
- onStatus?.(`RECONNECTING (attempt ${attempt + 1}/${RECONNECT_INTERVALS_MS.length}): ${err.message}`);
51
- await new Promise((resolve) => setTimeout(resolve, RECONNECT_INTERVALS_MS[attempt]));
52
- }
72
+ topics.set(topic, { handlers, onStatus });
73
+ try {
74
+ await ensureSdkSubscription();
75
+ } catch (err) {
76
+ topics.delete(topic);
77
+ throw err;
53
78
  }
79
+ onStatus?.('SUBSCRIBED');
54
80
  },
55
81
  unsubscribe(topic) {
56
82
  return remove(topic);
@@ -242,7 +242,8 @@ async function fetchAndPersistSlices(cfg, kitDir) {
242
242
  if (!activeCtx || !contexts[activeCtx]) {
243
243
  // First run (or the current context disappeared): seed with a context that
244
244
  // has planned work, else the first one. This is the ONLY place we choose it.
245
- const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status || '').toLowerCase() === 'planned'));
245
+ const pickup = readPickupStatus(kitDir).toLowerCase();
246
+ const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status || '').toLowerCase() === pickup));
246
247
  activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
247
248
  writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
248
249
  }
@@ -400,11 +401,12 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
400
401
  await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
401
402
  console.error('[agent] Slice persist error:', err),
402
403
  );
403
- // Planned slices are handled by onPlannedSlice directly — no task needed.
404
+ // Slices in the kit's pickup status (Planned, or Handover for UI kits) are handled by
405
+ // onPlannedSlice directly — no task needed.
404
406
  // queueAllStatuses opts out of that split entirely (e.g. bridge has no
405
407
  // onPlannedSlice consumer, so a lingering Planned slice would otherwise
406
408
  // never naturally clear its own trigger — see lib/ralph.js callers).
407
- if (queueAllStatuses || (payload.sliceStatus || '').toLowerCase() !== 'planned') {
409
+ if (queueAllStatuses || (payload.sliceStatus || '').toLowerCase() !== readPickupStatus(kitDir).toLowerCase()) {
408
410
  await writeTask(payload, kitDir).catch((err) => console.error('[agent] writeTask error:', err));
409
411
  }
410
412
  }
@@ -513,6 +515,17 @@ function hasPendingTasks(kitDir) {
513
515
  return readTasks(join(kitDir, 'tasks.json')).length > 0;
514
516
  }
515
517
 
518
+ // The slice status this kit claims work from. A stack declares it in `lib/kit.json`
519
+ // ({ "pickupStatus": "Handover" } for the UI kits, which build what a backend kit handed over);
520
+ // absent means "Planned", the backend default.
521
+ export function readPickupStatus(kitDir) {
522
+ try {
523
+ const s = JSON.parse(readFileSync(join(kitDir, 'lib', 'kit.json'), 'utf-8')).pickupStatus;
524
+ if (typeof s === 'string' && s.trim()) return s.trim();
525
+ } catch {}
526
+ return 'Planned';
527
+ }
528
+
516
529
  function readCurrentContext(kitDir) {
517
530
  const ctxPath = join(kitDir, '.slices', 'current_context.json');
518
531
  if (!existsSync(ctxPath)) return null;
@@ -541,13 +554,14 @@ function getFirstPlannedSlice(kitDir) {
541
554
  try {
542
555
  others = readdirSync(slicesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
543
556
  } catch {}
557
+ const pickup = readPickupStatus(kitDir).toLowerCase();
544
558
  const candidates = [...(currentCtx ? [currentCtx] : []), ...others.filter((c) => c !== currentCtx)];
545
559
  for (const ctx of candidates) {
546
560
  const indexPath = join(slicesDir, ctx, 'index.json');
547
561
  if (!existsSync(indexPath)) continue;
548
562
  try {
549
563
  const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
550
- const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === 'planned');
564
+ const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === pickup);
551
565
  if (!planned) continue;
552
566
  if (ctx !== currentCtx) {
553
567
  writeFileSync(join(slicesDir, 'current_context.json'), JSON.stringify({ name: ctx }, null, 2), 'utf-8');
@@ -673,11 +687,20 @@ async function runWithRetry(label, fn) {
673
687
  // alone, the agent read it as background, answered "what would you like me to help with?" with
674
688
  // no tool calls, and a Planned slice stayed Planned until the stuck-slice guard blocked it. So
675
689
  // every turn ends with the concrete request — here, once, for every runner.
676
- function taskRequest(planned = null) {
690
+ // Whether a backend kit finishes UI-bearing slices at `Handover` instead of `Done`: this run's
691
+ // `run --handover` (RALPH_HANDOVER) over the project's `handover` setting in config.json.
692
+ export function resolveHandover(cfg) {
693
+ return process.env.RALPH_HANDOVER === '1' || cfg?.handover === true;
694
+ }
695
+
696
+ function taskRequest(planned = null, cfg = null, pickupStatus = 'Planned') {
677
697
  const task = planned
678
- ? `build the Planned slice "${planned.title}" (id=${planned.id}, context="${planned.ctx}")`
698
+ ? `build the ${pickupStatus} slice "${planned.title}" (id=${planned.id}, context="${planned.ctx}")`
679
699
  : 'process the pending tasks in tasks.json';
680
- return `\n\n---\n\nYour task now: ${task}, following the instructions above. Start immediately.\n`;
700
+ // Stated explicitly so the agent never has to work it out from config.json: this run's effective
701
+ // value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup Handover) ignore it.
702
+ const handover = planned && pickupStatus === 'Planned' ? `\nHandover: ${resolveHandover(cfg) ? 'on' : 'off'}\n` : '';
703
+ return `\n\n---\n\nYour task now: ${task}, following the instructions above. Start immediately.\n${handover}`;
681
704
  }
682
705
 
683
706
  async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false) {
@@ -718,7 +741,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
718
741
  continue;
719
742
  }
720
743
 
721
- const prompt = readFileSync(backendPromptFile, 'utf-8') + taskRequest(planned);
744
+ const prompt = readFileSync(backendPromptFile, 'utf-8') + taskRequest(planned, cfg, readPickupStatus(kitDir));
722
745
  if (credentialed) await fetchFullSlice(cfg, kitDir, planned.id);
723
746
  lastReply = null;
724
747
  const built = await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, async () => { lastReply = await onPlannedSlice(prompt, {
@@ -289,7 +289,7 @@ Connecting is also where the session's read discipline starts. Every skill that
289
289
 
290
290
  The orientation read also tells you what you may write to. `get_board_outline` returns `sliceStatus` per column — a slice owns a column, so every element in that column shares its status. `get_nodes` does **not** carry it, so take it from the outline before any write:
291
291
 
292
- **Only a slice in `Created` may be written to.** Any other status — `Planned`, `Assigned`, `InProgress`, `Review`, `Blocked`, `Done`, `Informational` — means someone is working on that slice: read its elements for context, but never change, move, rename or delete them, and never add scenarios, fields or examples to them. An element in a column with **no** `sliceStatus` is in no slice at all, which is not the same as locked — that one is writable.
292
+ **Only a slice in `Created` may be written to.** Any other status — `Ready`, `Planned`, `Assigned`, `InProgress`, `Handover`, `Review`, `Blocked`, `Done`, `Informational` — means someone is working on that slice: read its elements for context, but never change, move, rename or delete them, and never add scenarios, fields or examples to them. An element in a column with **no** `sliceStatus` is in no slice at all, which is not the same as locked — that one is writable.
293
293
 
294
294
  Never spend a `list_slices`/`get_slice_data` call to answer this; the outline already did.
295
295
 
@@ -644,7 +644,7 @@ Create a complete slice (1 column + its nodes automatically placed).
644
644
  interaction?: Partial<NodeData> & { rowId?: string, fields?: FieldDef[] }
645
645
  swimlane?: Partial<NodeData> & { rowId?: string, fields?: FieldDef[] }
646
646
  }
647
- status?: 'Created' | 'Planned' | 'InProgress' | 'Review' | 'Done' | 'Blocked' | 'Assigned' | 'Informational'
647
+ status?: 'Created' | 'Ready' | 'Planned' | 'InProgress' | 'Handover' | 'Review' | 'Done' | 'Blocked' | 'Assigned' | 'Informational'
648
648
  }
649
649
  ```
650
650
 
@@ -671,7 +671,7 @@ Create a standalone SLICE_BORDER node spanning an **existing** column. Unlike th
671
671
  {
672
672
  columnId: string // id of an existing column on this timeline
673
673
  title: string // slice title — always taken from this field, never derived
674
- status?: 'Created' | 'Planned' | 'InProgress' | 'Review' | 'Done' | 'Blocked' | 'Assigned' | 'Informational'
674
+ status?: 'Created' | 'Ready' | 'Planned' | 'InProgress' | 'Handover' | 'Review' | 'Done' | 'Blocked' | 'Assigned' | 'Informational'
675
675
  data?: Record<string, unknown> // optional node.data payload
676
676
  meta?: Record<string, unknown> // optional extra meta fields (type, colId, title are always set explicitly and cannot be overridden here)
677
677
  }
@@ -845,7 +845,8 @@ How much each slice of one context has been reworked — read-only, derived from
845
845
  for it.
846
846
 
847
847
  A slice's rework is read from its own SLICE_BORDER history. Going backwards means a step down the progress order
848
- (Created → Planned → Assigned → InProgress → Review → Done), or reaching `Blocked` from `Review`/`Done`;
848
+ (Created → Ready → Planned → Assigned → InProgress → Handover → Review → Done), or reaching `Blocked` from
849
+ `Handover`/`Review`/`Done`;
849
850
  `Informational` is never scored. `reopens` counts transitions leading away from `Done`, `changes` counts writes to the
850
851
  slice itself. This says nothing about edits to the elements inside a slice.
851
852
 
@@ -854,6 +855,7 @@ context (the same rule `slicedata` uses), so one that inherits a context is repo
854
855
  none is its own. Any other node type is rejected. Always scoped to one context; there is no board-wide form.
855
856
 
856
857
  **Response**: `200` — `{ contextId, contextName, metrics, slices[] }`, slices most reworked first.
858
+ `metrics.progress` is where the slices stand now: `done`, `inProgress` (Assigned/InProgress/Review), `handover`, `ready`, `blocked`, `planned` (Planned/Created) as counts plus a `…Rate` share each, over the non-Informational slices, summing to 1.
857
859
  `slices[]` = `{ sliceId, title, status, changes, reopens, regressions, everReachedDone }`.
858
860
  `metrics` = `{ slices, everReachedDone, reopenedAfterDone, firstTimeRightRate, currentlyReopened, avgReopensPerSlice,
859
861
  avgChangesPerSlice }` — `firstTimeRightRate` is the share of the slices that reached Done and never came back, or
@@ -1172,11 +1174,12 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
1172
1174
  | to show several screens at once — a list, a gallery, a storyboard ("show me all screens of X") | `walkthrough`, one step per screen | nothing — selecting a step pans the canvas |
1173
1175
  | to answer where one piece of information comes from and where it ends up ("where is the customer email used?") | `dataJourney` | nothing — element names pan the canvas |
1174
1176
  | to show a diagram you drew yourself — a context map, a domain story, a sketch, any picture that isn't a chart | `svg` | nothing |
1177
+ | to show a UML or flow diagram you write as Mermaid — a sequence, flowchart, state, ER or class diagram | `mermaid` | nothing |
1175
1178
  | to lay out rows and columns — a comparison, a matrix (stressors × elements), a short list with a few attributes | `table` | nothing — linked cells pan the canvas |
1176
1179
  | to show a prototype you just built and uploaded (the `prototype` skill), running in the chat | `prototype` | nothing — **Full screen** opens the board's prototype player |
1177
1180
  | something the customer defined their own snippet type for (a risk card, a sign-off — see `list_snippet_types`) | `custom` | the clicked action's `answer`; `context.snippetReply.then` says what to do |
1178
1181
 
1179
- `confirm`, `poll`, `tasks` and `changes` are questions: a person answers once, then they show as answered. `link`, `image`, `code`, `report`, `walkthrough`, `screen`, `dataJourney`, `svg`, `table` and `prototype` are display only and never wait for anything.
1182
+ `confirm`, `poll`, `tasks` and `changes` are questions: a person answers once, then they show as answered. `link`, `image`, `code`, `report`, `walkthrough`, `screen`, `dataJourney`, `svg`, `mermaid`, `table` and `prototype` are display only and never wait for anything.
1180
1183
 
1181
1184
  ### Shapes
1182
1185
  ```typescript
@@ -1225,6 +1228,8 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
1225
1228
  title?: string, caption?: string,
1226
1229
  links?: Array<{ nodeId: string, x: number, y: number, // ≤ 200 clickable areas over the picture, in the svg's viewBox coordinates;
1227
1230
  width: number, height: number, title?: string }> } // a click zooms the canvas to nodeId; title = tooltip (else the element's title)
1231
+ { kind: 'mermaid', source: string, // Mermaid source, no ``` fence (≤ 60000 chars); the app draws it
1232
+ title?: string, caption?: string }
1228
1233
  { kind: 'table', title?: string, caption?: string,
1229
1234
  columns: Array<string | { label: string, nodeId?: string, // 1–12; nodeId makes the header a link (a matrix with one column per element)
1230
1235
  align?: 'left' | 'center' | 'right' }>,
@@ -1247,6 +1252,7 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
1247
1252
  - **`dataJourney`** answers "where does X come from / where is X used?" for **one piece of information**, as a table: one stop per element it passes through, in flow order, each with its `role`, the field name there and the slice. A headline counts the roles ("Originates in 1 place, stored in 2 elements and used in 3 places"). Build it from the board — the `data-journey` skill (modeling kit) has the procedure; every `nodeId` must be a real id from this board, and a field the board doesn't carry is not a stop. Keep `text` to one line naming the data and where it originates.
1248
1253
  - **`svg`** is for a picture you draw yourself: give the root a `viewBox` (and `width`/`height` for its natural size), inline colours and fonts — it is shown as an image, so scripts, `<a>` links and external resources do nothing. To make parts of it clickable, list them in `links`: a rectangle in the viewBox coordinates you drew in plus the element it stands for (only real ids from this board); the app lays a clickable area over each that zooms the canvas there. Prefer `report` for numbers. A domain story (who does what with which work object) is an `svg` you draw — the `domain-story` skill (modeling kit) has the procedure and the drawing style. Every SVG snippet has a download-as-PNG button.
1249
1254
  - **"Place it on the board" after you drew an SVG always means: put that picture on the board as an image** — never rebuild it from elements (stickies, lanes, a timeline), never a SCREEN, never a new snippet. `place_image` with `imageBase64` = the SVG document itself, base64-encoded, `mimeType: "image/svg+xml"`, `title` = the snippet's title: it becomes an OUTLINE (the drawing tools' image element), free-floating at `x`/`y`, in no chapter and connected to nothing. Place it where the person says, else in free space next to the chapter it is about (`get_chapter_bounds`: right of or below it). Add a white background `<rect>` first if the SVG has none, or it is unreadable on a dark board. One SVG = one image. The image on the board has no clickable areas — `links` stay in the chat.
1255
+ - **`mermaid`** is for a diagram you can write as text: a sequence diagram (`sequenceDiagram`), a flow (`flowchart LR`), states (`stateDiagram-v2`), an ER or class diagram. Send just the source; the app draws it, with a *Show source* toggle and a copy button, and shows the source when it has a syntax error — so keep the syntax plain (quote labels with special characters). Prefer it over a `code` block for any diagram: a `code` block with `language: "mermaid"` is drawn too, but `mermaid` says what you mean. It has no element links; for clickable parts draw an `svg`.
1250
1256
  - **`table`** is for anything that is rows and columns and not a chart: a comparison of options, an incidence matrix (rows = stressors, columns = elements, `true` where it hits), a short list with a few attributes per item. It computes nothing — totals are a row you add. Put element ids in `nodeId` (header or cell) so names are links; a row longer than the columns is cut, a shorter one padded, an empty one dropped. Prefer `report` for numbers you want *seen* as a trend or share, `dataJourney` for one piece of data's path.
1251
1257
  - **`prototype`** runs a prototype in the chat: the page the platform stored for `timelineId` (the `contextId` the `prototype` skill uploaded under — any timeline of that context works too), in a sandboxed iframe with **Restart** (empty event log) and **Full screen** (the same player as the timeline's ▶). Send it only after `upload` succeeded — before that it shows "No prototype has been built for this yet". The `text` is the short report of what was built; the prototype itself is the snippet.
1252
1258
  - A snippet is shown exactly as you send it — keep secrets and tokens out of `code` and `image`.
@@ -0,0 +1,128 @@
1
+ ---
2
+ name: ui-contract
3
+ description: Publish the slice's UI contract — the OpenAPI operation of its endpoint (read = GET, write/state-change = POST) — as its own `## UI Contract` section in the slice's MARKDOWN node in the feedback lane, which the slice export carries in `slice.json` `notes[]` — so the UI build kit finds the contract in one lookup instead of parsing the whole note. Called by every build-state-view / build-state-change skill after the endpoint is built. A build kit can override it by shipping its own `ui-contract` skill.
4
+ ---
5
+
6
+ # UI Contract
7
+
8
+ The UI build kit builds the frontend against what the backend slice **actually exposes**. This skill
9
+ records that as a contract — the endpoint's **OpenAPI operation**, one per slice, in a fixed, machine-findable place.
10
+
11
+ > Invoke `connect` first if not already connected (`BOARD_ID`). Prefer `mcp__eventmodelers__*` tools;
12
+ > the REST equivalents are in `learn-eventmodelers-api`.
13
+ >
14
+ > **Override**: a build kit that needs a different contract shape or location ships its own
15
+ > `.claude/skills/ui-contract/SKILL.md`. The kit's copy replaces this one at install time (stack
16
+ > templates are applied after the shared skills). Callers only say "invoke `ui-contract`" — they never
17
+ > restate the format.
18
+
19
+ ## When
20
+
21
+ At the end of building a slice, **after** the endpoint exists (so the contract describes the real
22
+ route, not the plan), before the final commit:
23
+
24
+ | Slice type | Contract kind |
25
+ |---|---|
26
+ | `STATE_VIEW` (READMODEL) | **read** — `GET` |
27
+ | `STATE_CHANGE` (COMMAND) | **write** — `POST` (a state change) |
28
+ | `AUTOMATION` | none — no UI counterpart, skip |
29
+
30
+ ## Step 1 — Derive the OpenAPI operation from the built endpoint
31
+
32
+ Never copy from the plan — the contract describes what was built. The contract **is** the endpoint's
33
+ OpenAPI 3 operation (one path, one verb) — no second, hand-made shape.
34
+
35
+ 1. **Use the source the stack names.** If the calling `build-state-view` / `build-state-change`
36
+ skill's `## UI Contract` section points to the stack's machine-readable API description (e.g. the
37
+ handler's `@openapi` block), take that operation **verbatim** — see the stack's
38
+ `ui-contract-from-openapi.md`. Do not rewrite it.
39
+ 2. **Otherwise read the endpoint's route/handler code** and write the equivalent OpenAPI operation:
40
+ method, path, `parameters` (path / query / header), `requestBody` schema, and a response for the
41
+ success status and for every error status the handler really returns.
42
+ 3. **Trim to what a UI needs**: drop `tags`, internal notes and anything about storage or auth plumbing
43
+ (keep `security` if set). Keep `summary`, `description`, parameter/field `example`s and the response
44
+ descriptions — they carry the business meaning of each error status.
45
+ 4. **Cross-check against `slice.json`**: the request-body / response property names must equal the
46
+ command's / read model's `fields[]` (path parameters such as the generated id excepted). A mismatch
47
+ is an implementation bug — fix the code, don't publish a contract that disagrees with `slice.json`.
48
+
49
+ ## Step 2 — Write the `## UI Contract` section into the slice's MARKDOWN node
50
+
51
+ 1. **Find the node, creating whatever is missing** — never skip or fail because it doesn't exist yet:
52
+ 1. `get_board_outline { boardId, chapterId }` → locate the slice's column and the chapter's
53
+ `feedback` lane. **No feedback lane** → create it: `add_lane { boardId, timelineId, lanes: [{ type: "feedback", label: "Notes" }] }`
54
+ (returns the `laneId`; a feedback lane sorts after the spec lane automatically).
55
+ 2. Look for a `markdown` node in the feedback lane in the slice's column. **None** → create it with a
56
+ `node:created` event through `submit_node_events` (not `place_element` / cell-drop):
57
+ `{ "id": "<fresh uuid>", "eventType": "node:created", "nodeId": "<fresh uuid>", "chapterId": "<chapterId>",
58
+ "cellId": "<feedbackRowId>-<columnId>", "meta": { "type": "MARKDOWN", "title": "<slice title>",
59
+ "description": "## UI Contract\n\n…" } }` — the new node's body is just the section in the format of item 3 below.
60
+ 2. **Upsert only the section** (skip the read when you just created the node): read the node once (`get_node`), then replace the text from the
61
+ `## UI Contract` heading to the next `## ` heading (or end of file) — or append it when absent.
62
+ Leave every other section untouched. Write back `meta.description` via a `node:changed` event
63
+ (`submit_node_events`; the content lives in `description`, never `content`).
64
+ 3. **Section format** — exactly this, so it can be extracted with a single match:
65
+
66
+ ````markdown
67
+ ## UI Contract
68
+
69
+ **READ** `GET /api/customers/{customerId}/reservation` — <one-line purpose>
70
+
71
+ ```yaml ui-contract
72
+ /api/customers/{customerId}/reservation:
73
+ get:
74
+ summary: ...
75
+ parameters: [...]
76
+ responses: {...}
77
+ ```
78
+ ````
79
+
80
+ `**READ**` for a `GET`, `**WRITE**` for a `POST`.
81
+
82
+ **Formatting is part of the contract.** The note renders as Markdown, so `meta.description` must contain
83
+ *real* line breaks — build the string with actual newlines (a multi-line string, or `\n` that the JSON
84
+ parser turns into a newline), never a single line and never a literal backslash-`n` in the stored text.
85
+ Blank line after the heading, blank line before and after the fenced block, the opening ```` ```yaml ui-contract ````
86
+ and the closing ```` ``` ```` each on their own line, and the YAML keeps its indentation (2 spaces). After
87
+ the write, read the node back once (`get_node`) and check that `description` has multiple lines and no
88
+ literal `\n`; if it is flat, rewrite it before moving on.
89
+
90
+ The fenced block whose info string is `ui-contract` is the single source: the operation alone, keyed by its path — no `openapi:`/`info:`/`servers:`
91
+ wrapper, no restated table.
92
+
93
+ ## Step 3 — Nothing else to write
94
+
95
+ The board is the single source. The slice export (`fetch` / `get_slice_data`, full projection) carries
96
+ every MARKDOWN node of the slice's column as a plain string in `slice.json` `notes[]` (the
97
+ `outline` / `fields` / `specs` projections leave `notes` out). The UI build kit therefore needs no
98
+ extra file and no board call.
99
+
100
+ ## Step 4 — Completion status: `Handover` instead of `Done`
101
+
102
+ A slice with a UI is only finished once the UI kit has built its half. Whether this build kit hands
103
+ over is decided per run, in this order: the task request's `Handover: on|off` line (authoritative — the
104
+ runner resolves `run --handover` over the project setting), else `"handover": true` in
105
+ `.eventmodelers/config.json` (saved by `init --handover` / `eventmodelers config --handover on|off`).
106
+
107
+ - **On** — the build kit's "set the slice to `Done`" for a STATE_VIEW / STATE_CHANGE slice means
108
+ **`Handover`** instead: after the contract is published and the commit/merge succeeded, via
109
+ `update-slice-status` (mirrored in the local `index.json`). The UI kit claims `Handover` slices and sets
110
+ `Done` when its UI is built.
111
+ - **Off / unset** — nothing changes: `Done`.
112
+ - Automations and translations have no UI → always `Done`.
113
+
114
+ ## How the UI build kit looks the contract up
115
+
116
+ 1. In the slice's `slice.json`, take the `notes[]` entry that contains a `## UI Contract` heading.
117
+ Ignore every other entry.
118
+ 2. In that entry, take the fenced block whose info string is `ui-contract` (```` ```yaml ui-contract ````)
119
+ and parse just that YAML — an OpenAPI operation keyed by its path: request = path/verb +
120
+ `parameters` + `requestBody`, success = the 2xx `responses` entry, errors = every non-2xx entry.
121
+ Ignore the rest of the note.
122
+ 3. No such entry → the backend slice isn't built yet, or the slice wasn't re-fetched since; re-fetch
123
+ once, and if still absent build against `slice.json` and note the assumption.
124
+
125
+ ## Re-running
126
+
127
+ Idempotent: a rebuilt slice overwrites its own section; it never duplicates the
128
+ section and never touches other sections of the note.