@eventmodelers/cli 1.0.163 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +48 -36
  2. package/RELEASE_NOTES.md +20 -2
  3. package/cli.js +774 -247
  4. package/lib/adapters/spec-kitty-adapter.js +3 -7
  5. package/lib/build-kits.js +23 -0
  6. package/lib/global-agent-id.js +45 -0
  7. package/lib/modeling-local-ai.js +4 -2
  8. package/lib/scaffold-model.js +71 -0
  9. package/lib/scaffold.js +224 -0
  10. package/lib/supervise.js +60 -0
  11. package/lib/ui-baseline.js +123 -0
  12. package/package.json +1 -1
  13. package/shared/build-kit/README.md +4 -19
  14. package/shared/build-kit/lib/agent-lock.js +75 -0
  15. package/shared/build-kit/lib/agent-name.js +27 -0
  16. package/shared/build-kit/lib/event-prune.js +58 -0
  17. package/shared/build-kit/lib/events-view.js +134 -0
  18. package/shared/build-kit/lib/local-ai-agent.js +14 -26
  19. package/shared/build-kit/lib/mcp-register.js +17 -1
  20. package/shared/build-kit/lib/prompt-journal.js +191 -0
  21. package/shared/build-kit/lib/prompt-replay.js +35 -0
  22. package/shared/build-kit/lib/ralph.js +218 -169
  23. package/shared/build-kit/lib/slice-curation.js +40 -0
  24. package/shared/build-kit/lib/slice-events.js +93 -0
  25. package/shared/build-kit/lib/slice-git.js +86 -0
  26. package/shared/build-kit/lib/slice-learnings.js +197 -0
  27. package/shared/build-kit/lib/slice-projection.js +101 -0
  28. package/shared/build-kit/lib/slice-run.js +181 -0
  29. package/shared/build-kit/lib/slice-state.js +184 -0
  30. package/shared/build-kit/lib/slice-tasks.js +35 -0
  31. package/shared/build-kit/ralph-claude.js +4 -2
  32. package/shared/build-kit/ralph-exec.js +4 -2
  33. package/shared/build-kit/ralph-local-ai.js +5 -3
  34. package/shared/scaffold/LICENSE-em-scaffold +21 -0
  35. package/shared/scaffold/supabase/generate.mjs +290 -0
  36. package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
  37. package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
  38. package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
  39. package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
  40. package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
  41. package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
  42. package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
  43. package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
  44. package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
  45. package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
  46. package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
  47. package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
  48. package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
  49. package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
  50. package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
  51. package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
  52. package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
  53. package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
  54. package/shared/skills/learn-eventmodelers-api/SKILL.md +17 -4
  55. package/shared/skills/request-feedback/SKILL.md +1 -2
  56. package/shared/skills/ui-contract/SKILL.md +7 -5
  57. package/shared/skills/update-slice-status/SKILL.md +1 -1
  58. package/stacks/axon/templates/build-kit/CLAUDE.md +7 -6
  59. package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
  60. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
  61. package/stacks/axon/templates/build-kit/lib/prompt.md +32 -41
  62. package/stacks/blank/templates/build-kit/CLAUDE.md +7 -6
  63. package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
  64. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
  65. package/stacks/blank/templates/build-kit/lib/prompt.md +33 -42
  66. package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
  67. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  68. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  69. package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
  70. package/stacks/classic/templates/build-kit/CLAUDE.md +9 -8
  71. package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
  72. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
  73. package/stacks/classic/templates/build-kit/lib/prompt.md +33 -42
  74. package/stacks/kurrent/templates/build-kit/CLAUDE.md +7 -7
  75. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
  76. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
  77. package/stacks/kurrent/templates/build-kit/lib/prompt.md +32 -41
  78. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  79. package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
  80. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
  81. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
  82. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
  83. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
  84. package/stacks/modeling-kit/templates/kit/CLAUDE.md +18 -28
  85. package/stacks/modeling-kit/templates/kit/README.md +1 -1
  86. package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
  87. package/stacks/node/templates/build-kit/CLAUDE.md +7 -6
  88. package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
  89. package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
  90. package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
  91. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +7 -7
  92. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
  93. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
  94. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +32 -41
  95. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  96. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
  97. package/stacks/react/templates/build-kit/CLAUDE.md +7 -6
  98. package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
  99. package/stacks/react/templates/build-kit/lib/backend-prompt.md +25 -42
  100. package/stacks/react/templates/build-kit/lib/kit.json +1 -1
  101. package/stacks/react/templates/build-kit/lib/prompt.md +47 -43
  102. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
  103. package/stacks/supabase/templates/build-kit/CLAUDE.md +7 -6
  104. package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
  105. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
  106. package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
  107. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +80 -8
  108. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +66 -13
  109. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +8 -7
  110. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
  111. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +25 -42
  112. package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
  113. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +48 -44
  114. package/stacks/umadb/templates/build-kit/CLAUDE.md +7 -6
  115. package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
  116. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
  117. package/stacks/umadb/templates/build-kit/lib/prompt.md +32 -41
  118. package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
  119. package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
  120. package/shared/build-kit/lib/agent.sh +0 -20
  121. package/shared/build-kit/ralph.sh +0 -111
  122. package/shared/build-kit/realtime-agent.js +0 -20
  123. package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
  124. package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
  125. package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
  126. package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
  127. package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
  128. package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
package/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { Command } from 'commander';
3
+ import { Command, Option } from 'commander';
4
4
  import { fileURLToPath, pathToFileURL } from 'url';
5
5
  import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'path';
