@eventmodelers/cli 2.0.1 → 2.0.3

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 (80) hide show
  1. package/README.md +27 -9
  2. package/cli.js +369 -36
  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/build-log-share.js +149 -0
  10. package/shared/build-kit/lib/events-view.js +18 -3
  11. package/shared/build-kit/lib/ralph.js +27 -16
  12. package/shared/build-kit/lib/slice-events.js +42 -2
  13. package/shared/build-kit/lib/slice-info.js +52 -0
  14. package/shared/build-kit/lib/slice-run.js +2 -2
  15. package/shared/build-kit/lib/slice-state.js +6 -4
  16. package/shared/scaffold/LICENSE-em-scaffold +21 -0
  17. package/shared/scaffold/supabase/generate.mjs +290 -0
  18. package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
  19. package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
  20. package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
  21. package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
  22. package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
  23. package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
  24. package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
  25. package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
  26. package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
  27. package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
  28. package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
  29. package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
  30. package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
  31. package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
  32. package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
  33. package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
  34. package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
  35. package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
  36. package/shared/skills/learn-eventmodelers-api/SKILL.md +4 -4
  37. package/shared/skills/ui-contract/SKILL.md +6 -6
  38. package/shared/skills/update-slice-status/SKILL.md +1 -1
  39. package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
  40. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +11 -1
  41. package/stacks/axon/templates/build-kit/lib/prompt.md +24 -0
  42. package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
  43. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +11 -1
  44. package/stacks/blank/templates/build-kit/lib/prompt.md +24 -0
  45. package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
  46. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +11 -1
  47. package/stacks/classic/templates/build-kit/lib/prompt.md +24 -0
  48. package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
  49. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +11 -1
  50. package/stacks/kurrent/templates/build-kit/lib/prompt.md +24 -0
  51. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  52. package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +2 -0
  53. package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +7 -5
  54. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +1 -1
  55. package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -5
  56. package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
  57. package/stacks/node/templates/build-kit/lib/backend-prompt.md +11 -1
  58. package/stacks/node/templates/build-kit/lib/prompt.md +10 -0
  59. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
  60. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +11 -1
  61. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +24 -0
  62. package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
  63. package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
  64. package/stacks/react/templates/build-kit/lib/backend-prompt.md +17 -7
  65. package/stacks/react/templates/build-kit/lib/kit.json +1 -1
  66. package/stacks/react/templates/build-kit/lib/prompt.md +39 -2
  67. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
  68. package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
  69. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +11 -1
  70. package/stacks/supabase/templates/build-kit/lib/prompt.md +10 -0
  71. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
  72. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
  73. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
  74. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
  75. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +17 -7
  76. package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
  77. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +40 -3
  78. package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
  79. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +11 -1
  80. package/stacks/umadb/templates/build-kit/lib/prompt.md +24 -0
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,16 +35,18 @@ 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';
49
+ import { INFO_KINDS, infoComment, recordInfo, recordTurnInfos } from './shared/build-kit/lib/slice-info.js';
44
50
  import { listLearnings, mergeLearnings, recordLearning, recordTurnLearnings, retractLearning, syncLearnings } from './shared/build-kit/lib/slice-learnings.js';
45
51
 
46
52
  const __filename = fileURLToPath(import.meta.url);
@@ -463,6 +469,30 @@ function getSharedRl() {
463
469
  return sharedRl;
464
470
  }
465
471
 
472
+ // --force on a build kit: the credentials step of `init-config` against the project-root config the
473
+ // runner reads. Interactive, so under `run --all` the parent runs it for each kit one after the other —
474
+ // the supervised children have no stdin to ask on. `shared` (another kit's answer) is applied
475
+ // instead of asking, so every kit of one run ends up on the same credentials.
476
+ async function forceBuildKitCredentials(kitDir, cwd, opts, globalOpts, shared = null) {
477
+ const projectDir = dirname(kitDir);
478
+ const configPath = globalOpts.config
479
+ ? resolve(projectDir, globalOpts.config)
480
+ : join(projectDir, '.eventmodelers', 'config.json');
481
+ const cfg = await configureCredentials({
482
+ config: loadEffectiveConfig(cwd, kitDir, globalOpts.config).config,
483
+ configPath,
484
+ targetDir: projectDir,
485
+ requiredFields: ['organizationId', 'boardId', 'token'],
486
+ overrides: shared ?? credentialOverridesFromOpts(opts),
487
+ print: false,
488
+ force: !shared,
489
+ });
490
+ ensureMcpRegistered(projectDir, cfg.baseUrl || DEFAULT_BASE_URL);
491
+ ensureEnvToken(projectDir, cfg.token);
492
+ ensureMcpEnabled(projectDir);
493
+ return cfg;
494
+ }
495
+
466
496
  // Pulls one line from the shared readline's own async iterator rather than calling