6
6
  import {
@@ -10,6 +10,7 @@ import {
10
10
  copyFileSync,
11
11
  rmSync,
12
12
  readdirSync,
13
+ statSync,
13
14
  writeFileSync,
14
15
  readFileSync,
15
16
  appendFileSync,
@@ -19,6 +20,10 @@ import { createInterface, emitKeypressEvents, moveCursor, clearScreenDown } from
19
20
  import { homedir, tmpdir } from 'os';
20
21
  import { randomUUID } from 'crypto';
21
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';
22
27
  import { createModelingLocalAiRunner } from './lib/modeling-local-ai.js';
23
28
  import { run as runSpecKittyAdapter } from './lib/adapters/spec-kitty-adapter.js';
24
29
  import { latestPublishedVersion, updateHints } from './lib/update-check.js';
@@ -29,14 +34,27 @@ import { modelingPermissions } from './lib/agent-permissions.js';
29
34
  // copyDirContents in installStack) for ralph.js to import standalone — see
30
35
  // shared/build-kit/lib/adapters/realtime-adapter.js for why it lives there instead.
31
36
  import { createRealtimeAdapter, keepSubscribed } from './shared/build-kit/lib/adapters/realtime-adapter.js';
37
+ import { recordAgentStatus } from './shared/build-kit/lib/slice-run.js';
38
+ import { findSliceDir, fingerprint, readBaseline, writeBaseline, compare } from './lib/ui-baseline.js';
39
+ import { defaultAgentName, withInstanceSuffix } from './shared/build-kit/lib/agent-name.js';
40
+ import { acquireAgentId } from './shared/build-kit/lib/agent-lock.js';
41
+ import { readEvents } from './shared/build-kit/lib/slice-events.js';
42
+ import { findPromptEvent, replayRequest } from './shared/build-kit/lib/prompt-replay.js';
43
+ import { prunableEvents, pruneLog, rememberPruneAnswer, shouldOfferPrune } from './shared/build-kit/lib/event-prune.js';
44
+ import { fold } from './shared/build-kit/lib/slice-state.js';
45
+ import { filterEvents, baselineIds, formatEvent, formatPayload, sliceTitles, summarize } from './shared/build-kit/lib/events-view.js';
46
+ import { stableGlobalAgentId } from './lib/global-agent-id.js';
47
+ import { ensureLearningsGitRules } from './shared/build-kit/lib/ralph.js';
48
+ import { createCallTracker, heldPromptsRequest, journalPromptAction, shouldJournalCall, journalPromptFinished, journalPromptReceived, recoverOwnPrompts } from './shared/build-kit/lib/prompt-journal.js';
49
+ import { listLearnings, mergeLearnings, recordLearning, recordTurnLearnings, retractLearning, syncLearnings } from './shared/build-kit/lib/slice-learnings.js';
32
50
 
33
51
  const __filename = fileURLToPath(import.meta.url);
34
52
  const __dirname = dirname(__filename);
35
53
 
36
54
  // Each stack is a template set under stacks/<key>/templates/{.claude,root,<kitSubdir>}.
37
55
  // Stacks with useShared:true also get shared/build-kit/* copied into their kit dir
38
- // (ralph.js, ralph-claude.js, ralph-local-ai.js, ralph-exec.js, ralph.sh, realtime-agent.js,
39
- // code-export.mjs, lib/agent.sh, lib/local-ai-agent.js, package.json, README.md) —
56
+ // (ralph.js, ralph-claude.js, ralph-local-ai.js, ralph-exec.js,
57
+ // code-export.mjs, lib/local-ai-agent.js, package.json, README.md) —
40
58
  // those files have no per-stack content, so they live once instead of being
41
59
  // copy-pasted into every stack (that copy-pasting is exactly how they drifted out
42
60
  // of sync before: a bugfix or default landing in one stack's copy but not another's).
@@ -44,7 +62,7 @@ const __dirname = dirname(__filename);
44
62
  // (lib/prompt.md, lib/backend-prompt.md, lib/AGENT.md, checks, etc.); shared/build-kit/*
45
63
  // is copied after it, so the shared runtime always wins over any copy a stack still ships.
46
64
  // modeling-kit (below) is the one kit that opts out of all of this (useShared:false)
47
- // — it has no cold-spawn/tasks.json runtime at all, so none of shared/build-kit/*
65
+ // — it has no cold-spawn/task-queue runtime at all, so none of shared/build-kit/*
48
66
  // applies to it; see its own templates/kit for its (much smaller) self-contained set.
49
67
  // --- Local-AI runner selection ------------------------------------------------
50
68
  // One runner drives every local/self-hosted backend. What differs between Ollama,
@@ -117,8 +135,8 @@ function resolveAgentPreset(opts, loop) {
117
135
  console.error('❌ --agent and --exec are mutually exclusive — --agent is a shortcut for --exec.');
118
136
  process.exit(1);
119
137
  }
120
- if (resolveLocalAiTarget(opts) !== null || opts.bash) {
121
- console.error('❌ --agent is mutually exclusive with --local-ai/--bash — pick one runner.');
138
+ if (resolveLocalAiTarget(opts) !== null) {
139
+ console.error('❌ --agent is mutually exclusive with --local-ai — pick one runner.');
122
140
  process.exit(1);
123
141
  }
124
142
  const cmd = AGENT_PRESETS[name]?.[loop] ?? null;
@@ -212,7 +230,7 @@ const STACKS = {
212
230
 
213
231
  // Not a stack — no backend scaffold, just skills + the agent loop. Installed via
214
232
  // `init --modeling` instead of the `init --stack <name>` picker.
215
- // useShared:false — unlike build-kit, modeling-kit has no cold-spawn/tasks.json
233
+ // useShared:false — unlike build-kit, modeling-kit has no cold-spawn/task-queue
216
234
  // runtime to reuse from shared/build-kit/*; its only runtime mode is the CLI's
217
235
  // own warm, direct-dispatch loop (`run --modeling`), so its kit dir just needs
218
236
  // lib/config.js for config resolution — see stacks/modeling-kit/templates/kit.
@@ -225,34 +243,9 @@ const MODELING_KIT = {
225
243
  needsBoardId: false,
226
244
  };
227
245
 
228
- // Frameworks a bridge install can translate board slices into. Each key needs
229
- // a matching `bridge-<key>-specify` skill under shared/bridge/ — see
230
- // stacks/bridge/templates/bridge/lib/prompt.md for how the loop picks it up.
231
- const BRIDGE_TARGETS = {
232
- 'spec-kitty': { label: 'Spec Kitty' },
233
- };
234
-
235
- // Also not a stack — no backend scaffold, just the bridge-*/shared skills +
236
- // the agent loop. Installed via `init --bridge --target <name>` instead of
237
- // the `init --stack <name>` picker. useShared:true (unlike modeling-kit): a
238
- // bridge agent reuses build-kit's cold-spawn/tasks.json engine as-is
239
- // (lib/ralph.js) — it just reacts to every slice change instead of only
240
- // "Planned" ones (see queueAllStatuses in lib/ralph.js) and translates
241
- // instead of building. Its own templates/bridge overlay swaps in
242
- // bridge-specific prompt.md/AGENT.md and a ralph-claude.js that omits
243
- // onPlannedSlice entirely — see stacks/bridge/templates/bridge.
244
- const BRIDGE_KIT = {
245
- key: 'bridge',
246
- label: 'Bridge — translate board slices into another spec framework, no backend scaffold',
247
- kitSubdir: 'bridge',
248
- kitDirName: '.bridge-kit',
249
- useShared: true,
250
- needsBoardId: true,
251
- };
252
-
253
246
  // Also not a stack — installed via `init --build-kit` instead of `init --stack <name>`.
254
247
  // useShared:true, same as any real backend stack: it reuses build-kit's cold-spawn/
255
- // tasks.json engine as-is (lib/ralph.js). Its templates/build-kit/CLAUDE.md,
248
+ // task-queue engine as-is (lib/ralph.js). Its templates/build-kit/CLAUDE.md,
256
249
  // lib/{prompt,backend-prompt}.md, and templates/.claude/skills/build-*/SKILL.md are
257
250
  // TODO-marked placeholders instead of real stack content (see stacks/blank/templates)
258
251
  // — this is for a stack that isn't built into this CLI yet: fill in the TODOs against
@@ -267,7 +260,7 @@ const BLANK_BUILD_KIT = {
267
260
  needsBoardId: true,
268
261
  };
269
262
 
270
- const KIT_DIR_NAMES = [...new Set([...Object.values(STACKS), MODELING_KIT, BRIDGE_KIT, BLANK_BUILD_KIT].map((s) => s.kitDirName))];
263
+ const KIT_DIR_NAMES = [...new Set([...Object.values(STACKS), MODELING_KIT, BLANK_BUILD_KIT].map((s) => s.kitDirName))];
271
264
 
272
265
  // What `init --demo` installs, for the messages it prints. Kept in step with
273
266
  // shared/demo-slices/ (context.json's name, and the slice folders beside it).
@@ -475,6 +468,30 @@ function getSharedRl() {
475
468
  return sharedRl;
476
469
  }
477
470
 
471
+ // --force on a build kit: the credentials step of `init-config` against the project-root config the
472
+ // runner reads. Interactive, so under `run --all` the parent runs it for each kit one after the other —
473
+ // the supervised children have no stdin to ask on. `shared` (another kit's answer) is applied
474
+ // instead of asking, so every kit of one run ends up on the same credentials.
475
+ async function forceBuildKitCredentials(kitDir, cwd, opts, globalOpts, shared = null) {
476
+ const projectDir = dirname(kitDir);
477
+ const configPath = globalOpts.config
478
+ ? resolve(projectDir, globalOpts.config)
479
+ : join(projectDir, '.eventmodelers', 'config.json');
480
+ const cfg = await configureCredentials({
481
+ config: loadEffectiveConfig(cwd, kitDir, globalOpts.config).config,
482
+ configPath,
483
+ targetDir: projectDir,
484
+ requiredFields: ['organizationId', 'boardId', 'token'],
485
+ overrides: shared ?? credentialOverridesFromOpts(opts),
486
+ print: false,
487
+ force: !shared,
488
+ });
489
+ ensureMcpRegistered(projectDir, cfg.baseUrl || DEFAULT_BASE_URL);
490
+ ensureEnvToken(projectDir, cfg.token);
491
+ ensureMcpEnabled(projectDir);
492
+ return cfg;
493
+ }
494
+
478
495
  // Pulls one line from the shared readline's own async iterator rather than calling
479
496
  // its `.question()` — `.question()` attaches a one-shot 'line' listener *after* the
480
497
  // prompt is issued, but when stdin is piped (a file, `<<<`, scripted/CI input) readline
@@ -953,7 +970,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
953
970
  // install TO, versus where we install FROM.
954
971
  const targetDir = options.targetDir ? resolve(options.targetDir) : process.cwd();
955
972
  // `init --git <url>` passes a resolved clone dir's templates/ here instead — every
956
- // other input (STACKS, MODELING_KIT, BRIDGE_KIT) keeps using the built-in path.
973
+ // other input (STACKS, MODELING_KIT) keeps using the built-in path.
957
974
  const templatesSource = options.templatesSource || join(__dirname, 'stacks', stackKey, 'templates');
958
975
  const sharedBuildKit = join(__dirname, 'shared', 'build-kit');
959
976
  // Skills with no stack-specific content (connect, learn-eventmodelers-api,
@@ -962,15 +979,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
962
979
  // of sync with each other before (e.g. one stack's connect skill silently
963
980
  // missing a bugfix another stack's copy had).
964
981
  const sharedSkills = join(__dirname, 'shared', 'skills');
965
- // Adapter skills that translate board slices for another spec framework —
966
- // one subfolder per target (shared/bridge/spec-kitty/bridge-spec-kitty-*,
967
- // shared/bridge/kiro/..., etc.), so a bridge install only ever pulls in
968
- // the target it was actually configured for, not every framework's
969
- // skills. Only relevant to a bridge install — never copied into the four
970
- // backend stacks or modeling-kit.
971
- const isBridge = stackKey === BRIDGE_KIT.key;
972
982
  const isModelingKit = stackKey === MODELING_KIT.key;
973
- const sharedBridgeSkills = isBridge ? join(__dirname, 'shared', 'bridge', options.target) : null;
974
983
 
975
984
  if (!existsSync(templatesSource)) {
976
985
  console.error('❌ Templates directory not found at:', templatesSource);
@@ -983,7 +992,6 @@ async function installStack(stackKey, stackCfg, options = {}) {
983
992
  const claudeSkillsSrc = join(templatesSource, '.claude', 'skills');
984
993
  const installedSkills = [
985
994
  ...(existsSync(sharedSkills) ? readdirSync(sharedSkills) : []),
986
- ...(isBridge && existsSync(sharedBridgeSkills) ? readdirSync(sharedBridgeSkills) : []),
987
995
  ...(existsSync(claudeSkillsSrc) ? readdirSync(claudeSkillsSrc) : []),
988
996
  ];
989
997
  let claudeExtras = [];
@@ -992,14 +1000,12 @@ async function installStack(stackKey, stackCfg, options = {}) {
992
1000
  const globalSkillsDir = join(homedir(), '.claude', 'skills');
993
1001
  console.log('📦 Installing skills globally...');
994
1002
  copyDirContents(sharedSkills, globalSkillsDir);
995
- if (isBridge) copyDirContents(sharedBridgeSkills, globalSkillsDir);
996
1003
  copyDirContents(claudeSkillsSrc, globalSkillsDir);
997
1004
  } else {
998
1005
  console.log('📦 Installing skills...');
999
1006
  // Shared skills first, the stack's own templates last — so a build kit can override
1000
1007
  // a shared skill (e.g. `ui-contract`) just by shipping a skill of the same name.
1001
1008
  copyDirContents(sharedSkills, join(targetDir, '.claude', 'skills'));
1002
- if (isBridge) copyDirContents(sharedBridgeSkills, join(targetDir, '.claude', 'skills'));
1003
1009
  copyDirContents(join(templatesSource, '.claude'), join(targetDir, '.claude'));
1004
1010
  claudeExtras = existsSync(join(templatesSource, '.claude'))
1005
1011
  ? readdirSync(join(templatesSource, '.claude')).filter((f) => f !== 'skills')
@@ -1041,7 +1047,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
1041
1047
  }
1042
1048
  }
1043
1049
 
1044
- // A modeling-kit + build-kit (or bridge) combo in the same project is supported
1050
+ // A modeling-kit + build-kit combo in the same project is supported
1045
1051
  // (they share one config.json — see ensureAgentId) but each kit's own instructions
1046
1052
  // now live in its own kit dir (.build-kit/CLAUDE.md, .agent-modeling-kit/CLAUDE.md)
1047
1053
  // instead of root/CLAUDE.md, precisely so a second kit's install can never clobber
@@ -1131,16 +1137,8 @@ async function installStack(stackKey, stackCfg, options = {}) {
1131
1137
  }
1132
1138
  }
1133
1139
 
1134
- // Static (no-LLM) bridge adapters live once in this package's own lib/
1135
- // adapters/ — `fetch --spec-kitty` imports them directly, and a bridge
1136
- // install gets its own copy here so ralph-static.js can run standalone
1137
- // with no access back to the published package.
1138
- if (isBridge) {
1139
- copyDirContents(join(__dirname, 'lib', 'adapters'), join(kitDir, 'adapters'));
1140
- }
1141
-
1142
1140
  // Make scripts executable
1143
- for (const script of ['ralph.sh', 'lib/agent.sh', 'ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1141
+ for (const script of ['ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1144
1142
  const p = join(kitDir, script);
1145
1143
  if (existsSync(p)) {
1146
1144
  try { execSync(`chmod +x "${p}"`); } catch {}
@@ -1246,12 +1244,10 @@ async function installStack(stackKey, stackCfg, options = {}) {
1246
1244
  if (options.skipEpilogue) return;
1247
1245
 
1248
1246
  console.log('\n✅ Done! Start your agent:\n');
1249
- if (isBridge) {
1250
- console.log(' npx @eventmodelers/cli bridge\n');
1251
- } else if (isModelingKit) {
1247
+ if (isModelingKit) {
1252
1248
  console.log(' npx @eventmodelers/cli run --modeling\n');
1253
1249
  } else {
1254
- console.log(' npx @eventmodelers/cli run (--local-ai or --bash for other runners)\n');
1250
+ console.log(' npx @eventmodelers/cli run (--local-ai or --exec for other runners)\n');
1255
1251
  }
1256
1252
  console.log('Connect this project to an MCP client (Claude Code, VS Code, ...):\n');
1257
1253
  console.log(` npx @eventmodelers/cli init-mcp\n`);
@@ -1390,7 +1386,7 @@ async function refreshSharedRuntime({ targetDir, kitDir, globalOpts, opts }) {
1390
1386
  if (existsSync(manifestPath)) {
1391
1387
  writeFileSync(manifestPath, JSON.stringify({ ...readJsonSafe(manifestPath), version: CLI_VERSION }, null, 2));
1392
1388
  }
1393
- for (const script of ['ralph.sh', 'lib/agent.sh', 'ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1389
+ for (const script of ['ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1394
1390
  const p = join(kitDir, script);
1395
1391
  if (existsSync(p)) {
1396
1392
  try { execSync(`chmod +x "${p}"`); } catch {}
@@ -1750,6 +1746,22 @@ async function fetchDefaultBoardId(baseUrl, token) {
1750
1746
  }
1751
1747
  }
1752
1748
 
1749
+ // The board whose credentials were written most recently — the best guess for "the board I
1750
+ // was just driving" when nothing in this directory names one. Only offered as the default
1751
+ // of the interactive question, never applied silently.
1752
+ function lastUsedBoardId() {
1753
+ try {
1754
+ const dir = join(GLOBAL_DIR, 'boards');
1755
+ return readdirSync(dir)
1756
+ .filter((f) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/.test(f))
1757
+ .map((f) => ({ id: f.slice(0, -5), mtime: statSync(join(dir, f)).mtimeMs, creds: readJsonSafe(join(dir, f)) }))
1758
+ .filter((b) => b.creds.token)
1759
+ .sort((a, b) => b.mtime - a.mtime)[0]?.id ?? null;
1760
+ } catch {
1761
+ return null;
1762
+ }
1763
+ }
1764
+
1753
1765
  // Per-run credentials for the global install. Precedence is this CLI's usual one, with the
1754
1766
  // per-board file slotted in as the most specific *file*: explicit flags beat
1755
1767
  // EVENTMODELERS_* env vars beat ~/.eventmodelers/boards/<board>.json beat the nearest
@@ -1789,6 +1801,7 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print,
1789
1801
  let boardChosen = !!(explicit.boardId || process.env.EVENTMODELERS_BOARD_ID);
1790
1802
  let pasted = null;
1791
1803
  if (!boardChosen && interactive) {
1804
+ boardId = boardId || lastUsedBoardId();
1792
1805
  const answer = await prompt(boardId ? `\n Board ID [${boardId}]: ` : '\n Board ID: ');
1793
1806
  if (answer) {
1794
1807
  pasted = parseCredentialsPaste(answer, ['organizationId', 'token', 'boardId']);
@@ -1916,16 +1929,12 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print,
1916
1929
  process.exit(1);
1917
1930
  }
1918
1931
 
1919
- // A fresh identity for every standalone run, deliberately not persisted. A standalone agent
1920
- // is started ad hoc from wherever, and nothing stops two of them running for the same board —
1921
- // with one id stored per board they upserted the same alive row (the heartbeat is keyed on
1922
- // token + agent_id + agent_type), so the second agent replaced the first instead of joining
1923
- // it: the board showed one agent however many were running, and their writes were
1924
- // indistinguishable. A per-run uuid costs the identity its continuity across restarts (a
1925
- // restarted agent is a new row, and the old one lingers until its 45s window lapses) — pass
1926
- // `run --id <uuid>` when an agent needs to keep one identity, which is also what makes
1927
- // "preferred agent" on the board stick to it.
1928
- config.agentId = randomUUID();
1932
+ // One identity per board and repo — the directory the run is started in — kept in ~/.eventmodelers/agent-ids.json, so a
1933
+ // standalone agent is the same agent on every start: its chat sessions, its star on the board and the prompts it holds
1934
+ // stay addressed to it. This is the id it keeps; whether this process may use it (another live run may already be
1935
+ // under it) is decided in the run action, which also lets `run --id` pin a different one. Not written into the
1936
+ // per-board credentials file below — the id is not a credential.
1937
+ config.agentId = stableGlobalAgentId({ file: join(GLOBAL_DIR, 'agent-ids.json'), boardId: config.boardId, dir: cwd });
1929
1938
  writeBoardCredentials(stored.useGlobal
1930
1939
  ? { boardId: config.boardId, useGlobal: true, agentName: config.agentName }
1931
1940
  : config);
@@ -1963,7 +1972,7 @@ async function ensureGlobalKit(baseUrl) {
1963
1972
  }
1964
1973
 
1965
1974
  // `run --modeling`: modeling-kit's one and only runtime mode — there is no
1966
- // cold-spawn/tasks.json loop for this kit (that's a build-kit concept; see the
1975
+ // cold-spawn/task-queue loop for this kit (that's a build-kit concept; see the
1967
1976
  // `run` command's build-kit-vs-modeling-kit gate above). It keeps ONE Claude
1968
1977
  // process warm across turns via `--input-format stream-json`, subscribes to the
1969
1978
  // org's realtime channel itself, and writes each prompt straight to that
@@ -1990,6 +1999,27 @@ async function ensureGlobalKit(baseUrl) {
1990
1999
  // untargeted is handed straight back to the queue for another agent to take. It says
1991
2000
  // nothing about the standalone lane — a self-directed turn is nobody's task, so an
1992
2001
  // exclusive standalone agent still works the board on its own initiative.
2002
+ // A new instance of a loop starts by checking how big its event log has grown: at every multiple of 1000 events it
2003
+ // asks whether to prune it (`events --prune` does the same on demand). Only asked where someone can answer.
2004
+ async function offerPrune(kitDir, { interactive }) {
2005
+ let count;
2006
+ try { count = readEvents(kitDir).length; } catch { return; }
2007
+ if (!shouldOfferPrune(kitDir, count)) return;
2008
+ const prunable = prunableEvents(readEvents(kitDir)).length;
2009
+ if (!interactive) {
2010
+ console.error(`ℹ️ The event log has ${count} events (${prunable} of them prunable) — \`eventmodelers events --prune\` removes them.`);
2011
+ return;
2012
+ }
2013
+ const answer = (await prompt(`The event log has ${count} events. Should we prune the log? Removes the journal of finished prompts (${prunable} events); slice state and learnings stay. [y/N] `)).toLowerCase();
2014
+ if (answer === 'y' || answer === 'yes') {
2015
+ const { removed, kept } = pruneLog(kitDir);
2016
+ rememberPruneAnswer(kitDir, kept);
2017
+ console.log(`Pruned ${removed} events, ${kept} left.\n`);
2018
+ } else {
2019
+ rememberPruneAnswer(kitDir, count);
2020
+ }
2021
+ }
2022
+
1993
2023
  async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false, standalone = false, exclusive = false, worker = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS, identity = {}, localAi = null, exec = null, model = null, skipPermissions = false, browser = false } = {}) {
1994
2024
  // --worker: only prompts — no chat, and no board-change turns (it has nowhere to propose).
1995
2025
  const chat = !worker;
@@ -2017,7 +2047,17 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2017
2047
  // `run --id` overrides that for this run only — ensureAgentId is skipped rather than
2018
2048
  // overwritten, so the project's own stable id stays on disk and the next run without the
2019
2049
  // flag is the same agent the platform saw before.
2020
- if (!overrides) local.agentId = identity.agentId || ensureAgentId(kitDir, 'MODELING');
2050
+ if (!overrides) {
2051
+ if (identity.agentId) {
2052
+ local.agentId = identity.agentId;
2053
+ } else {
2054
+ // The project's stable id — unless a live process already runs under it (two `run --modeling` in one
2055
+ // project), in which case this one gets a fresh id (agent-lock.js).
2056
+ const { agentId, fellBack } = acquireAgentId({ lockDir: join(projectDir, '.eventmodelers', 'locks'), stableId: ensureAgentId(kitDir, 'MODELING') });
2057
+ local.agentId = agentId;
2058
+ local.agentIdFellBack = fellBack;
2059
+ }
2060
+ }
2021
2061
  if (!local.token || !local.organizationId) {
2022
2062
  console.error('❌ --modeling needs platform credentials in .eventmodelers/config.json (token + organizationId) — run `/connect` once or paste your config first.');
2023
2063
  process.exit(1);
@@ -2026,6 +2066,17 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2026
2066
  // resolveModelingCredentials just minted for this run, which would otherwise win back over
2027
2067
  // an explicit --id.
2028
2068
  const cfg = { ...applyEnvOverrides(await fetchPlatformConfig(local)), ...(overrides ?? {}), ...(identity.agentId ? { agentId: identity.agentId } : {}), ...(identity.agentName ? { agentName: identity.agentName } : {}), ...(model ? { model } : {}) }; // adds realtimeProvider + its provider-specific fields (supabaseUrl/supabaseAnonKey or pocketbaseUrl), + boardId if the config has a default one
2069
+ // No name given (--name, agentName in the config): the repository's, so the board shows which agent is live.
2070
+ // (On the global install projectDir is ~/.eventmodelers/kit; the repo is the directory the run was started in.)
2071
+ if (!cfg.agentName) {
2072
+ const base = defaultAgentName(codeDir ?? projectDir, 'MODELING');
2073
+ cfg.agentName = cfg.agentIdFellBack ? withInstanceSuffix(base, cfg.agentId) : base;
2074
+ }
2075
+ // Learnings are kept only on a project install: the global install's kit dir (~/.eventmodelers/kit) is shared by every
2076
+ // board and account, so what one board taught the agent would end up in another's AGENTS.md. The prompt journal has no
2077
+ // such problem — every entry is stamped with its board (`events --board`) — so every run keeps it, standalone included.
2078
+ const keepsRecord = !standalone && !overrides;
2079
+ if (keepsRecord) ensureLearningsGitRules(kitDir);
2029
2080
  if (!cfg.boardId) {
2030
2081
  console.error('❌ --modeling needs a boardId — a modeling agent always runs for exactly one board. Run `/connect board=<uuid>` once, or add boardId to .eventmodelers/config.json.');
2031
2082
  process.exit(1);
@@ -2051,7 +2102,7 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2051
2102
  let localRunner = null;
2052
2103
  if (localAi) {
2053
2104
  try {
2054
- localRunner = createModelingLocalAiRunner({ cfg, target: localAi, log, verbose, standalone });
2105
+ localRunner = createModelingLocalAiRunner({ cfg, target: localAi, log, verbose, standalone, onToolUse: (name, input) => { const id = `local-${++localCallSeq}`; noteToolStart(id, name, input); return (outcome) => noteToolEnd(id, outcome); } });
2055
2106
  } catch (err) {
2056
2107
  console.error(`❌ --local-ai: ${err.message}`);
2057
2108
  process.exit(1);
@@ -2190,6 +2241,19 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2190
2241
  let proc = null;
2191
2242
  let stdoutBuffer = '';
2192
2243
  let pending = null; // one in-flight turn at a time
2244
+ let turnPromptId = null; // the prompt the current turn works, so the board writes it makes are journaled against it
2245
+ // A call is journaled when it ends, so its entry carries how long it took (see journalPromptAction). Only the calls the
2246
+ // journal will keep are tracked at all.
2247
+ const calls = createCallTracker();
2248
+ let localCallSeq = 0;
2249
+ const journalCall = (call) => journal(() => journalPromptAction({ kitDir, id: turnPromptId, toolName: call.name, input: call.input, verbose, detail: verbose ? describeToolUse(call) : undefined, secrets: [cfg.token], startedAt: call.startedAt, durationMs: call.durationMs, error: call.error, meta: journalMeta }));
2250
+ const noteToolStart = (id, name, input) => {
2251
+ if (turnPromptId && shouldJournalCall(name, input, verbose)) calls.begin(id, { name, input });
2252
+ };
2253
+ const noteToolEnd = (id, outcome) => {
2254
+ const call = calls.end(id, outcome);
2255
+ if (call) journalCall(call);
2256
+ };
2193
2257
  // The last message of each session this process has handed to Claude. A session the process has
2194
2258
  // never seen is read whole; after that only what came since — each message enters the warm
2195
2259
  // session's context once. Cleared whenever the process is (re)spawned: a fresh process has read
@@ -2248,10 +2312,16 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2248
2312
  let msg;
2249
2313
  try { msg = JSON.parse(line); } catch { return; }
2250
2314
 
2315
+ if (msg.type === 'user' && Array.isArray(msg.message?.content)) {
2316
+ for (const block of msg.message.content) if (block.type === 'tool_result') noteToolEnd(block.tool_use_id, { isError: block.is_error });
2317
+ return;
2318
+ }
2319
+
2251
2320
  if (msg.type === 'assistant') {
2252
2321
  for (const block of msg.message?.content ?? []) {
2253
2322
  if (block.type === 'text' && block.text && verbose) log(block.text);
2254
2323
  if (block.type === 'tool_use') {
2324
+ noteToolStart(block.id, block.name, block.input);
2255
2325
  if (verbose) log(`→ ${describeToolUse(block)}`);
2256
2326
  else if (block.name === 'Skill') log(`→ Skill: ${block.input?.skill ?? ''}`);
2257
2327
  else log(`→ ${block.name}`);
@@ -2381,10 +2451,10 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2381
2451
  log(chat ? 'chat: ON — answering chat messages addressed to this agent' : 'chat: off (--worker)');
2382
2452
  if (exclusive) {
2383
2453
  log(`exclusive: ON — only prompts addressed to ${cfg.agentId} are worked; every untargeted prompt is handed back to the queue`);
2384
- // A global/standalone run mints its id per run (see resolveModelingCredentials), so an id
2385
- // someone addressed a prompt to yesterday is not this agent — worth saying out loud here,
2454
+ // A run keeps its stable id unless another live process is already running under it (agent-lock.js), in which
2455
+ // case it got a fresh one — and an id someone addressed a prompt to is not this agent. Worth saying out loud here,
2386
2456
  // where the alternative is an agent that looks healthy and quietly works nothing.
2387
- if (overrides && !identity.agentId) log('exclusive: this run minted a fresh agent id — star it on the board now, or restart with `--id <uuid>` to keep one addressable identity');
2457
+ if (cfg.agentIdFellBack) log('exclusive: another process is already running under this agent\'s usual id, so this run got a fresh one — star it on the board now, or stop the other process and restart');
2388
2458
  }
2389
2459
  // Both warm-ups are the same bet — pay the session's fixed setup cost before a turn arrives
2390
2460
  // rather than making whoever sends the first prompt wait for it. For a local model that cost
@@ -2420,7 +2490,16 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2420
2490
  headers: { 'x-token': cfg.token, 'Content-Type': 'application/json', ...agentHeaders(cfg) },
2421
2491
  body: JSON.stringify({ status: 'ADDED' }),
2422
2492
  });
2423
- if (!res.ok) throw new Error(`prompts/${promptId}/status: HTTP ${res.status}`);
2493
+ if (!res.ok) throw Object.assign(new Error(`prompts/${promptId}/status: HTTP ${res.status}`), { status: res.status });
2494
+ }
2495
+
2496
+ // The prompts the platform shows as held (CLAIMED / IN_PROGRESS) under this agent's id — GET /prompts?claimed_by=,
2497
+ // which reads and claims nothing. At start an agent holds none, so these are what an earlier run left behind.
2498
+ async function fetchHeldPrompts(agentId) {
2499
+ const { url, headers } = heldPromptsRequest({ baseUrl: cfg.baseUrl, organizationId: cfg.organizationId, boardId: cfg.boardId, token: cfg.token, jwt: realtimeToken, agentId });
2500
+ const res = await fetch(url, { headers });
2501
+ if (!res.ok) throw new Error(`prompts list: HTTP ${res.status}`);
2502
+ return ((await res.json()).prompts ?? []).map((p) => ({ id: p.id, status: p.status }));
2424
2503
  }
2425
2504
 
2426
2505
  // ── Chat ───────────────────────────────────────────────────────────────────
@@ -2547,6 +2626,21 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2547
2626
 
2548
2627
  let realtimeToken = await getRealtimeToken();
2549
2628
 
2629
+ // What this agent writes into its own record: events in the kit, stamped with who and where (learnings: see keepsRecord).
2630
+ const journalMeta = { agent_id: cfg.agentId, agent_type: 'MODELING', organization_id: cfg.organizationId, board_id: cfg.boardId };
2631
+ const journal = (write) => {
2632
+ try { write(); } catch (err) { log(`could not write the prompt journal: ${err.message}`); }
2633
+ };
2634
+
2635
+ // Prompts an earlier run of this agent was holding are still CLAIMED / IN_PROGRESS — the platform never re-queues
2636
+ // them by itself. This run holds none yet, so whatever the platform shows as held under this agent's id goes back.
2637
+ {
2638
+ const recovered = await recoverOwnPrompts({ kitDir, meta: journalMeta, agentId: cfg.agentId, fetchHeld: fetchHeldPrompts, release: releasePrompt });
2639
+ if (recovered.released || recovered.settled || recovered.left) {
2640
+ log(`prompts from an earlier run: ${recovered.released} handed back, ${recovered.settled} already settled${recovered.left ? `, ${recovered.left} left for the next start` : ''}`);
2641
+ }
2642
+ }
2643
+
2550
2644
  let draining = false;
2551
2645
  async function drain() {
2552
2646
  if (draining) return;
@@ -2583,10 +2677,28 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2583
2677
  continue;
2584
2678
  }
2585
2679
  log(`prompt received: "${p.prompt}" (board=${p.board_id ?? cfg.boardId ?? 'n/a'}, priority=${p.priority})`);
2680
+ journal(() => journalPromptReceived({ kitDir, prompt: p, meta: journalMeta }));
2586
2681
  try {
2587
- await runTurn(buildTurn(p));
2682
+ turnPromptId = p.id;
2683
+ const reply = await runTurn(buildTurn(p));
2684
+ // The agent cannot run `learn` (locked-down Bash), so it states learnings in its reply and they are recorded here.
2685
+ // Best effort: a failed write never takes the prompt queue down.
2686
+ if (keepsRecord) {
2687
+ try {
2688
+ const n = recordTurnLearnings({ kitDir, reply, meta: journalMeta });
2689
+ if (n) log(`recorded ${n} learning${n === 1 ? '' : 's'} from the turn`);
2690
+ } catch (err) {
2691
+ log(`could not record the turn's learnings: ${err.message}`);
2692
+ }
2693
+ }
2694
+ journal(() => journalPromptFinished({ kitDir, id: p.id, outcome: 'worked', meta: journalMeta }));
2588
2695
  } catch (err) {
2696
+ // Not finished: the entry stays open, so the next start asks the platform about it and hands it back
2697
+ // (or closes it if the agent had set DONE before the turn failed).
2589
2698
  log(`turn failed: ${err.message}`);
2699
+ } finally {
2700
+ for (const call of calls.drain()) journalCall(call); // never ended: the turn died mid-call, so no duration
2701
+ turnPromptId = null;
2590
2702
  }
2591
2703
  }
2592
2704
  } finally {
@@ -3203,11 +3315,40 @@ program
3203
3315
  // dir: a modeling run falls back to the global install (see ensureGlobalKit) rather than
3204
3316
  // requiring one here, and the build-kit branch reports a better-targeted error of its own
3205
3317
  // than this generic gate can.
3206
- const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen']);
3318
+ const NO_INIT_REQUIRED = new Set(['init', 'scaffold', 'init-templates', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
3207
3319
 
3208
3320
  // Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
3209
3321
  // front of them would only be noise.
3210
- const NO_KIT_UPDATE_HINT = new Set(['init', 're-init', 'uninstall']);
3322
+ const NO_KIT_UPDATE_HINT = new Set(['init', 'scaffold', 'init-templates', 're-init', 'uninstall']);
3323
+
3324
+ // Retired: still recognised, so an old script or muscle memory gets an explanation instead of "unknown option".
3325
+ // The hidden options and the `bridge` command exist only to say so; they do nothing else.
3326
+ const RETIRED_BASH = [
3327
+ '❌ `run --bash` (the bash-only loop, ralph.sh) has been retired.',
3328
+ ' Its task queue (tasks.json) is now an event log inside the kit, which the bash scripts cannot read or write.',
3329
+ ' Use the default loop instead: npx @eventmodelers/cli run',
3330
+ ' (add --exec "<command>" to drive another agent harness, or --local-ai for a local model).',
3331
+ ];
3332
+ const RETIRED_BRIDGE = [
3333
+ '❌ The bridge kit (`init --bridge`, `bridge`) has been retired — it ran on the same tasks.json queue, which is now an event log.',
3334
+ ' To hand slices to Spec Kitty, `fetch --spec-kitty` still works on its own, without a standing loop.',
3335
+ ];
3336
+ function retire(lines) {
3337
+ console.error(lines.join('\n'));
3338
+ process.exit(1);
3339
+ }
3340
+
3341
+ program
3342
+ .command('bridge', { hidden: true })
3343
+ .allowUnknownOption()
3344
+ .allowExcessArguments()
3345
+ .action(() => retire(RETIRED_BRIDGE));
3346
+
3347
+ program.hook('preAction', (_thisCommand, actionCommand) => {
3348
+ const o = actionCommand.opts();
3349
+ if (actionCommand.name() === 'run' && o.bash) retire(RETIRED_BASH);
3350
+ if (actionCommand.name() === 'init' && (o.bridge || o.target || o.hook)) retire(RETIRED_BRIDGE);
3351
+ });
3211
3352
 
3212
3353
  // Startup hint when this CLI is behind npm, or an installed kit here is behind this CLI.
3213
3354
  // Best effort and on stderr, so it never breaks piped output; EVENTMODELERS_NO_UPDATE_CHECK
@@ -3228,7 +3369,6 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
3228
3369
  console.error(' Run one of these first:');
3229
3370
  console.error(` npx @eventmodelers/cli init --stack <name> (${Object.keys(STACKS).join(', ')})`);
3230
3371
  console.error(' npx @eventmodelers/cli init --modeling');
3231
- console.error(` npx @eventmodelers/cli init --bridge --target <name> (${Object.keys(BRIDGE_TARGETS).join(', ')})`);
3232
3372
  process.exit(1);
3233
3373
  });
3234
3374
 
@@ -3261,13 +3401,13 @@ const AGENT_NAME_OPTION = [
3261
3401
  ];
3262
3402
 
3263
3403
  // `--handover` is the project policy for how a backend build kit finishes a slice with a UI:
3264
- // set, it ends STATE_VIEW / STATE_CHANGE slices at `Handover` (the UI kit builds the rest and sets
3404
+ // set, it ends STATE_VIEW / STATE_CHANGE slices at `Waiting` (the UI kit builds the rest and sets
3265
3405
  // `Done`); unset, it ends at `Done`, exactly as before. Persisted as `handover: true` in the project's
3266
3406
  // config.json — the `ui-contract` skill (Step 4) reads it there. Switch it later with
3267
3407
  // `eventmodelers config --handover on|off`.
3268
3408
  const HANDOVER_OPTION = [
3269
3409
  '--handover',
3270
- 'Backend build kit: finish slices that have a UI at status `Handover` (UI build kit takes over, then sets `Done`) instead of `Done`. Saved as `handover: true` in .eventmodelers/config.json; without it slices end at `Done`. Turn it off later with `eventmodelers config --handover off`.',
3410
+ 'Backend build kit: finish slices that have a UI at status `Waiting` (UI build kit takes over, then sets `Done`) instead of `Done`. Saved as `handover: true` in .eventmodelers/config.json; without it slices end at `Done`. Turn it off later with `eventmodelers config --handover off`.',
3271
3411
  ];
3272
3412
 
3273
3413
  function identityOverridesFromOpts(opts) {
@@ -3278,15 +3418,15 @@ function identityOverridesFromOpts(opts) {
3278
3418
  credentialFlags(program
3279
3419
  .command('init')
3280
3420
  .alias('install')
3281
- .description('Scaffold a stack + install the agent kit into the current directory (or --modeling for skills + agent loop only, no backend scaffold; or --bridge to translate board slices into another spec framework; or --build-kit for a blank build-kit scaffold to fill in for a stack not built into this CLI yet; or --git to install a community/custom build kit from a git repo)')
3421
+ .description('Scaffold a stack + install the agent kit into the current directory (or --modeling for skills + agent loop only, no backend scaffold; or --build-kit for a blank build-kit scaffold to fill in for a stack not built into this CLI yet; or --git to install a community/custom build kit from a git repo)')
3282
3422
  .option('--stack <name>', `Stack to install (${Object.keys(STACKS).join(', ')}), or a name of your choosing when combined with --git`)
3283
3423
  .option('--git <url>', 'Install a build kit not built into this CLI by cloning this git repo (used with --stack <name> to name it) — the repo must mirror the templates/.claude, templates/root, templates/<kitSubdir> layout of this CLI\'s own stacks/<name>/templates, optionally with a stack.json declaring label/kitSubdir/useShared/needsBoardId')
3284
3424
  .option('--branch <name>', 'Branch to clone — only meaningful with --git (defaults to the repo\'s default branch)')
3285
- .option('--modeling', 'Install skills + the agent loop only — no backend scaffold. Mutually exclusive with --stack/--bridge/--build-kit.')
3286
- .option('--bridge', 'Install a bridge kit — translates board slices into another spec framework instead of building code. Mutually exclusive with --stack/--modeling/--build-kit. Requires --target.')
3287
- .option('--target <name>', `Bridge target framework (${Object.keys(BRIDGE_TARGETS).join(', ')}) — only meaningful with --bridge`)
3288
- .option('--hook <command>', 'Persist a default shell command hook for `bridge` to run per batch of slice changes instead of Claude/Ollama (e.g. commit + push .slices/ for a CI pipeline to pick up) — only meaningful with --bridge. Can also be set per-run with `bridge --hook`.')
3289
- .option('--build-kit', 'Install a blank build-kit scaffold (.build-kit/ + .claude/skills/build-*/SKILL.md placeholders, all TODO-marked) for a stack not built into this CLI yet — no fixed backend. Mutually exclusive with --stack/--modeling/--bridge.')
3425
+ .option('--modeling', 'Install skills + the agent loop only — no backend scaffold. Mutually exclusive with --stack/--build-kit.')
3426
+ .addOption(new Option('--bridge').hideHelp())
3427
+ .addOption(new Option('--target <name>').hideHelp())
3428
+ .addOption(new Option('--hook <command>').hideHelp())
3429
+ .option('--build-kit', 'Install a blank build-kit scaffold (.build-kit/ + .claude/skills/build-*/SKILL.md placeholders, all TODO-marked) for a stack not built into this CLI yet — no fixed backend. Mutually exclusive with --stack/--modeling.')
3290
3430
  .option('--hooks', 'Install the slice commit-scope guard (.githooks/pre-commit, running .build-kit/lib/check-commit-scope.cjs) and wire it up via `git config core.hooksPath .githooks` — only meaningful with --stack (build-kit stacks). Off by default.')
3291
3431
  .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.')
3292
3432
  .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
@@ -3296,10 +3436,10 @@ credentialFlags(program
3296
3436
  .action(async (opts, command) => {
3297
3437
  const globalOpts = command.optsWithGlobals();
3298
3438
 
3299
- if (opts.modeling || opts.bridge || opts.buildKit) {
3300
- const modeCount = [opts.modeling, opts.bridge, opts.buildKit].filter(Boolean).length;
3439
+ if (opts.modeling || opts.buildKit) {
3440
+ const modeCount = [opts.modeling, opts.buildKit].filter(Boolean).length;
3301
3441
  if (opts.stack || opts.git || modeCount > 1) {
3302
- console.error('❌ --stack/--git, --modeling, --bridge, and --build-kit are mutually exclusive — pick one.');
3442
+ console.error('❌ --stack/--git, --modeling and --build-kit are mutually exclusive — pick one.');
3303
3443
  process.exit(1);
3304
3444
  }
3305
3445
  }
@@ -3328,38 +3468,6 @@ credentialFlags(program
3328
3468
  return;
3329
3469
  }
3330
3470
 
3331
- if (opts.bridge) {
3332
- if (!opts.target) {
3333
- console.error(`❌ --bridge requires --target (${Object.keys(BRIDGE_TARGETS).join(', ')}).`);
3334
- process.exit(1);
3335
- }
3336
- if (!BRIDGE_TARGETS[opts.target]) {
3337
- console.error(`❌ Unknown bridge target "${opts.target}". Available: ${Object.keys(BRIDGE_TARGETS).join(', ')}`);
3338
- process.exit(1);
3339
- }
3340
- await installStack(BRIDGE_KIT.key, BRIDGE_KIT, {
3341
- configPath: globalOpts.config,
3342
- print: globalOpts.print,
3343
- global: opts.global,
3344
- force: opts.force,
3345
- credentialOverrides: { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) },
3346
- demo: opts.demo,
3347
- target: opts.target,
3348
- });
3349
- // Deliberately NOT under .bridge-kit/.eventmodelers/ — that whole name is
3350
- // gitignored (a bare `.eventmodelers` pattern matches at any depth, since
3351
- // it protects the root credentials file), so anything written there is
3352
- // per-machine only. target/hookCommand are project policy — how this repo
3353
- // reacts to board changes — meant to be committed and shared by every
3354
- // teammate and CI runner, so they live in a plain sibling file instead.
3355
- const bridgeConfigPath = join(process.cwd(), BRIDGE_KIT.kitDirName, 'bridge.json');
3356
- const existingBridgeCfg = readJsonSafe(bridgeConfigPath);
3357
- mkdirSync(dirname(bridgeConfigPath), { recursive: true });
3358
- writeFileSync(bridgeConfigPath, JSON.stringify({ ...existingBridgeCfg, target: opts.target, ...(opts.hook ? { hookCommand: opts.hook } : {}) }, null, 2));
3359
- console.log(` ✓ Bridge target set to "${opts.target}"${opts.hook ? ` with hook: ${opts.hook}` : ''}`);
3360
- return;
3361
- }
3362
-
3363
3471
  if (opts.branch && !opts.git) {
3364
3472
  console.error('❌ --branch only applies to --git — nothing to clone without it.');
3365
3473
  process.exit(1);
@@ -3561,13 +3669,11 @@ program
3561
3669
  .description("Run this project's commit-scope checks (.build-kit/lib/check-commit-scope.cjs, the same runner .githooks/pre-commit calls) against the currently staged changeset. Works for any build-kit stack — stacks/installs that ship no checks yet report that and exit 0, so this is safe to call unconditionally (e.g. from an agent loop) without checking the stack first.")
3562
3670
  .action(() => {
3563
3671
  const cwd = process.cwd();
3564
- // Mirrors `run`'s buildKitDir resolution: modeling-kit/bridge-kit installs share
3565
- // KIT_DIR_NAMES but never ship check-commit-scope.cjs, so prefer whichever
3566
- // installed dir isn't one of those over blindly taking the first match.
3672
+ // Mirrors `run`'s buildKitDir resolution: a modeling-kit install shares KIT_DIR_NAMES but never ships
3673
+ // check-commit-scope.cjs, so prefer whichever installed dir isn't the modeling kit.
3567
3674
  const installedKitDirs = findAllInstalledKitDirs(cwd);
3568
3675
  const modelingKitDir = installedKitDirs.find((d) => d.endsWith(MODELING_KIT.kitDirName)) ?? null;
3569
- const bridgeKitDir = installedKitDirs.find((d) => d.endsWith(BRIDGE_KIT.kitDirName)) ?? null;
3570
- const kitDir = installedKitDirs.find((d) => d !== modelingKitDir && d !== bridgeKitDir) ?? installedKitDirs[0];
3676
+ const kitDir = installedKitDirs.find((d) => d !== modelingKitDir) ?? installedKitDirs[0];
3571
3677
 
3572
3678
  const checkScript = join(kitDir, 'lib', 'check-commit-scope.cjs');
3573
3679
  if (!existsSync(checkScript)) {
@@ -3702,10 +3808,11 @@ credentialFlags(program
3702
3808
  .command('run')
3703
3809
  .description('Start the agent loop from the installed kit dir — build-kit stacks: ralph-claude.js (default); modeling-kit: --modeling, or --standalone, which needs no install at all')
3704
3810
  .option('--local-ai [target]', `Drive the loop with a local (or self-hosted) model instead of the default Claude runner: a build kit runs it via ralph-local-ai.js, and --modeling/--standalone via lib/modeling-local-ai.js (board tools over MCP, but no skills and no subagent fan-out — those are Claude Code features). Optional target preset picks the URL and wire dialect: ${LOCAL_AI_TARGETS.join(', ')} — bare --local-ai means ollama. Anything OpenAI-compatible (vLLM, LM Studio, llama.cpp, TGI) works by pointing LOCAL_AI_URL at it; see LOCAL_AI_* in the docs. Board work only: --local-ai never builds a Planned slice (no file, shell or git tools). To build code with a local model, use --exec with a coding harness pointed at it, e.g. "codex exec --oss --full-auto". Claude remains the default when this flag is absent.`)
3705
- .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, --local-ai and --bash.`)
3811
+ .addOption(new Option('--bash').hideHelp())
3812
+ .option('--agent [name]', `Pick the agent that does the work by name: ${Object.keys(AGENT_PRESETS).join(', ')}. claude is the default runner; any other is a shortcut for --exec with that harness's headless command filled in — plain-text output for --modeling/--standalone/--worker, JSON output (for agent tracing) for a build kit. Bare --agent lists the agents and the exact command each runs, then stops. Mutually exclusive with --exec and --local-ai.`)
3706
3813
  .option('--model <id>', 'The model the agent runs on, for this run only: `-m <id>` on the --agent opencode/codex/gemini command (e.g. opencode/big-pickle, gpt-5-codex, gemini-2.5-pro), `--model <id>` for Claude (overriding `model` from config), LOCAL_AI_MODEL for --local-ai. Not with --exec — put the harness\'s own model flag in the command there.')
3707
3814
  .option('--exec [command]', 'Fallback for any harness --agent does not cover, or other flags than its preset. Hand each prompt to an external agent command instead of the default Claude runner — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto", "gemini --yolo -p" or "opencode run". One process per prompt; the prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent. A build kit runs it via ralph-exec.js; there, with agent tracing on, include the harness\'s JSON output flag (codex exec --json, opencode run --format json, gemini --output-format stream-json) — without it no slice cost is recorded. With --modeling/--standalone/--worker it is how any agent takes board prompts and chats: the loop claims each prompt or chat message and runs the command once for it. Keep the harness in its plain-text output mode there — stdout is the turn\'s answer — the eventmodelers MCP server is registered for it on every run (opencode.json, .gemini/settings.json, or -c flags for codex; any other command logs what to register by hand), authenticating with $EVENTMODELERS_TOKEN from the env the command inherits, and `init-agents --hosts <harness>` installs the skills for a harness that does not read .claude/skills. No subagents: self-directed work is done inline.')
3708
- .option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
3815
+ .option('--all', 'Run every build kit in this project at once — the one in this directory and any up to two levels below it (a scaffolded app has `.build-kit` at the root and in `web/`, so this works from the app or from the folder holding it) — each from its own directory, output prefixed with the directory, Ctrl+c stops them all. Every other flag is passed on to each run. Not interactive: the kits\' credentials must already be configured (except with --force, which asks once per kit up front, here, before the runs start). With --modeling, the modeling agent runs alongside the build kits. Not with --standalone, --worker, --global, --local or --id (one id cannot belong to several agents).')
3709
3816
  .option('--modeling', 'Connect a modeling agent to the board: it takes prompts and chat messages and hands each to an AI — by default one Claude process kept warm across prompts, for low-latency voice/live use; --agent (or --local-ai, or --exec as the manual fallback) picks another. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
3710
3817
  .option('-f, --force', 'Ask for credentials again before the modeling loop starts, even when a config already has everything required. With a modeling kit in this directory it re-runs the same prompt as `init --modeling`, overwriting the project\'s .eventmodelers/config.json; with the global install it drops the "keep the stored credentials" choice, so a board\'s credentials have to be answered afresh. Applies to build-kit runs too, where it re-asks against the project\'s .eventmodelers/config.json. Cannot be combined with --non-interactive, --print or --local.')
3711
3818
  .option('--non-interactive', 'Never ask anything: take the board and credentials that resolve from flags, EVENTMODELERS_* env vars and the config files, and fail with the reason if they are incomplete instead of interviewing for them. A TTY was previously the only signal — right for CI or a supervisor, wrong for a loop started from a terminal, where stdin is a TTY nobody is watching and the run stops on a question. Only affects the modeling loop (--modeling/--standalone/--worker/--global).')
@@ -3717,15 +3824,58 @@ credentialFlags(program
3717
3824
  .option('--local', 'Skip platform config/credential lookup entirely and run the local-only loop (no board sync, no realtime agent) — even if .eventmodelers/config.json has credentials (build-kit stacks only)')
3718
3825
  .option('--dangerously-skip-permissions', 'Run the modeling agent\'s Claude process with --dangerously-skip-permissions, as before the lockdown: every tool call is approved — any shell command, curl to anywhere, any file the user can read or write, every MCP server the machine configures. Off by default because the agent takes chat messages and snippet clicks from any collaborator on the board; pass it only for a board whose collaborators you trust with your shell. Default Claude runner of --modeling/--standalone/--worker/--global only — not with --agent, --exec or --local-ai, which bring their own permission flags.')
3719
3826
  .option('--browser', 'Give the locked-down modeling agent a browser: the Playwright MCP server (npx @playwright/mcp) next to the eventmodelers one, for discover-storyboard. Off by default because a browser can reach any site, which the lockdown otherwise rules out. Default Claude runner only; --dangerously-skip-permissions already loads every MCP server the machine configures.')
3720
- .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`.')
3721
- .option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned, Handover). Defaults to the kit\'s own pickup status — `Planned` for backend kits, `Handover` for the UI kits. Nothing is written to disk.')
3827
+ .option('--handover', 'Backend build kit: for this run only, finish slices that have a UI at `Waiting` instead of `Done` (same as the project setting `handover: true`, which `init --handover` / `config --handover on` save — this flag writes nothing). Without it the project setting applies; without both, slices end at `Done`.')
3828
+ .option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned, Waiting). Defaults to the kit\'s own pickup status — `Planned` for backend kits, `Handover` for the UI kits. Nothing is written to disk.')
3722
3829
  .option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
3723
- .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.')
3830
+ .option('--id <id>', 'Pin the agent id this run identifies itself with on the platform. Without it an agent keeps one id: a project install per project, a --global/--standalone run per board and repo (the directory it is started in) — the same agent on every restart, with its chat, its star on the board and the prompts it holds. If another live process already runs under that id, this one gets a fresh id instead (two agents must not share a heartbeat row). Pass --id when a supervisor already knows the id you want, or for a second agent on purpose; it is used as given, with no such check. Per-run only: nothing is written to disk.')
3724
3831
  .option('--name <name>', 'A human-readable name for this agent, sent with every heartbeat so the board shows which agent is live rather than a bare uuid (e.g. "ci-builder", "martins-laptop"). Per-run only, like --id: the persistent name is `agentName` in config.json (set via `init --name` / `init-config --name`), and this overrides it for one run without writing anything.')
3725
3832
  .option('--credentials <values>', 'Credentials as the comma-separated blob from app.eventmodelers.ai/account (token=...,boardId=...,organizationId=...,baseUrl=...), the equivalent JSON, or - to read either from stdin. Saved to ~/.eventmodelers/boards/<board>.json, so it is only needed once per board, and passing it skips the first-run question. The individual flags below override single fields of it.'))
3726
3833
  .action(async (opts, command) => {
3727
3834
  const globalOpts = command.optsWithGlobals();
3728
3835
  const cwd = process.cwd();
3836
+ if (opts.all) {
3837
+ const clashing = [['--standalone', opts.standalone], ['--worker', opts.worker], ['--global', opts.global], ['--local', opts.local], ['--id', opts.id !== undefined]]
3838
+ .filter(([, set]) => set).map(([flag]) => flag);
3839
+ if (clashing.length) {
3840
+ console.error(`❌ --all runs build kits and cannot be combined with ${clashing.join(', ')}.`);
3841
+ process.exit(1);
3842
+ }
3843
+ const kitDirNames = [...new Set(Object.values(STACKS).map((st) => st.kitDirName))];
3844
+ const dirs = findBuildKitDirs(cwd, kitDirNames);
3845
+ if (!dirs.length && !opts.modeling) {
3846
+ console.error(`❌ No build kit found in this directory or up to two levels below it (looked for ${kitDirNames.join(', ')}).`);
3847
+ process.exit(1);
3848
+ }
3849
+ // Each kit runs as its own `run` with whatever else was typed; only --all is dropped, and
3850
+ // --modeling too — that one is its own run, below, not something a build kit takes.
3851
+ // --force is the one flag that asks questions, and the children have no terminal to ask on
3852
+ // (the supervisor pipes their stdin, so the first prompt dies with ERR_USE_AFTER_CLOSE). The
3853
+ // credentials step runs here once per kit instead, then --force is not forwarded.
3854
+ if (opts.force) {
3855
+ if (opts.nonInteractive || globalOpts.print) {
3856
+ console.error('❌ --force asks for credentials again, which --non-interactive/--print rule out — pick one.');
3857
+ process.exit(1);
3858
+ }
3859
+ // Asked once, for the kit in this directory (the first one found when there is none), and
3860
+ // handed to the others — the modeling agent reads this directory's config.
3861
+ const rootDir = dirs.find((dir) => dirname(dir) === cwd) ?? dirs[0];
3862
+ console.log(`\n🔑 Credentials for ${relative(cwd, rootDir) || '.'}, shared by all`);
3863
+ const { organizationId, boardId, token, baseUrl } = await forceBuildKitCredentials(rootDir, cwd, opts, globalOpts);
3864
+ for (const dir of dirs.filter((d) => d !== rootDir)) {
3865
+ await forceBuildKitCredentials(dir, cwd, opts, globalOpts, { organizationId, boardId, token, baseUrl });
3866
+ }
3867
+ if (sharedRl) sharedRl.close();
3868
+ console.log('');
3869
+ }
3870
+ const forwarded = process.argv.slice(2).filter((a) => a !== '--all' && a !== '--modeling' && !(opts.force && a === '--force'));
3871
+ const cliJs = join(__dirname, 'cli.js');
3872
+ const specs = dirs.map((dir) => ({ name: relative(cwd, dir) || '.', file: process.execPath, args: [cliJs, ...forwarded], cwd: dir }));
3873
+ // --modeling adds the modeling agent next to the build kits: one process, from this directory
3874
+ // (its modeling kit, or the global install when there is none), like a plain `run --modeling`.
3875
+ if (opts.modeling) specs.push({ name: 'modeling', file: process.execPath, args: [cliJs, ...forwarded, '--modeling'], cwd });
3876
+ console.log(`▶ Running ${dirs.length} build kit(s)${opts.modeling ? ' + the modeling agent' : ''}: ${specs.map((sp) => sp.name).join(', ')} (Ctrl+c stops all)\n`);
3877
+ process.exit(await supervise(specs));
3878
+ }
3729
3879
  // Validated up front, before any runner is selected: a cost guard given as garbage
3730
3880
  // should fail on the spot, not once the loop is already up — and a cap passed where
3731
3881
  // nothing will read it is worth saying out loud rather than ignoring silently.
@@ -3779,11 +3929,6 @@ credentialFlags(program
3779
3929
  process.exit(1);
3780
3930
  }
3781
3931
  }
3782
- // ralph.sh has no realtime agent and never pings /api/agent-alive, so there is no
3783
- // identity for either flag to override there.
3784
- if ((identity.agentId || identity.agentName) && opts.bash) {
3785
- console.log('ℹ️ --id/--name only apply to agents that ping the platform; the --bash loop does not, so they are ignored here.');
3786
- }
3787
3932
  // Both kit dirs can be installed side by side (e.g. running a build-kit and a
3788
3933
  // modeling-kit agent from the same project). findInstalledKitDir only ever
3789
3934
  // returns its first fixed-order match, which would silently prefer one stack
@@ -3791,16 +3936,11 @@ credentialFlags(program
3791
3936
  // we resolve each stack's dir independently instead of relying on that order.
3792
3937
  const installedKitDirs = findAllInstalledKitDirs(cwd);
3793
3938
  const modelingKitDir = installedKitDirs.find((d) => d.endsWith(MODELING_KIT.kitDirName)) ?? null;
3794
- const bridgeKitDir = installedKitDirs.find((d) => d.endsWith(BRIDGE_KIT.kitDirName)) ?? null;
3795
- // A bridge kit is not a build-kit stand-in even though it also reuses
3796
- // lib/ralph.js — it has its own `eventmodelers bridge` entrypoint (no
3797
- // onPlannedSlice/--local-ai/--bash support), so it's excluded here rather
3798
- // than falling through to the generic build-kit runner below.
3799
- const buildKitDir = installedKitDirs.find((d) => d !== modelingKitDir && d !== bridgeKitDir) ?? null;
3939
+ const buildKitDir = installedKitDirs.find((d) => d !== modelingKitDir) ?? null;
3800
3940
 
3801
3941
  // No overlap between the two stacks' runtimes: modeling-kit only ever runs the
3802
3942
  // warm, direct-dispatch loop (--modeling); build-kit only ever runs the
3803
- // cold-spawn/tasks.json loop (default, or --local-ai/--bash). Neither falls back
3943
+ // cold-spawn/task-queue loop (default, or --local-ai/--exec). Neither falls back
3804
3944
  // to the other's mechanism, so each side is gated explicitly below rather than
3805
3945
  // just being left to fail on a missing file.
3806
3946
  // --standalone implies --modeling: it already refused every other runner, so there
@@ -3809,14 +3949,8 @@ credentialFlags(program
3809
3949
  // no meaning for a build kit, which is scaffolded per project by definition.
3810
3950
  if (opts.modeling || opts.standalone || opts.worker || opts.global) {
3811
3951
  const picked = opts.modeling ? '--modeling' : opts.standalone ? '--standalone' : opts.worker ? '--worker' : '--global';
3812
- // --bash stays build-kit only: it is the bash tasks.json loop, which the modeling loop has
3813
- // no equivalent of. --local-ai and --exec are different — they name what does a turn, not a
3814
- // queue, and the modeling loop has its own runner for each (lib/modeling-local-ai.js,
3815
- // lib/modeling-exec.js).
3816
- if (opts.bash) {
3817
- console.error(`❌ ${picked} is mutually exclusive with --bash — that selects a build-kit runner, which the modeling loop has no use for.`);
3818
- process.exit(1);
3819
- }
3952
+ // --local-ai and --exec name what does a turn, not a queue, and the modeling loop has its own
3953
+ // runner for each (lib/modeling-local-ai.js, lib/modeling-exec.js).
3820
3954
  const modelingLocalAi = resolveLocalAiTarget(opts);
3821
3955
  if (modelingLocalAi !== null && opts.exec) {
3822
3956
  console.error('❌ --local-ai and --exec are mutually exclusive — pick one runner.');
@@ -3865,6 +3999,13 @@ credentialFlags(program
3865
3999
  ...Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v)),
3866
4000
  };
3867
4001
  const config = await resolveModelingCredentials(cwd, flags, globalOpts.config, globalOpts.print, !!opts.nonInteractive, !!opts.force);
4002
+ // The stable id of this board and repo — unless `--id` pins another, or a live process already runs under it,
4003
+ // in which case this run gets a fresh one (agent-lock.js).
4004
+ if (!identity.agentId) {
4005
+ const { agentId, fellBack } = acquireAgentId({ lockDir: join(GLOBAL_DIR, 'locks'), stableId: config.agentId });
4006
+ config.agentId = agentId;
4007
+ config.agentIdFellBack = fellBack;
4008
+ }
3868
4009
  projectDir = await ensureGlobalKit(config.baseUrl);
3869
4010
  kitDir = join(projectDir, MODELING_KIT.kitDirName);
3870
4011
  overrides = config;
@@ -3896,6 +4037,7 @@ credentialFlags(program
3896
4037
  // right after (dynamic imports, config reads) can eat the event-loop tick this write
3897
4038
  // needed to drain, so a piped watcher sees the ping arrive after runModeling's own
3898
4039
  // [modeling] log lines instead of before them.
4040
+ await offerPrune(kitDir, { interactive: !globalOpts.print && !opts.nonInteractive && !!process.stdin.isTTY });
3899
4041
  const shown = relative(cwd, kitDir);
3900
4042
  const runnerLabel = modelingLocalAi ? 'local model' : opts.exec ? 'one agent process per turn' : 'warm Claude process';
3901
4043
  await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (${runnerLabel}) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
@@ -3910,9 +4052,7 @@ credentialFlags(program
3910
4052
 
3911
4053
  if (!buildKitDir) {
3912
4054
  if (modelingKitDir) {
3913
- console.error(`❌ A modeling-kit install (${MODELING_KIT.kitDirName}/) only runs via \`eventmodelers run --modeling\` — there is no cold-spawn/tasks.json loop for modeling-only projects.`);
3914
- } else if (bridgeKitDir) {
3915
- console.error(`❌ A bridge-kit install (${BRIDGE_KIT.kitDirName}/) only runs via \`eventmodelers bridge\` — it has no --modeling/--local-ai/--bash modes.`);
4055
+ console.error(`❌ A modeling-kit install (${MODELING_KIT.kitDirName}/) only runs via \`eventmodelers run --modeling\` — there is no cold-spawn/task-queue loop for modeling-only projects.`);
3916
4056
  } else {
3917
4057
  console.error(`❌ No kit installed in ${cwd} — run \`eventmodelers install\` first.`);
3918
4058
  console.error(' (A modeling agent needs no install at all: eventmodelers run --standalone --board-id <uuid>)');
@@ -3922,17 +4062,19 @@ credentialFlags(program
3922
4062
  const kitDir = buildKitDir;
3923
4063
 
3924
4064
  const localAiTarget = resolveLocalAiTarget(opts);
3925
- const pickedCount = [opts.bash, localAiTarget !== null, !!opts.exec].filter(Boolean).length;
4065
+ const pickedCount = [localAiTarget !== null, !!opts.exec].filter(Boolean).length;
3926
4066
  if (pickedCount > 1) {
3927
- console.error('❌ --bash, --local-ai and --exec are mutually exclusive — pick one runner.');
4067
+ console.error('❌ --local-ai and --exec are mutually exclusive — pick one runner.');
3928
4068
  process.exit(1);
3929
4069
  }
3930
4070
 
4071
+ await offerPrune(kitDir, { interactive: !globalOpts.print && !opts.nonInteractive && !!process.stdin.isTTY });
4072
+
3931
4073
  // The actual agent loop lives in the scaffolded kit dir, not in this package — this
3932
4074
  // is just a thin dispatcher so users don't have to remember the kit-dir name or which
3933
4075
  // runner file to invoke. Users (and the agent itself, via AGENT.md) may customize these
3934
4076
  // files freely; `run` always executes whatever is currently on disk.
3935
- const runner = opts.bash ? 'ralph.sh' : opts.exec ? 'ralph-exec.js' : localAiTarget !== null ? 'ralph-local-ai.js' : 'ralph-claude.js';
4077
+ const runner = opts.exec ? 'ralph-exec.js' : localAiTarget !== null ? 'ralph-local-ai.js' : 'ralph-claude.js';
3936
4078
  const runnerPath = join(kitDir, runner);
3937
4079
  if (!existsSync(runnerPath)) {
3938
4080
  console.error(`❌ ${relative(cwd, runnerPath)} not found.`);
@@ -3950,22 +4092,7 @@ credentialFlags(program
3950
4092
  console.error('❌ --force asks for credentials, which --local (no platform config) never uses — pick one.');
3951
4093
  process.exit(1);
3952
4094
  }
3953
- const projectDir = dirname(kitDir);
3954
- const configPath = globalOpts.config
3955
- ? resolve(projectDir, globalOpts.config)
3956
- : join(projectDir, '.eventmodelers', 'config.json');
3957
- const cfg = await configureCredentials({
3958
- config: loadEffectiveConfig(cwd, kitDir, globalOpts.config).config,
3959
- configPath,
3960
- targetDir: projectDir,
3961
- requiredFields: ['organizationId', 'boardId', 'token'],
3962
- overrides: credentialOverridesFromOpts(opts),
3963
- print: false,
3964
- force: true,
3965
- });
3966
- ensureMcpRegistered(projectDir, cfg.baseUrl || DEFAULT_BASE_URL);
3967
- ensureEnvToken(projectDir, cfg.token);
3968
- ensureMcpEnabled(projectDir);
4095
+ await forceBuildKitCredentials(kitDir, cwd, opts, globalOpts);
3969
4096
  }
3970
4097
 
3971
4098
  // ralph-claude.js hands every turn to a `claude` that only discovers MCP servers from
@@ -3983,77 +4110,16 @@ credentialFlags(program
3983
4110
  console.log(`▶ Starting ${relative(cwd, runnerPath)}...\n`);
3984
4111
  const cmd = runner.endsWith('.sh') ? `"${runnerPath}"` : `node "${runnerPath}"`;
3985
4112
  try {
3986
- // Only ralph-claude.js reads RALPH_VERBOSE — the bash loop and the local-AI executor have
3987
- // their own separate output paths with no stream-json parsing to gate. RALPH_LOCAL is
3988
- // read by all three runners (ralph.js's startRalph, and ralph.sh directly) to force the
3989
- // local-only branch even when .eventmodelers/config.json has valid credentials.
3990
- // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read in ralph.js's startRalph, so
3991
- // they reach both node runners but not ralph.sh, which has no heartbeat to identify.
4113
+ // Only ralph-claude.js reads RALPH_VERBOSE — the local-AI executor has its own separate output
4114
+ // path with no stream-json parsing to gate. RALPH_LOCAL is read by ralph.js's startRalph to force
4115
+ // the local-only branch even when .eventmodelers/config.json has valid credentials.
4116
+ // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read there too.
3992
4117
  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 } : {}) } });
3993
4118
  } catch (err) {
3994
4119
  process.exit(err.status || 1);
3995
4120
  }
3996
4121
  });
3997
4122
 
3998
- program
3999
- .command('bridge')
4000
- .description('Start the bridge agent loop from the installed .bridge-kit/ — translates board slice changes into another spec framework instead of building code. A deterministic adapter runs with no LLM call if one exists for the configured target (e.g. spec-kitty); otherwise Claude is the default executor. --local-ai, --hook, or --claude override the pick.')
4001
- .option('--local-ai [target]', `Use ralph-local-ai.js instead of the default runner — a local or self-hosted model. Optional target preset: ${LOCAL_AI_TARGETS.join(', ')} (bare flag means ollama).`)
4002
- .option('--hook <command>', 'Run this shell command instead of an AI agent for each batch of slice changes (e.g. commit + push .slices/ for a CI pipeline to pick up) — overrides any hook persisted via `init --bridge --hook` for this run only')
4003
- .option('--claude', 'Force the Claude runner even if a static adapter exists for this target')
4004
- .action((opts) => {
4005
- const cwd = process.cwd();
4006
- const kitDir = findAllInstalledKitDirs(cwd).find((d) => d.endsWith(BRIDGE_KIT.kitDirName)) ?? null;
4007
- if (!kitDir) {
4008
- console.error(`❌ No bridge-kit installed in ${cwd} — run \`eventmodelers init --bridge --target <name>\` first.`);
4009
- process.exit(1);
4010
- }
4011
-
4012
- const localAiTarget = resolveLocalAiTarget(opts);
4013
- if ([localAiTarget !== null, opts.hook, opts.claude].filter(Boolean).length > 1) {
4014
- console.error('❌ --local-ai, --hook, and --claude are mutually exclusive — pick one executor.');
4015
- process.exit(1);
4016
- }
4017
-
4018
- // A persisted default (from `init --bridge --hook`) lives in bridge.json, a
4019
- // plain sibling file — NOT under .eventmodelers/, which is gitignored (see
4020
- // the comment in `init`'s --bridge branch) and would otherwise make this
4021
- // per-machine instead of a shared, checked-in team/CI convention.
4022
- const bridgeCfg = readJsonSafe(join(kitDir, 'bridge.json'));
4023
- const persistedHook = bridgeCfg.hookCommand;
4024
- const hookCmd = opts.hook || persistedHook;
4025
-
4026
- // A static adapter (adapters/<target>-adapter.js, e.g. spec-kitty-adapter.js)
4027
- // is deterministic and costs no LLM call, so it wins over the Claude default
4028
- // whenever one exists for the configured target — --claude opts back out.
4029
- const staticAdapterPath = join(kitDir, 'adapters', `${bridgeCfg.target}-adapter.js`);
4030
- const hasStaticAdapter = bridgeCfg.target && existsSync(staticAdapterPath);
4031
-
4032
- const runner = hookCmd
4033
- ? 'ralph-hook.js'
4034
- : localAiTarget !== null
4035
- ? 'ralph-local-ai.js'
4036
- : !opts.claude && hasStaticAdapter
4037
- ? 'ralph-static.js'
4038
- : 'ralph-claude.js';
4039
- const runnerPath = join(kitDir, runner);
4040
- if (!existsSync(runnerPath)) {
4041
- console.error(`❌ ${relative(cwd, runnerPath)} not found.`);
4042
- process.exit(1);
4043
- }
4044
-
4045
- console.log(`▶ Starting ${relative(cwd, runnerPath)}${hookCmd ? ` (hook: ${hookCmd})` : ''}...\n`);
4046
- try {
4047
- execSync(`node "${runnerPath}"`, {
4048
- cwd: kitDir,
4049
- stdio: 'inherit',
4050
- env: hookCmd ? { ...process.env, BRIDGE_HOOK_CMD: hookCmd } : process.env,
4051
- });
4052
- } catch (err) {
4053
- process.exit(err.status || 1);
4054
- }
4055
- });
4056
-
4057
4123
  program
4058
4124
  .command('listen')
4059
4125
  .description('Start the code-export listener (code-export.mjs) from the installed kit dir, or the bundled copy when no kit is installed — receives slice/screen data pushed from the eventmodelers board UI and writes it into .slices/')
@@ -4152,6 +4218,162 @@ program
4152
4218
  }
4153
4219
  });
4154
4220
 
4221
+ program
4222
+ .command('scaffold <target> [args...]')
4223
+ .description('`scaffold add-user <email> <password> [dir]` adds a sign-in user to a scaffolded app (run in its folder, or pass dir). Otherwise: generate a runnable app skeleton from the board — sign-in, navigation with one entry per timeline, a page shell per screen, 501 for every unbuilt endpoint, hot reload — and install the build kits of the chosen --type into it, ready for `run` to add slices. <target> is an empty or new directory, or a skeleton made earlier (which is refreshed from the current model).')
4224
+ .option('--type <name>', `Kind of app to scaffold (${scaffoldTypeNames().join(', ')})`, 'supabase')
4225
+ .option('--no-install', 'Stop after generating the skeleton: no npm install, checks or commit')
4226
+ .option('--no-check', 'Install dependencies but skip the type-check, tests and web build/lint')
4227
+ .option('--templates <dir>', 'Folder of *.tmpl files that replace the built-in skeleton template of the same path (or add files). Default: the target\'s own templates/ folder when it exists, so customisations survive a refresh')
4228
+ .option('--port <name=value>', "Pin one of the type's ports instead of picking a free one, e.g. --port api=3200 (repeatable; the names are listed in the error when one is unknown). A refresh keeps its ports unless pinned here", (v, all) => [...all, v], [])
4229
+ .action(async (target, args, opts, command) => {
4230
+ if (target === 'add-user') {
4231
+ const [email, password, dir] = args;
4232
+ if (!email || !password) {
4233
+ console.error('❌ usage: eventmodelers scaffold add-user <email> <password> [dir]');
4234
+ process.exit(1);
4235
+ }
4236
+ const addUser = SCAFFOLD_TYPES[opts.type]?.addUser;
4237
+ if (!addUser) {
4238
+ console.log(`ℹ️ The "${opts.type}" scaffold has no users — nothing to do.`);
4239
+ return;
4240
+ }
4241
+ const appDir = resolve(process.cwd(), dir ?? '.');
4242
+ if (!existsSync(join(appDir, addUser.requires))) {
4243
+ console.error(`❌ ${appDir} is not a scaffolded "${opts.type}" app (no ${addUser.requires}) — run this in its folder or pass the folder as the last argument.`);
4244
+ process.exit(1);
4245
+ }
4246
+ const [file, ...fileArgs] = addUser.command(email, password);
4247
+ try {
4248
+ execFileSync(file, fileArgs, { cwd: appDir, stdio: 'inherit' });
4249
+ } catch {
4250
+ process.exit(1);
4251
+ }
4252
+ return;
4253
+ }
4254
+ if (args.length) {
4255
+ console.error(`❌ Unexpected argument(s): ${args.join(' ')}`);
4256
+ process.exit(1);
4257
+ }
4258
+ const type = SCAFFOLD_TYPES[opts.type];
4259
+ if (!type) {
4260
+ console.error(`❌ Unknown --type "${opts.type}". Available: ${scaffoldTypeNames().join(', ')}`);
4261
+ process.exit(1);
4262
+ }
4263
+ const missing = missingPrerequisites(type);
4264
+ if (missing.length) {
4265
+ console.error(`❌ Missing on this machine:\n${missing.map((m) => ` - ${m}`).join('\n')}`);
4266
+ process.exit(1);
4267
+ }
4268
+
4269
+ const cwd = process.cwd();
4270
+ const globalOpts = command.optsWithGlobals();
4271
+ let cfg = loadEffectiveConfig(cwd, findInstalledKitDir(cwd), globalOpts.config).config;
4272
+ const explicit = Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v));
4273
+ cfg = { ...cfg, ...explicit };
4274
+ const configPath = globalOpts.config ? resolve(cwd, globalOpts.config) : join(cwd, '.eventmodelers', 'config.json');
4275
+ const requiredFields = ['organizationId', 'boardId', 'token'];
4276
+ const promptForCredentials = async () => {
4277
+ cfg = await configureCredentials({ config: cfg, configPath, targetDir: cwd, requiredFields, boardIdOptional: false, overrides: {}, print: globalOpts.print });
4278
+ if (requiredFields.some((f) => !cfg[f])) {
4279
+ console.error('❌ Still missing token/organizationId/boardId — re-run `eventmodelers scaffold` once configured.');
4280
+ process.exit(1);
4281
+ }
4282
+ };
4283
+ if (requiredFields.some((f) => !cfg[f])) await promptForCredentials();
4284
+
4285
+ const readModel = async () => {
4286
+ const baseUrl = cfg.baseUrl || 'https://api.eventmodelers.ai';
4287
+ const headers = { 'x-token': cfg.token, 'x-user-id': 'cli-scaffold', 'content-type': 'application/json' };
4288
+ const call = async (url, what, init) => {
4289
+ const res = await fetch(url, { headers, ...init }).catch((err) => {
4290
+ console.error(`❌ Request failed (${what}): ${err.message}`);
4291
+ process.exit(1);
4292
+ });
4293
+ if (res.status === 401 || res.status === 403) throw new FetchAuthError(res.status, `${what}: ${res.status === 401 ? 'invalid or expired token' : "token's organization does not match this board"}`);
4294
+ if (!res.ok) {
4295
+ console.error(`❌ ${what}: HTTP ${res.status}`);
4296
+ process.exit(1);
4297
+ }
4298
+ return res.json();
4299
+ };
4300
+ const boardBase = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}`;
4301
+ const boards = await call(`${baseUrl}/api/boards`, 'boards').catch((err) => { if (err instanceof FetchAuthError) throw err; return []; });
4302
+ const board = (Array.isArray(boards) ? boards : boards.boards ?? []).find((b) => b.id === cfg.boardId);
4303
+ return buildModel({
4304
+ appTitle: board?.name ?? board?.title,
4305
+ api: {
4306
+ get: (path) => call(`${boardBase}${path}`, path.split('?')[0]),
4307
+ post: (path, body) => call(`${boardBase}${path}`, path, { method: 'POST', body: JSON.stringify(body) }),
4308
+ },
4309
+ });
4310
+ };
4311
+
4312
+ console.log(`▶ Reading the model of board ${cfg.boardId}...`);
4313
+ let model;
4314
+ try {
4315
+ model = await readModel();
4316
+ } catch (err) {
4317
+ if (!(err instanceof FetchAuthError)) throw err;
4318
+ console.error(`❌ ${err.message}`);
4319
+ if (err.status === 404) delete cfg.boardId;
4320
+ else delete cfg.token;
4321
+ await promptForCredentials();
4322
+ model = await readModel();
4323
+ }
4324
+ console.log(` ${model.timelines.length} timeline(s), ${model.slices.length} slice(s), ${model.screens.length} screen(s), css: ${model.css}`);
4325
+
4326
+ try {
4327
+ const { root, ports } = await runScaffold({
4328
+ typeName: opts.type,
4329
+ target,
4330
+ cfg,
4331
+ model,
4332
+ cliPath: join(__dirname, 'cli.js'),
4333
+ install: opts.install,
4334
+ check: opts.check,
4335
+ templates: opts.templates ? resolve(cwd, opts.templates) : undefined,
4336
+ ports: Object.fromEntries(opts.port.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
4337
+ });
4338
+ console.log(`\n✅ Skeleton ready in ${root}`);
4339
+ console.log(` cd ${relative(cwd, root) || '.'} && npm run up # ${Object.entries(ports).map(([n, v]) => `${n} :${v}`).join(', ')}`);
4340
+ console.log(' npx @eventmodelers/cli scaffold add-user <email> <password> # a user to sign in with (no sign-up screen)');
4341
+ console.log(' npx @eventmodelers/cli run --all # then build: every kit in the project, in this terminal');
4342
+ } catch (err) {
4343
+ console.error(`❌ ${err.message}`);
4344
+ process.exit(1);
4345
+ }
4346
+ });
4347
+
4348
+ program
4349
+ .command('init-templates [paths...]')
4350
+ .description('Copy the built-in scaffold templates into ./templates/ (or --dir) so you can edit them: `scaffold` uses a template found there and falls back to the built-in one for everything else. Name paths (a file like web/src/app/Layout.tsx, or a folder like web/src/app) to copy only those — a copied template no longer follows the CLI\'s own updates, so take only what you mean to change. Existing files are kept unless --force.')
4351
+ .option('--type <name>', `Scaffold type whose templates to copy (${scaffoldTypeNames().join(', ')})`, 'supabase')
4352
+ .option('--dir <dir>', 'Where to copy them', 'templates')
4353
+ .option('--list', 'Only list the built-in template paths')
4354
+ .option('-f, --force', 'Overwrite templates already in the folder')
4355
+ .action((paths, opts) => {
4356
+ const type = SCAFFOLD_TYPES[opts.type];
4357
+ if (!type) {
4358
+ console.error(`❌ Unknown --type "${opts.type}". Available: ${scaffoldTypeNames().join(', ')}`);
4359
+ process.exit(1);
4360
+ }
4361
+ if (opts.list) {
4362
+ for (const rel of builtInTemplates(type).keys()) console.log(rel);
4363
+ return;
4364
+ }
4365
+ try {
4366
+ const dest = resolve(process.cwd(), opts.dir);
4367
+ const { copied, kept } = initTemplates(type, dest, { only: paths, force: opts.force });
4368
+ for (const rel of copied) console.log(` ✓ ${relative(process.cwd(), join(dest, `${rel}.tmpl`))}`);
4369
+ if (kept.length) console.log(` ${kept.length} already there, kept (use --force to overwrite)`);
4370
+ console.log(`\n✅ ${copied.length} template(s) copied. Edit them, then run \`eventmodelers scaffold <dir>\` — it uses ${relative(process.cwd(), dest) || '.'}/ when that is the target's templates/ folder, or pass --templates.`);
4371
+ } catch (err) {
4372
+ console.error(`❌ ${err.message}`);
4373
+ process.exit(1);
4374
+ }
4375
+ });
4376
+
4155
4377
  program
4156
4378
  .command('activate-context')
4157
4379
  .description('Choose which fetched context is active — writes .slices/current_context.json, which `run` works through first; once it has no planned slices left, `run` moves on to the next context that does')
@@ -4209,7 +4431,7 @@ const SLICE_STATUSES = [
4209
4431
  { label: '🚫 Blocked', value: 'Blocked' },
4210
4432
  { label: '🧭 Ready', value: 'Ready' },
4211
4433
  { label: '📅 Planned', value: 'Planned' },
4212
- { label: '🤝 Handover', value: 'Handover' },
4434
+ { label: '🤝 Waiting', value: 'Waiting' },
4213
4435
  { label: 'ℹ️ Informational', value: 'Informational' },
4214
4436
  ];
4215
4437
 
@@ -4360,6 +4582,313 @@ program
4360
4582
  console.log(`✅ Pushed status change to the board (node ${slice.id}).`);
4361
4583
  });
4362
4584
 
4585
+ // The kit a command that records events works against: a build kit if one is installed, else the modeling
4586
+ // kit. The build kit wins when both are installed — the modeling agent never runs these commands itself
4587
+ // (its Bash is an allowlist); it states its learnings in its reply and `runModeling` records them.
4588
+ function requireBuildKitDir(cwd) {
4589
+ const installed = findAllInstalledKitDirs(cwd);
4590
+ const kitDir = installed.find((d) => !d.endsWith(MODELING_KIT.kitDirName)) ?? installed.find((d) => d.endsWith(MODELING_KIT.kitDirName));
4591
+ if (!kitDir) {
4592
+ console.error('❌ This command needs a build or modeling kit (npx @eventmodelers/cli init --stack <name> or init --modeling).');
4593
+ process.exit(1);
4594
+ }
4595
+ return kitDir;
4596
+ }
4597
+
4598
+ // What the build agent's events are stamped with: the loop exports the agent id into its environment.
4599
+ function agentEventMeta(cwd, kitDir, command) {
4600
+ const { config: cfg } = loadEffectiveConfig(cwd, kitDir, command.optsWithGlobals().config);
4601
+ return {
4602
+ agent_id: process.env.RALPH_AGENT_ID || process.env.EVENTMODELERS_AGENT_ID,
4603
+ agent_type: 'BUILD', // `learn` and `slice-status` are the build agent's commands
4604
+ organization_id: cfg.organizationId,
4605
+ board_id: cfg.boardId,
4606
+ };
4607
+ }
4608
+
4609
+ program
4610
+ .command('slice-status <status>')
4611
+ .description("For a build agent: record the outcome of the slice the run loop is building (Done, Waiting, Blocked, ...). The loop owns the slice's status on the board and pushes it when the build ends — never edit index.json or call update-slice-status for a loop build.")
4612
+ .option('--slice <idOrTitle>', 'The slice, when the loop has claimed more than one (default: the one it is building)')
4613
+ .action((status, opts, command) => {
4614
+ const cwd = process.cwd();
4615
+ // `Handover` is the old name of `Waiting`; older skills and prompts still say it
4616
+ const wanted = status.toLowerCase() === 'handover' ? 'waiting' : status.toLowerCase();
4617
+ const canonical = SLICE_STATUSES.find((s) => s.value.toLowerCase() === wanted)?.value;
4618
+ if (!canonical) {
4619
+ console.error(`❌ "${status}" is not a slice status. Valid: ${SLICE_STATUSES.map((s) => s.value).join(', ')}`);
4620
+ process.exit(1);
4621
+ }
4622
+ const kitDir = requireBuildKitDir(cwd);
4623
+ const meta = agentEventMeta(cwd, kitDir, command);
4624
+ try {
4625
+ const result = recordAgentStatus({ kitDir, status: canonical, sliceId: opts.slice, meta });
4626
+ console.log(`✅ "${result.title}": recorded ${result.status} — the run loop pushes it to the board when the build ends.`);
4627
+ } catch (err) {
4628
+ console.error(`❌ ${err.message}`);
4629
+ process.exit(1);
4630
+ }
4631
+ });
4632
+
4633
+ program
4634
+ .command('ui-baseline <action>')
4635
+ .alias('build-baseline')
4636
+ .description("For a build agent: decide whether a slice's UI needs work. `check` compares the board's hashes of the slice's screen and of every element of the slice and its codeGen.uiPrompts with what was recorded after the last UI build and prints NO_BASELINE, UNCHANGED or CHANGED (UNCHANGED = skip all UI work). `record` stores the current state once the UI is built — commit the resulting ui-baseline.json with the slice.")
4637
+ .option('--slice <idOrTitle>', 'The slice (default: the one the run loop has claimed)')
4638
+ .option('--scope <scope>', 'ui (default): screen, elements and UI prompts · backend: elements only, for a kit that builds no UI', 'ui')
4639
+ .action(async (action, opts, command) => {
4640
+ if (!['ui', 'backend'].includes(opts.scope)) {
4641
+ console.error('❌ --scope must be `ui` or `backend`.');
4642
+ process.exit(1);
4643
+ }
4644
+ if (!['check', 'record'].includes(action)) {
4645
+ console.error('❌ Action must be `check` or `record`.');
4646
+ process.exit(1);
4647
+ }
4648
+ const cwd = process.cwd();
4649
+ const kitDir = requireBuildKitDir(cwd);
4650
+ let wanted = opts.slice;
4651
+ if (!wanted) {
4652
+ const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
4653
+ if (claimed.length !== 1) {
4654
+ console.error(`❌ ${claimed.length ? `${claimed.length} slices are claimed` : 'No slice is claimed by the run loop'} — name the slice with --slice.`);
4655
+ process.exit(1);
4656
+ }
4657
+ wanted = claimed[0][0];
4658
+ }
4659
+ const found = findSliceDir(kitDir, wanted);
4660
+ if (!found) {
4661
+ console.error(`❌ Slice "${wanted}" not found under ${relative(cwd, join(kitDir, '.slices'))}.`);
4662
+ process.exit(1);
4663
+ }
4664
+ const globalOpts = command.optsWithGlobals();
4665
+ const cfg = loadEffectiveConfig(cwd, kitDir, globalOpts.config).config;
4666
+ const fetchNodes = async (ids) => {
4667
+ if (['organizationId', 'boardId', 'token'].some((f) => !cfg[f])) throw new Error('token/organizationId/boardId are not configured (.eventmodelers/config.json)');
4668
+ const res = await fetch(`${cfg.baseUrl || DEFAULT_BASE_URL}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/query`, {
4669
+ method: 'POST',
4670
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'cli-ui-baseline', ...agentHeaders(cfg) },
4671
+ body: JSON.stringify({ ids }),
4672
+ });
4673
+ if (!res.ok) throw new Error(`board returned HTTP ${res.status}`);
4674
+ return res.json();
4675
+ };
4676
+ let fp;
4677
+ try {
4678
+ fp = await fingerprint({ slice: found.slice, fetchNodes, scope: opts.scope });
4679
+ } catch (err) {
4680
+ // Cannot tell — the safe answer is the full check, never a skip.
4681
+ console.log('UNKNOWN');
4682
+ console.log(`Could not read the screen from the board (${err.message}) — do the full screen diff.`);
4683
+ return;
4684
+ }
4685
+ if (action === 'record') {
4686
+ writeBaseline(found.path, fp, opts.scope);
4687
+ console.log(`✅ Recorded the ${opts.scope === 'backend' ? 'build' : 'UI'} baseline for "${found.slice.title}" (${relative(cwd, join(found.path, 'ui-baseline.json'))}) — commit it with the slice.`);
4688
+ return;
4689
+ }
4690
+ const result = compare(readBaseline(found.path), fp, opts.scope);
4691
+ console.log(result.verdict);
4692
+ if (result.verdict === 'UNCHANGED') {
4693
+ console.log('Screen, elements and UI prompts are as of the last UI build — skip all UI work.');
4694
+ return;
4695
+ }
4696
+ if (result.verdict === 'NO_BASELINE') console.log('No UI build recorded for this slice yet — do one full screen diff, apply every UI prompt, then `ui-baseline record`.');
4697
+ const nodeLine = (label, list) => (list.length ? `\n ${label}: ${list.map((n) => `${n.title} (${n.kind === 'screen' ? 'screen' : n.kind.replace(/s$/, '')})`).join(', ')}` : '');
4698
+ const nodeDetail = nodeLine('changed', result.changedNodes) + nodeLine('added', result.addedNodes) + nodeLine('removed', result.removedNodes);
4699
+ if (nodeDetail) console.log(`Since the last recorded build:${nodeDetail}\nWork on exactly these; leave what matches alone.`);
4700
+ if (result.screenChanged && result.verdict === 'CHANGED') console.log('SCREEN_CHANGED: re-fetch the screen node, diff the components against it and adjust the UI.');
4701
+ if (result.elementsChanged && result.verdict === 'CHANGED') console.log('ELEMENTS_CHANGED: an element of the slice (command, event, read model, processor, table, actor, aggregate, specification, ...) changed on the board — diff slice.json against the code field by field.');
4702
+ if (result.promptsChanged) console.log(`PROMPTS_CHANGED: apply ${result.newPrompts.length ? 'these new/changed UI prompts' : 'the UI prompts (they were edited or removed — re-read them)'}:${result.newPrompts.map((p) => `\n - ${p.trim()}`).join('')}`);
4703
+ console.log('When the UI is done, run `ui-baseline record`.');
4704
+ });
4705
+
4706
+ program
4707
+ .command('learn [text]')
4708
+ .description("For a build agent: record one reusable learning (a codebase pattern, a gotcha, a command that works or doesn't, what a failed approach tried and why it failed). .build-kit/AGENTS.md is generated from the recorded learnings — never edit it by hand. --retract withdraws a wrong or obsolete one (ids: `learnings --ids`).")
4709
+ .option('--topic <topic>', 'Group the learning under this heading in AGENTS.md')
4710
+ .option('--tag <tag>', 'Label the learning (repeatable)', (tag, tags) => [...tags, tag], [])
4711
+ .option('--scope <scope>', 'system: true of the modeled domain · repo: about the codebase and its tooling (default repo)')
4712
+ .option('--slice <slice>', 'The slice it was learned on')
4713
+ .option('--retract <id>', 'Withdraw the learning with this id (or a unique prefix of it) instead of recording one')
4714
+ .option('--merge <ids>', 'Replace these learnings (comma-separated ids) by one with the given text — the curator\'s tool')
4715
+ .option('--reason <reason>', 'Why — recorded with a retraction or a merge')
4716
+ .action((text, opts, command) => {
4717
+ const cwd = process.cwd();
4718
+ const kitDir = requireBuildKitDir(cwd);
4719
+ const meta = agentEventMeta(cwd, kitDir, command);
4720
+ try {
4721
+ if (opts.retract !== undefined) {
4722
+ const gone = retractLearning({ kitDir, id: opts.retract, reason: opts.reason, meta });
4723
+ console.log(`✅ Retracted: ${gone.text}`);
4724
+ return;
4725
+ }
4726
+ if (opts.merge !== undefined) {
4727
+ const ids = opts.merge.split(',').map((id) => id.trim()).filter(Boolean);
4728
+ const merged = mergeLearnings({ kitDir, ids, text, topic: opts.topic, scope: opts.scope, tags: opts.tag, reason: opts.reason, meta });
4729
+ console.log(`✅ Merged ${ids.length} learnings into one (${merged.id.slice(0, 8)}).`);
4730
+ return;
4731
+ }
4732
+ if (text === undefined) throw new Error('give the learning as text, or --retract <id>, or --merge <ids> <text>');
4733
+ // The curator may only merge and retract — an LLM asked to tidy must not add facts of its own.
4734
+ if (process.env.EVENTMODELERS_CURATING === '1') throw new Error('a curation turn only merges and retracts — new learnings are not recorded during it');
4735
+ const result = recordLearning({ kitDir, text, topic: opts.topic, tags: opts.tag, scope: opts.scope ?? 'repo', slice: opts.slice, meta });
4736
+ console.log(result.duplicate ? `ℹ️ Already recorded (${result.id.slice(0, 8)}) — nothing to add.` : `✅ Recorded (${result.id.slice(0, 8)}) — .build-kit/AGENTS.md is updated.`);
4737
+ } catch (err) {
4738
+ console.error(`❌ ${err.message}`);
4739
+ process.exit(1);
4740
+ }
4741
+ });
4742
+
4743
+ program
4744
+ .command('events')
4745
+ .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.")
4746
+ .option('--kit <dir>', "Read this kit directory's log (default: the build kit of the current project, else its modeling kit)")
4747
+ .option('--global', 'Read the log of the global install (~/.eventmodelers/kit) — what runs started without a project install wrote')
4748
+ .option('--board <id>', "Only events stamped with this board (an id prefix) — the global install's log mixes every board")
4749
+ .option('--type <types>', 'Only these event types, comma-separated (e.g. claimed,settled)')
4750
+ .option('--slice <slice>', 'Only events of the slice with this id (prefix) or a title containing this text')
4751
+ .option('--tag <tag>', 'Only events carrying this tag')
4752
+ .option('--last <n>', 'Only the last n events (after the other filters)')
4753
+ .option('--json', 'Print the raw events, one JSON object per line')
4754
+ .option('--verbose', 'Print each event\'s payload under its line — for a tool call, the input it was sent (recorded by runs started with --verbose). Also shows the baseline events: the kit\'s first look at each slice (a fresh kit records every slice already on the board), which are hidden by default. `--type board-observed` and `--json` show them too.')
4755
+ .option('--state', 'Print the state the log folds to (slices, queue, open prompts, learnings) instead of the events')
4756
+ .option('--prune', 'Delete the events nothing reads any more — the journal of finished prompts — and stop. Slice state, learnings and open prompts are kept; the same prune a run offers at every 1000 events.')
4757
+ .option('--follow', 'Keep running and print events as they are appended')
4758
+ .option('--watch', 'Same as --follow')
4759
+ .action(async (opts) => {
4760
+ const cwd = process.cwd();
4761
+ if (opts.global && opts.kit) {
4762
+ console.error('❌ --global and --kit are alternatives — pick one.');
4763
+ process.exit(1);
4764
+ }
4765
+ const globalLog = join(GLOBAL_KIT_DIR, MODELING_KIT.kitDirName);
4766
+ const kitDir = opts.global ? globalLog : opts.kit ? resolve(cwd, opts.kit) : requireBuildKitDir(cwd);
4767
+ if (opts.prune) {
4768
+ try {
4769
+ const { removed, kept } = pruneLog(kitDir);
4770
+ rememberPruneAnswer(kitDir, kept);
4771
+ console.log(removed ? `Pruned ${removed} events, ${kept} left.` : `Nothing to prune — ${kept} events, all of them carry state.`);
4772
+ } catch (err) {
4773
+ console.error(`❌ ${err.message}`);
4774
+ process.exit(1);
4775
+ }
4776
+ return;
4777
+ }
4778
+ let last;
4779
+ if (opts.last !== undefined) {
4780
+ last = Number(opts.last);
4781
+ if (!Number.isInteger(last) || last < 1) {
4782
+ console.error('❌ --last needs a positive number.');
4783
+ process.exit(1);
4784
+ }
4785
+ }
4786
+ const filter = { types: opts.type?.split(',').map((t) => t.trim()).filter(Boolean), slice: opts.slice, tag: opts.tag, board: opts.board };
4787
+ const color = process.stdout.isTTY && !process.env.NO_COLOR;
4788
+ const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
4789
+ const family = (type) => (/^(claim|settled|agent-recorded|task-done|prompt-finished)/.test(type) ? '32' : /^(attempt-failed|blocked|released)/.test(type) ? '31' : /^(learning|learnings)/.test(type) ? '36' : /^(prompt|task)/.test(type) ? '35' : '33');
4790
+ const print = (events, titles) => {
4791
+ for (const e of events) {
4792
+ if (opts.json) { console.log(JSON.stringify(e)); continue; }
4793
+ const [time, ...rest] = formatEvent(e, { titles }).split(' ');
4794
+ const type = rest.shift();
4795
+ console.log([paint('2', time), paint(family(e.type), type), ...rest].join(' '));
4796
+ if (opts.verbose) { const payload = formatPayload(e); if (payload) console.log(paint('2', payload)); }
4797
+ }
4798
+ };
4799
+
4800
+ const follow = opts.follow || opts.watch;
4801
+ const events = readEvents(kitDir);
4802
+ // The baseline (each slice's first board-observed) is noise unless asked for: --verbose, the raw --json, or the type itself.
4803
+ const hideBaseline = !opts.verbose && !opts.json && !filter.types?.includes('board-observed');
4804
+ const titles = sliceTitles(events);
4805
+ if (opts.state) {
4806
+ if (follow) console.error('ℹ️ --follow/--watch does not apply to --state.');
4807
+ console.log(events.length ? summarize(fold(events), titles) : 'No events yet in this kit.');
4808
+ return;
4809
+ }
4810
+ if (!events.length && !follow) {
4811
+ console.log(`No events yet in ${opts.global ? '~/.eventmodelers/kit' : relative(cwd, kitDir) || kitDir}.`);
4812
+ // Runs started without a project install keep their log in the global kit — say so when it has one.
4813
+ if (!opts.global && !opts.kit && readEvents(globalLog).length) console.log('The global install has a log of its own: `eventmodelers events --global`.');
4814
+ return;
4815
+ }
4816
+ print(filterEvents(events, { ...filter, last, omit: hideBaseline ? baselineIds(events) : undefined }, titles), titles);
4817
+ if (!follow) return;
4818
+
4819
+ // Follow: what has been printed is remembered by event id, and the log is read again twice a second.
4820
+ const seen = new Set(events.map((e) => e.id));
4821
+ setInterval(() => {
4822
+ const all = readEvents(kitDir);
4823
+ const fresh = all.filter((e) => !seen.has(e.id));
4824
+ for (const e of fresh) seen.add(e.id);
4825
+ if (fresh.length) print(filterEvents(fresh, { ...filter, omit: hideBaseline ? baselineIds(all) : undefined }, sliceTitles(all)), sliceTitles(all));
4826
+ }, 500);
4827
+ });
4828
+
4829
+ program
4830
+ .command('replay <id>')
4831
+ .description("Submit a prompt from the event log again: puts it back on its board's queue as a new prompt, with the same text, node, comment, priority and canvas context, for an agent to work. <id> is a prompt id from `events` (a unique prefix will do). Writes to the platform.")
4832
+ .option('--kit <dir>', "Read this kit directory's log (default: the build kit of the current project, else its modeling kit)")
4833
+ .option('--global', 'Read the log of the global install (~/.eventmodelers/kit)')
4834
+ .option('--dry-run', 'Show the request that would be sent and stop — nothing is submitted')
4835
+ .option('--token <uuid>', 'API token (overrides config file / env var)')
4836
+ .option('--organization-id <uuid>', 'Organization ID (overrides config file / env var)')
4837
+ .option('--base-url <url>', 'Platform base URL (overrides config file / env var)')
4838
+ .action(async (id, opts) => {
4839
+ const cwd = process.cwd();
4840
+ if (opts.global && opts.kit) {
4841
+ console.error('❌ --global and --kit are alternatives — pick one.');
4842
+ process.exit(1);
4843
+ }
4844
+ const kitDir = opts.global ? join(GLOBAL_KIT_DIR, MODELING_KIT.kitDirName) : opts.kit ? resolve(cwd, opts.kit) : requireBuildKitDir(cwd);
4845
+ try {
4846
+ const event = findPromptEvent(readEvents(kitDir), id);
4847
+ const boardId = event.prompt.board_id ?? event.meta?.board_id;
4848
+ // The credentials of the board the prompt was for: flags and env vars over the board's own file over the account's.
4849
+ const stored = boardId ? readJsonSafe(boardCredentialsPath(boardId)) : {};
4850
+ const explicit = Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v));
4851
+ const cfg = applyEnvOverrides({ ...loadEffectiveConfig(cwd, null, null).config, ...(stored.useGlobal || stored.useBoard ? {} : stored), ...explicit });
4852
+ cfg.baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
4853
+ if (!cfg.token || !cfg.organizationId) throw new Error('no credentials for this board — pass --token/--organization-id, or run the agent once for it');
4854
+
4855
+ const tokenRes = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`, { headers: { 'x-token': cfg.token } });
4856
+ if (!tokenRes.ok) throw new Error(`realtime-token: HTTP ${tokenRes.status}`);
4857
+ const { url, headers, body } = replayRequest({ baseUrl: cfg.baseUrl, organizationId: cfg.organizationId, token: cfg.token, jwt: (await tokenRes.json()).token, event: { ...event, prompt: { ...event.prompt, board_id: boardId } } });
4858
+ if (opts.dryRun) {
4859
+ console.log(`POST ${url}\n${JSON.stringify(body, null, 2)}`);
4860
+ return;
4861
+ }
4862
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
4863
+ if (!res.ok) throw new Error(`prompts: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
4864
+ const created = await res.json();
4865
+ console.log(`Re-submitted prompt ${String(event.prompt.id).slice(0, 8)} as ${String(created.id ?? '').slice(0, 8)}: "${event.prompt.text.length > 80 ? `${event.prompt.text.slice(0, 79)}…` : event.prompt.text}"`);
4866
+ } catch (err) {
4867
+ console.error(`❌ ${err.message}`);
4868
+ process.exit(1);
4869
+ }
4870
+ });
4871
+
4872
+ program
4873
+ .command('learnings')
4874
+ .description('Print the recorded learnings — what .build-kit/AGENTS.md says. With --ids, one line each with the id to retract it by.')
4875
+ .option('--ids', 'One line per learning: id, topic, text')
4876
+ .action((opts, command) => {
4877
+ const cwd = process.cwd();
4878
+ const kitDir = requireBuildKitDir(cwd);
4879
+ syncLearnings({ kitDir, meta: agentEventMeta(cwd, kitDir, command) });
4880
+ const all = listLearnings(kitDir);
4881
+ if (!all.length) {
4882
+ console.log('No learnings recorded yet.');
4883
+ return;
4884
+ }
4885
+ if (!opts.ids) {
4886
+ console.log(readFileSync(join(kitDir, 'AGENTS.md'), 'utf-8').trimEnd());
4887
+ return;
4888
+ }
4889
+ for (const l of all) console.log(` ${l.id.slice(0, 8)} [${l.topic ?? 'General'}] ${l.text}`);
4890
+ });
4891
+
4363
4892
  program
4364
4893
  .command('stacks')
4365
4894
  .description('List available stacks (for `init --stack`)')
@@ -4455,16 +4984,14 @@ program
4455
4984
  .description('Remove everything init (with or without --modeling) installed: the kit dir, the skills it copied (project-local or ~/.claude/skills with --global), its MCP entry in .claude/settings.json, and any files written by init-agents. Leaves the root project scaffold untouched.')
4456
4985
  .option('--build-kit', `Remove ${STACKS.node.kitDirName}/ (the backend-stack kit dir)`)
4457
4986
  .option('--modeling-kit', `Remove ${MODELING_KIT.kitDirName}/ (the modeling-only kit dir)`)
4458
- .option('--bridge-kit', `Remove ${BRIDGE_KIT.kitDirName}/ (the bridge kit dir)`)
4459
4987
  .action((opts) => {
4460
4988
  const cwd = process.cwd();
4461
4989
  let targets;
4462
4990
 
4463
- if (opts.buildKit || opts.modelingKit || opts.bridgeKit) {
4991
+ if (opts.buildKit || opts.modelingKit) {
4464
4992
  targets = [];
4465
4993
  if (opts.buildKit) targets.push(join(cwd, STACKS.node.kitDirName));
4466
4994
  if (opts.modelingKit) targets.push(join(cwd, MODELING_KIT.kitDirName));
4467
- if (opts.bridgeKit) targets.push(join(cwd, BRIDGE_KIT.kitDirName));
4468
4995
  targets = targets.filter((p) => existsSync(p));
4469
4996
  if (!targets.length) {
4470
4997
  console.log('ℹ️ Nothing to remove for the requested option(s).');
@@ -4477,7 +5004,7 @@ program
4477
5004
  return;
4478
5005
  }
4479
5006
  if (targets.length > 1) {
4480
- console.log('⚠️ Multiple kit dirs found — re-run with --build-kit, --modeling-kit, and/or --bridge-kit to pick which to remove.');
5007
+ console.log('⚠️ Multiple kit dirs found — re-run with --build-kit and/or --modeling-kit to pick which to remove.');
4481
5008
  targets.forEach((t) => console.log(` ${t}`));
4482
5009
  return;
4483
5010
  }