467
497
  // its `.question()` — `.question()` attaches a one-shot 'line' listener *after* the
468
498
  // prompt is issued, but when stdin is piped (a file, `<<<`, scripted/CI input) readline
@@ -2652,15 +2682,18 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2652
2682
  try {
2653
2683
  turnPromptId = p.id;
2654
2684
  const reply = await runTurn(buildTurn(p));
2655
- // The agent cannot run `learn` (locked-down Bash), so it states learnings in its reply and they are recorded here.
2656
- // Best effort: a failed write never takes the prompt queue down.
2657
- if (keepsRecord) {
2658
- try {
2685
+ // The agent cannot run `learn` or `info` (locked-down Bash), so it states them in its reply and they are recorded here.
2686
+ // Best effort: a failed write never takes the prompt queue down. Infos are events stamped with their board, so every
2687
+ // run records them — standalone included; learnings are kept only where `keepsRecord` (they feed AGENTS.md).
2688
+ try {
2689
+ if (keepsRecord) {
2659
2690
  const n = recordTurnLearnings({ kitDir, reply, meta: journalMeta });
2660
2691
  if (n) log(`recorded ${n} learning${n === 1 ? '' : 's'} from the turn`);
2661
- } catch (err) {
2662
- log(`could not record the turn's learnings: ${err.message}`);
2663
2692
  }
2693
+ const infos = recordTurnInfos({ kitDir, reply, meta: journalMeta });
2694
+ if (infos) log(`recorded ${infos} info event${infos === 1 ? '' : 's'} from the turn`);
2695
+ } catch (err) {
2696
+ log(`could not record the turn's learnings or infos: ${err.message}`);
2664
2697
  }
2665
2698
  journal(() => journalPromptFinished({ kitDir, id: p.id, outcome: 'worked', meta: journalMeta }));
2666
2699
  } catch (err) {
@@ -3286,11 +3319,11 @@ program
3286
3319
  // dir: a modeling run falls back to the global install (see ensureGlobalKit) rather than
3287
3320
  // requiring one here, and the build-kit branch reports a better-targeted error of its own
3288
3321
  // 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']);
3322
+ 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
3323
 
3291
3324
  // Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
3292
3325
  // front of them would only be noise.
3293
- const NO_KIT_UPDATE_HINT = new Set(['init', 're-init', 'uninstall']);
3326
+ const NO_KIT_UPDATE_HINT = new Set(['init', 'scaffold', 'init-templates', 're-init', 'uninstall']);
3294
3327
 
3295
3328
  // Retired: still recognised, so an old script or muscle memory gets an explanation instead of "unknown option".
3296
3329
  // The hidden options and the `bridge` command exist only to say so; they do nothing else.
@@ -3372,13 +3405,13 @@ const AGENT_NAME_OPTION = [
3372
3405
  ];
3373
3406
 
3374
3407
  // `--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
3408
+ // set, it ends STATE_VIEW / STATE_CHANGE slices at `Waiting` (the UI kit builds the rest and sets
3376
3409
  // `Done`); unset, it ends at `Done`, exactly as before. Persisted as `handover: true` in the project's
3377
3410
  // config.json — the `ui-contract` skill (Step 4) reads it there. Switch it later with
3378
3411
  // `eventmodelers config --handover on|off`.
3379
3412
  const HANDOVER_OPTION = [
3380
3413
  '--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`.',
3414
+ '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
3415
  ];
3383
3416
 
3384
3417
  function identityOverridesFromOpts(opts) {
@@ -3783,6 +3816,7 @@ credentialFlags(program
3783
3816
  .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
3817
  .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
3818
  .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.')
3819
+ .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
3820
  .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
3821
  .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
3822
  .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 +3828,9 @@ credentialFlags(program
3794
3828
  .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
3829
  .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
3830
  .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.')
3831
+ .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`.')
3832
+ .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.')
3833
+ .option('--sync', 'Build kit: share this agent\'s event log with the board — events that name a slice are uploaded constantly (POST …/buildlog) for a live dashboard. The platform keeps one iteration per slice, 1000 entries at most. A no-op for --modeling/--standalone, and with --local.')
3799
3834
  .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
3835
  .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
3836
  .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 +3838,49 @@ credentialFlags(program
3803
3838
  .action(async (opts, command) => {
3804
3839
  const globalOpts = command.optsWithGlobals();
3805
3840
  const cwd = process.cwd();
3841
+ if (opts.all) {
3842
+ const clashing = [['--standalone', opts.standalone], ['--worker', opts.worker], ['--global', opts.global], ['--local', opts.local], ['--id', opts.id !== undefined]]
3843
+ .filter(([, set]) => set).map(([flag]) => flag);
3844
+ if (clashing.length) {
3845
+ console.error(`❌ --all runs build kits and cannot be combined with ${clashing.join(', ')}.`);
3846
+ process.exit(1);
3847
+ }
3848
+ const kitDirNames = [...new Set(Object.values(STACKS).map((st) => st.kitDirName))];
3849
+ const dirs = findBuildKitDirs(cwd, kitDirNames);
3850
+ if (!dirs.length && !opts.modeling) {
3851
+ console.error(`❌ No build kit found in this directory or up to two levels below it (looked for ${kitDirNames.join(', ')}).`);
3852
+ process.exit(1);
3853
+ }
3854
+ // Each kit runs as its own `run` with whatever else was typed; only --all is dropped, and
3855
+ // --modeling too — that one is its own run, below, not something a build kit takes.
3856
+ // --force is the one flag that asks questions, and the children have no terminal to ask on
3857
+ // (the supervisor pipes their stdin, so the first prompt dies with ERR_USE_AFTER_CLOSE). The
3858
+ // credentials step runs here once per kit instead, then --force is not forwarded.
3859
+ if (opts.force) {
3860
+ if (opts.nonInteractive || globalOpts.print) {
3861
+ console.error('❌ --force asks for credentials again, which --non-interactive/--print rule out — pick one.');
3862
+ process.exit(1);
3863
+ }
3864
+ // Asked once, for the kit in this directory (the first one found when there is none), and
3865
+ // handed to the others — the modeling agent reads this directory's config.
3866
+ const rootDir = dirs.find((dir) => dirname(dir) === cwd) ?? dirs[0];
3867
+ console.log(`\n🔑 Credentials for ${relative(cwd, rootDir) || '.'}, shared by all`);
3868
+ const { organizationId, boardId, token, baseUrl } = await forceBuildKitCredentials(rootDir, cwd, opts, globalOpts);
3869
+ for (const dir of dirs.filter((d) => d !== rootDir)) {
3870
+ await forceBuildKitCredentials(dir, cwd, opts, globalOpts, { organizationId, boardId, token, baseUrl });
3871
+ }
3872
+ if (sharedRl) sharedRl.close();
3873
+ console.log('');
3874
+ }
3875
+ const forwarded = process.argv.slice(2).filter((a) => a !== '--all' && a !== '--modeling' && !(opts.force && a === '--force'));
3876
+ const cliJs = join(__dirname, 'cli.js');
3877
+ const specs = dirs.map((dir) => ({ name: relative(cwd, dir) || '.', file: process.execPath, args: [cliJs, ...forwarded], cwd: dir }));
3878
+ // --modeling adds the modeling agent next to the build kits: one process, from this directory
3879
+ // (its modeling kit, or the global install when there is none), like a plain `run --modeling`.
3880
+ if (opts.modeling) specs.push({ name: 'modeling', file: process.execPath, args: [cliJs, ...forwarded, '--modeling'], cwd });
3881
+ console.log(`▶ Running ${dirs.length} build kit(s)${opts.modeling ? ' + the modeling agent' : ''}: ${specs.map((sp) => sp.name).join(', ')} (Ctrl+c stops all)\n`);
3882
+ process.exit(await supervise(specs));
3883
+ }
3806
3884
  // Validated up front, before any runner is selected: a cost guard given as garbage
3807
3885
  // should fail on the spot, not once the loop is already up — and a cap passed where
3808
3886
  // nothing will read it is worth saying out loud rather than ignoring silently.
@@ -3876,6 +3954,7 @@ credentialFlags(program
3876
3954
  // no meaning for a build kit, which is scaffolded per project by definition.
3877
3955
  if (opts.modeling || opts.standalone || opts.worker || opts.global) {
3878
3956
  const picked = opts.modeling ? '--modeling' : opts.standalone ? '--standalone' : opts.worker ? '--worker' : '--global';
3957
+ if (opts.sync) console.log(`ℹ️ --sync shares a build agent's log — it does nothing with ${picked}.`);
3879
3958
  // --local-ai and --exec name what does a turn, not a queue, and the modeling loop has its own
3880
3959
  // runner for each (lib/modeling-local-ai.js, lib/modeling-exec.js).
3881
3960
  const modelingLocalAi = resolveLocalAiTarget(opts);
@@ -4019,22 +4098,7 @@ credentialFlags(program
4019
4098
  console.error('❌ --force asks for credentials, which --local (no platform config) never uses — pick one.');
4020
4099
  process.exit(1);
4021
4100
  }
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);
4101
+ await forceBuildKitCredentials(kitDir, cwd, opts, globalOpts);
4038
4102
  }
4039
4103
 
4040
4104
  // ralph-claude.js hands every turn to a `claude` that only discovers MCP servers from
@@ -4056,7 +4120,7 @@ credentialFlags(program
4056
4120
  // path with no stream-json parsing to gate. RALPH_LOCAL is read by ralph.js's startRalph to force
4057
4121
  // the local-only branch even when .eventmodelers/config.json has valid credentials.
4058
4122
  // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read there too.
4059
- 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_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', 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 } : {}) } });
4123
+ execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_SYNC: opts.sync ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', 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 } : {}) } });
4060
4124
  } catch (err) {
4061
4125
  process.exit(err.status || 1);
4062
4126
  }
@@ -4160,6 +4224,162 @@ program
4160
4224
  }
4161
4225
  });
4162
4226
 
4227
+ program
4228
+ .command('scaffold <target> [args...]')
4229
+ .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).')
4230
+ .option('--type <name>', `Kind of app to scaffold (${scaffoldTypeNames().join(', ')})`, 'supabase')
4231
+ .option('--no-install', 'Stop after generating the skeleton: no npm install, checks or commit')
4232
+ .option('--no-check', 'Install dependencies but skip the type-check, tests and web build/lint')
4233
+ .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')
4234
+ .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], [])
4235
+ .action(async (target, args, opts, command) => {
4236
+ if (target === 'add-user') {
4237
+ const [email, password, dir] = args;
4238
+ if (!email || !password) {
4239
+ console.error('❌ usage: eventmodelers scaffold add-user <email> <password> [dir]');
4240
+ process.exit(1);
4241
+ }
4242
+ const addUser = SCAFFOLD_TYPES[opts.type]?.addUser;
4243
+ if (!addUser) {
4244
+ console.log(`ℹ️ The "${opts.type}" scaffold has no users — nothing to do.`);
4245
+ return;
4246
+ }
4247
+ const appDir = resolve(process.cwd(), dir ?? '.');
4248
+ if (!existsSync(join(appDir, addUser.requires))) {
4249
+ 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.`);
4250
+ process.exit(1);
4251
+ }
4252
+ const [file, ...fileArgs] = addUser.command(email, password);
4253
+ try {
4254
+ execFileSync(file, fileArgs, { cwd: appDir, stdio: 'inherit' });
4255
+ } catch {
4256
+ process.exit(1);
4257
+ }
4258
+ return;
4259
+ }
4260
+ if (args.length) {
4261
+ console.error(`❌ Unexpected argument(s): ${args.join(' ')}`);
4262
+ process.exit(1);
4263
+ }
4264
+ const type = SCAFFOLD_TYPES[opts.type];
4265
+ if (!type) {
4266
+ console.error(`❌ Unknown --type "${opts.type}". Available: ${scaffoldTypeNames().join(', ')}`);
4267
+ process.exit(1);
4268
+ }
4269
+ const missing = missingPrerequisites(type);
4270
+ if (missing.length) {
4271
+ console.error(`❌ Missing on this machine:\n${missing.map((m) => ` - ${m}`).join('\n')}`);
4272
+ process.exit(1);
4273
+ }
4274
+
4275
+ const cwd = process.cwd();
4276
+ const globalOpts = command.optsWithGlobals();
4277
+ let cfg = loadEffectiveConfig(cwd, findInstalledKitDir(cwd), globalOpts.config).config;
4278
+ const explicit = Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v));
4279
+ cfg = { ...cfg, ...explicit };
4280
+ const configPath = globalOpts.config ? resolve(cwd, globalOpts.config) : join(cwd, '.eventmodelers', 'config.json');
4281
+ const requiredFields = ['organizationId', 'boardId', 'token'];
4282
+ const promptForCredentials = async () => {
4283
+ cfg = await configureCredentials({ config: cfg, configPath, targetDir: cwd, requiredFields, boardIdOptional: false, overrides: {}, print: globalOpts.print });
4284
+ if (requiredFields.some((f) => !cfg[f])) {
4285
+ console.error('❌ Still missing token/organizationId/boardId — re-run `eventmodelers scaffold` once configured.');
4286
+ process.exit(1);
4287
+ }
4288
+ };
4289
+ if (requiredFields.some((f) => !cfg[f])) await promptForCredentials();
4290
+
4291
+ const readModel = async () => {
4292
+ const baseUrl = cfg.baseUrl || 'https://api.eventmodelers.ai';
4293
+ const headers = { 'x-token': cfg.token, 'x-user-id': 'cli-scaffold', 'content-type': 'application/json' };
4294
+ const call = async (url, what, init) => {
4295
+ const res = await fetch(url, { headers, ...init }).catch((err) => {
4296
+ console.error(`❌ Request failed (${what}): ${err.message}`);
4297
+ process.exit(1);
4298
+ });
4299
+ 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"}`);
4300
+ if (!res.ok) {
4301
+ console.error(`❌ ${what}: HTTP ${res.status}`);
4302
+ process.exit(1);
4303
+ }
4304
+ return res.json();
4305
+ };
4306
+ const boardBase = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}`;
4307
+ const boards = await call(`${baseUrl}/api/boards`, 'boards').catch((err) => { if (err instanceof FetchAuthError) throw err; return []; });
4308
+ const board = (Array.isArray(boards) ? boards : boards.boards ?? []).find((b) => b.id === cfg.boardId);
4309
+ return buildModel({
4310
+ appTitle: board?.name ?? board?.title,
4311
+ api: {
4312
+ get: (path) => call(`${boardBase}${path}`, path.split('?')[0]),
4313
+ post: (path, body) => call(`${boardBase}${path}`, path, { method: 'POST', body: JSON.stringify(body) }),
4314
+ },
4315
+ });
4316
+ };
4317
+
4318
+ console.log(`▶ Reading the model of board ${cfg.boardId}...`);
4319
+ let model;
4320
+ try {
4321
+ model = await readModel();
4322
+ } catch (err) {
4323
+ if (!(err instanceof FetchAuthError)) throw err;
4324
+ console.error(`❌ ${err.message}`);
4325
+ if (err.status === 404) delete cfg.boardId;
4326
+ else delete cfg.token;
4327
+ await promptForCredentials();
4328
+ model = await readModel();
4329
+ }
4330
+ console.log(` ${model.timelines.length} timeline(s), ${model.slices.length} slice(s), ${model.screens.length} screen(s), css: ${model.css}`);
4331
+
4332
+ try {
4333
+ const { root, ports } = await runScaffold({
4334
+ typeName: opts.type,
4335
+ target,
4336
+ cfg,
4337
+ model,
4338
+ cliPath: join(__dirname, 'cli.js'),
4339
+ install: opts.install,
4340
+ check: opts.check,
4341
+ templates: opts.templates ? resolve(cwd, opts.templates) : undefined,
4342
+ ports: Object.fromEntries(opts.port.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
4343
+ });
4344
+ console.log(`\n✅ Skeleton ready in ${root}`);
4345
+ console.log(` cd ${relative(cwd, root) || '.'} && npm run up # ${Object.entries(ports).map(([n, v]) => `${n} :${v}`).join(', ')}`);
4346
+ console.log(' npx @eventmodelers/cli scaffold add-user <email> <password> # a user to sign in with (no sign-up screen)');
4347
+ console.log(' npx @eventmodelers/cli run --all # then build: every kit in the project, in this terminal');
4348
+ } catch (err) {
4349
+ console.error(`❌ ${err.message}`);
4350
+ process.exit(1);
4351
+ }
4352
+ });
4353
+
4354
+ program
4355
+ .command('init-templates [paths...]')
4356
+ .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.')
4357
+ .option('--type <name>', `Scaffold type whose templates to copy (${scaffoldTypeNames().join(', ')})`, 'supabase')
4358
+ .option('--dir <dir>', 'Where to copy them', 'templates')
4359
+ .option('--list', 'Only list the built-in template paths')
4360
+ .option('-f, --force', 'Overwrite templates already in the folder')
4361
+ .action((paths, opts) => {
4362
+ const type = SCAFFOLD_TYPES[opts.type];
4363
+ if (!type) {
4364
+ console.error(`❌ Unknown --type "${opts.type}". Available: ${scaffoldTypeNames().join(', ')}`);
4365
+ process.exit(1);
4366
+ }
4367
+ if (opts.list) {
4368
+ for (const rel of builtInTemplates(type).keys()) console.log(rel);
4369
+ return;
4370
+ }
4371
+ try {
4372
+ const dest = resolve(process.cwd(), opts.dir);
4373
+ const { copied, kept } = initTemplates(type, dest, { only: paths, force: opts.force });
4374
+ for (const rel of copied) console.log(` ✓ ${relative(process.cwd(), join(dest, `${rel}.tmpl`))}`);
4375
+ if (kept.length) console.log(` ${kept.length} already there, kept (use --force to overwrite)`);
4376
+ 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.`);
4377
+ } catch (err) {
4378
+ console.error(`❌ ${err.message}`);
4379
+ process.exit(1);
4380
+ }
4381
+ });
4382
+
4163
4383
  program
4164
4384
  .command('activate-context')
4165
4385
  .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 +4437,7 @@ const SLICE_STATUSES = [
4217
4437
  { label: '🚫 Blocked', value: 'Blocked' },
4218
4438
  { label: '🧭 Ready', value: 'Ready' },
4219
4439
  { label: '📅 Planned', value: 'Planned' },
4220
- { label: '🤝 Handover', value: 'Handover' },
4440
+ { label: '🤝 Waiting', value: 'Waiting' },
4221
4441
  { label: 'ℹ️ Informational', value: 'Informational' },
4222
4442
  ];
4223
4443
 
@@ -4394,11 +4614,13 @@ function agentEventMeta(cwd, kitDir, command) {
4394
4614
 
4395
4615
  program
4396
4616
  .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.")
4617
+ .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
4618
  .option('--slice <idOrTitle>', 'The slice, when the loop has claimed more than one (default: the one it is building)')
4399
4619
  .action((status, opts, command) => {
4400
4620
  const cwd = process.cwd();
4401
- const canonical = SLICE_STATUSES.find((s) => s.value.toLowerCase() === status.toLowerCase())?.value;
4621
+ // `Handover` is the old name of `Waiting`; older skills and prompts still say it
4622
+ const wanted = status.toLowerCase() === 'handover' ? 'waiting' : status.toLowerCase();
4623
+ const canonical = SLICE_STATUSES.find((s) => s.value.toLowerCase() === wanted)?.value;
4402
4624
  if (!canonical) {
4403
4625
  console.error(`❌ "${status}" is not a slice status. Valid: ${SLICE_STATUSES.map((s) => s.value).join(', ')}`);
4404
4626
  process.exit(1);
@@ -4414,6 +4636,79 @@ program
4414
4636
  }
4415
4637
  });
4416
4638
 
4639
+ program
4640
+ .command('ui-baseline <action>')
4641
+ .alias('build-baseline')
4642
+ .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.")
4643
+ .option('--slice <idOrTitle>', 'The slice (default: the one the run loop has claimed)')
4644
+ .option('--scope <scope>', 'ui (default): screen, elements and UI prompts · backend: elements only, for a kit that builds no UI', 'ui')
4645
+ .action(async (action, opts, command) => {
4646
+ if (!['ui', 'backend'].includes(opts.scope)) {
4647
+ console.error('❌ --scope must be `ui` or `backend`.');
4648
+ process.exit(1);
4649
+ }
4650
+ if (!['check', 'record'].includes(action)) {
4651
+ console.error('❌ Action must be `check` or `record`.');
4652
+ process.exit(1);
4653
+ }
4654
+ const cwd = process.cwd();
4655
+ const kitDir = requireBuildKitDir(cwd);
4656
+ let wanted = opts.slice;
4657
+ if (!wanted) {
4658
+ const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
4659
+ if (claimed.length !== 1) {
4660
+ console.error(`❌ ${claimed.length ? `${claimed.length} slices are claimed` : 'No slice is claimed by the run loop'} — name the slice with --slice.`);
4661
+ process.exit(1);
4662
+ }
4663
+ wanted = claimed[0][0];
4664
+ }
4665
+ const found = findSliceDir(kitDir, wanted);
4666
+ if (!found) {
4667
+ console.error(`❌ Slice "${wanted}" not found under ${relative(cwd, join(kitDir, '.slices'))}.`);
4668
+ process.exit(1);
4669
+ }
4670
+ const globalOpts = command.optsWithGlobals();
4671
+ const cfg = loadEffectiveConfig(cwd, kitDir, globalOpts.config).config;
4672
+ const fetchNodes = async (ids) => {
4673
+ if (['organizationId', 'boardId', 'token'].some((f) => !cfg[f])) throw new Error('token/organizationId/boardId are not configured (.eventmodelers/config.json)');
4674
+ const res = await fetch(`${cfg.baseUrl || DEFAULT_BASE_URL}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/query`, {
4675
+ method: 'POST',
4676
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'cli-ui-baseline', ...agentHeaders(cfg) },
4677
+ body: JSON.stringify({ ids }),
4678
+ });
4679
+ if (!res.ok) throw new Error(`board returned HTTP ${res.status}`);
4680
+ return res.json();
4681
+ };
4682
+ let fp;
4683
+ try {
4684
+ fp = await fingerprint({ slice: found.slice, fetchNodes, scope: opts.scope });
4685
+ } catch (err) {
4686
+ // Cannot tell — the safe answer is the full check, never a skip.
4687
+ console.log('UNKNOWN');
4688
+ console.log(`Could not read the screen from the board (${err.message}) — do the full screen diff.`);
4689
+ return;
4690
+ }
4691
+ if (action === 'record') {
4692
+ writeBaseline(found.path, fp, opts.scope);
4693
+ 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.`);
4694
+ return;
4695
+ }
4696
+ const result = compare(readBaseline(found.path), fp, opts.scope);
4697
+ console.log(result.verdict);
4698
+ if (result.verdict === 'UNCHANGED') {
4699
+ console.log('Screen, elements and UI prompts are as of the last UI build — skip all UI work.');
4700
+ return;
4701
+ }
4702
+ 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`.');
4703
+ const nodeLine = (label, list) => (list.length ? `\n ${label}: ${list.map((n) => `${n.title} (${n.kind === 'screen' ? 'screen' : n.kind.replace(/s$/, '')})`).join(', ')}` : '');
4704
+ const nodeDetail = nodeLine('changed', result.changedNodes) + nodeLine('added', result.addedNodes) + nodeLine('removed', result.removedNodes);
4705
+ if (nodeDetail) console.log(`Since the last recorded build:${nodeDetail}\nWork on exactly these; leave what matches alone.`);
4706
+ if (result.screenChanged && result.verdict === 'CHANGED') console.log('SCREEN_CHANGED: re-fetch the screen node, diff the components against it and adjust the UI.');
4707
+ 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.');
4708
+ 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('')}`);
4709
+ console.log('When the UI is done, run `ui-baseline record`.');
4710
+ });
4711
+
4417
4712
  program
4418
4713
  .command('learn [text]')
4419
4714
  .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`).")
@@ -4451,6 +4746,42 @@ program
4451
4746
  }
4452
4747
  });
4453
4748
 
4749
+ program
4750
+ .command('info <text>')
4751
+ .description("For a build agent: record something that is not a reusable learning — what you cannot change and why (limitation), a decision you took, a note. It is an event in the log and a comment on the slice. Reusable knowledge about the codebase or model is a `learn`, not an info.")
4752
+ .option('--kind <kind>', `${INFO_KINDS.join(' · ')} (default note)`, 'note')
4753
+ .option('--slice <idOrTitle>', 'The slice it is about (default: the one the run loop has claimed)')
4754
+ .option('--tag <tag>', 'Label the info (repeatable)', (tag, tags) => [...tags, tag], [])
4755
+ .action(async (text, opts, command) => {
4756
+ const cwd = process.cwd();
4757
+ const kitDir = requireBuildKitDir(cwd);
4758
+ const meta = agentEventMeta(cwd, kitDir, command);
4759
+ try {
4760
+ let sliceId;
4761
+ if (opts.slice) sliceId = findSliceDir(kitDir, opts.slice)?.slice.id ?? opts.slice;
4762
+ else {
4763
+ const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
4764
+ if (claimed.length === 1) sliceId = claimed[0][0];
4765
+ }
4766
+ const info = recordInfo({ kitDir, text, kind: opts.kind, slice: sliceId, tags: opts.tag, meta });
4767
+ console.log(`✅ Recorded ${info.kind} (${info.id.slice(0, 8)}).`);
4768
+ if (!sliceId) return console.log('ℹ️ No slice named or claimed — not posted to the board.');
4769
+ // Best effort: the event is the record, the comment is how the board shows it.
4770
+ const cfg = loadEffectiveConfig(cwd, kitDir, command.optsWithGlobals().config).config;
4771
+ if (['organizationId', 'boardId', 'token'].some((f) => !cfg[f])) return console.log('ℹ️ No board credentials — not posted to the board.');
4772
+ const res = await fetch(`${cfg.baseUrl || DEFAULT_BASE_URL}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/${sliceId}/comments`, {
4773
+ method: 'POST',
4774
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'cli-info', ...agentHeaders(cfg) },
4775
+ body: JSON.stringify({ text: infoComment(info), type: 'COMMENT', author: 'build-agent' }),
4776
+ signal: AbortSignal.timeout(15_000),
4777
+ });
4778
+ console.log(res.ok ? '✅ Posted as a comment on the slice.' : `⚠️ Board returned HTTP ${res.status} — the info is only in the event log.`);
4779
+ } catch (err) {
4780
+ console.error(`❌ ${err.message}`);
4781
+ process.exit(1);
4782
+ }
4783
+ });
4784
+
4454
4785
  program
4455
4786
  .command('events')
4456
4787
  .description("Decode a kit's event log in the terminal: one line per event (claims, settles, learnings, tasks, prompts …) with the slice's title and the agent that wrote it. Filter it, print the raw events, or print the state the log folds to.")
@@ -4462,7 +4793,7 @@ program
4462
4793
  .option('--tag <tag>', 'Only events carrying this tag')
4463
4794
  .option('--last <n>', 'Only the last n events (after the other filters)')
4464
4795
  .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)')
4796
+ .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
4797
  .option('--state', 'Print the state the log folds to (slices, queue, open prompts, learnings) instead of the events')
4467
4798
  .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
4799
  .option('--follow', 'Keep running and print events as they are appended')
@@ -4510,6 +4841,8 @@ program
4510
4841
 
4511
4842
  const follow = opts.follow || opts.watch;
4512
4843
  const events = readEvents(kitDir);
4844
+ // The baseline (each slice's first board-observed) is noise unless asked for: --verbose, the raw --json, or the type itself.
4845
+ const hideBaseline = !opts.verbose && !opts.json && !filter.types?.includes('board-observed');
4513
4846
  const titles = sliceTitles(events);
4514
4847
  if (opts.state) {
4515
4848
  if (follow) console.error('ℹ️ --follow/--watch does not apply to --state.');
@@ -4522,7 +4855,7 @@ program
4522
4855
  if (!opts.global && !opts.kit && readEvents(globalLog).length) console.log('The global install has a log of its own: `eventmodelers events --global`.');
4523
4856
  return;
4524
4857
  }
4525
- print(filterEvents(events, { ...filter, last }, titles), titles);
4858
+ print(filterEvents(events, { ...filter, last, omit: hideBaseline ? baselineIds(events) : undefined }, titles), titles);
4526
4859
  if (!follow) return;
4527
4860
 
4528
4861
  // Follow: what has been printed is remembered by event id, and the log is read again twice a second.
@@ -4531,7 +4864,7 @@ program
4531
4864
  const all = readEvents(kitDir);
4532
4865
  const fresh = all.filter((e) => !seen.has(e.id));
4533
4866
  for (const e of fresh) seen.add(e.id);
4534
- if (fresh.length) print(filterEvents(fresh, filter, sliceTitles(all)), sliceTitles(all));
4867
+ if (fresh.length) print(filterEvents(fresh, { ...filter, omit: hideBaseline ? baselineIds(all) : undefined }, sliceTitles(all)), sliceTitles(all));
4535
4868
  }, 500);
4536
4869
  });
4537
4870
 
@@ -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
+ }