@eventmodelers/cli 1.0.162 → 2.0.1

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 (104) hide show
  1. package/README.md +22 -27
  2. package/RELEASE_NOTES.md +20 -2
  3. package/cli.js +461 -225
  4. package/lib/adapters/spec-kitty-adapter.js +3 -7
  5. package/lib/global-agent-id.js +45 -0
  6. package/lib/modeling-local-ai.js +4 -2
  7. package/package.json +1 -1
  8. package/shared/build-kit/README.md +4 -19
  9. package/shared/build-kit/lib/agent-lock.js +75 -0
  10. package/shared/build-kit/lib/agent-name.js +27 -0
  11. package/shared/build-kit/lib/event-prune.js +58 -0
  12. package/shared/build-kit/lib/events-view.js +121 -0
  13. package/shared/build-kit/lib/local-ai-agent.js +14 -26
  14. package/shared/build-kit/lib/mcp-register.js +17 -1
  15. package/shared/build-kit/lib/prompt-journal.js +191 -0
  16. package/shared/build-kit/lib/prompt-replay.js +35 -0
  17. package/shared/build-kit/lib/ralph.js +250 -163
  18. package/shared/build-kit/lib/slice-curation.js +40 -0
  19. package/shared/build-kit/lib/slice-events.js +93 -0
  20. package/shared/build-kit/lib/slice-git.js +86 -0
  21. package/shared/build-kit/lib/slice-learnings.js +197 -0
  22. package/shared/build-kit/lib/slice-projection.js +101 -0
  23. package/shared/build-kit/lib/slice-run.js +181 -0
  24. package/shared/build-kit/lib/slice-state.js +182 -0
  25. package/shared/build-kit/lib/slice-tasks.js +35 -0
  26. package/shared/build-kit/ralph-claude.js +4 -2
  27. package/shared/build-kit/ralph-exec.js +4 -2
  28. package/shared/build-kit/ralph-local-ai.js +5 -3
  29. package/shared/skills/connect/SKILL.md +1 -5
  30. package/shared/skills/learn-eventmodelers-api/SKILL.md +13 -0
  31. package/shared/skills/request-feedback/SKILL.md +1 -2
  32. package/shared/skills/ui-contract/SKILL.md +5 -3
  33. package/stacks/axon/templates/build-kit/CLAUDE.md +3 -3
  34. package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
  35. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
  36. package/stacks/axon/templates/build-kit/lib/prompt.md +18 -41
  37. package/stacks/blank/templates/build-kit/CLAUDE.md +3 -3
  38. package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
  39. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
  40. package/stacks/blank/templates/build-kit/lib/prompt.md +19 -42
  41. package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
  42. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  43. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  44. package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
  45. package/stacks/classic/templates/build-kit/CLAUDE.md +3 -3
  46. package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
  47. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
  48. package/stacks/classic/templates/build-kit/lib/prompt.md +19 -42
  49. package/stacks/kurrent/templates/build-kit/CLAUDE.md +3 -4
  50. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
  51. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
  52. package/stacks/kurrent/templates/build-kit/lib/prompt.md +18 -41
  53. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +1 -17
  54. package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
  55. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
  56. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
  57. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +0 -8
  58. package/stacks/modeling-kit/templates/.claude/skills/stressor-analysis/SKILL.md +0 -2
  59. package/stacks/modeling-kit/templates/.claude/skills/walkthrough/SKILL.md +1 -1
  60. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
  61. package/stacks/modeling-kit/templates/kit/AGENTS.md +0 -1
  62. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
  63. package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -72
  64. package/stacks/modeling-kit/templates/kit/README.md +1 -1
  65. package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
  66. package/stacks/node/templates/build-kit/CLAUDE.md +3 -3
  67. package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
  68. package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
  69. package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
  70. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +3 -4
  71. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
  72. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
  73. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +18 -41
  74. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  75. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
  76. package/stacks/react/templates/build-kit/CLAUDE.md +3 -3
  77. package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
  78. package/stacks/react/templates/build-kit/lib/backend-prompt.md +20 -37
  79. package/stacks/react/templates/build-kit/lib/prompt.md +19 -42
  80. package/stacks/supabase/templates/build-kit/CLAUDE.md +3 -3
  81. package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
  82. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
  83. package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
  84. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +2 -2
  85. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  86. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +3 -3
  87. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
  88. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +20 -37
  89. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +19 -42
  90. package/stacks/umadb/templates/build-kit/CLAUDE.md +3 -3
  91. package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
  92. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
  93. package/stacks/umadb/templates/build-kit/lib/prompt.md +18 -41
  94. package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
  95. package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
  96. package/shared/build-kit/lib/agent.sh +0 -20
  97. package/shared/build-kit/ralph.sh +0 -111
  98. package/shared/build-kit/realtime-agent.js +0 -20
  99. package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
  100. package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
  101. package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
  102. package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
  103. package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
  104. 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,
@@ -29,14 +30,26 @@ import { modelingPermissions } from './lib/agent-permissions.js';
29
30
  // copyDirContents in installStack) for ralph.js to import standalone — see
30
31
  // shared/build-kit/lib/adapters/realtime-adapter.js for why it lives there instead.
31
32
  import { createRealtimeAdapter, keepSubscribed } from './shared/build-kit/lib/adapters/realtime-adapter.js';
33
+ import { recordAgentStatus } from './shared/build-kit/lib/slice-run.js';
34
+ import { defaultAgentName, withInstanceSuffix } from './shared/build-kit/lib/agent-name.js';
35
+ import { acquireAgentId } from './shared/build-kit/lib/agent-lock.js';
36
+ import { readEvents } from './shared/build-kit/lib/slice-events.js';
37
+ import { findPromptEvent, replayRequest } from './shared/build-kit/lib/prompt-replay.js';
38
+ import { prunableEvents, pruneLog, rememberPruneAnswer, shouldOfferPrune } from './shared/build-kit/lib/event-prune.js';
39
+ import { fold } from './shared/build-kit/lib/slice-state.js';
40
+ import { filterEvents, formatEvent, formatPayload, sliceTitles, summarize } from './shared/build-kit/lib/events-view.js';
41
+ import { stableGlobalAgentId } from './lib/global-agent-id.js';
42
+ import { ensureLearningsGitRules } from './shared/build-kit/lib/ralph.js';
43
+ import { createCallTracker, heldPromptsRequest, journalPromptAction, shouldJournalCall, journalPromptFinished, journalPromptReceived, recoverOwnPrompts } from './shared/build-kit/lib/prompt-journal.js';
44
+ import { listLearnings, mergeLearnings, recordLearning, recordTurnLearnings, retractLearning, syncLearnings } from './shared/build-kit/lib/slice-learnings.js';
32
45
 
33
46
  const __filename = fileURLToPath(import.meta.url);
34
47
  const __dirname = dirname(__filename);
35
48
 
36
49
  // Each stack is a template set under stacks/<key>/templates/{.claude,root,<kitSubdir>}.
37
50
  // 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) —
51
+ // (ralph.js, ralph-claude.js, ralph-local-ai.js, ralph-exec.js,
52
+ // code-export.mjs, lib/local-ai-agent.js, package.json, README.md) —
40
53
  // those files have no per-stack content, so they live once instead of being
41
54
  // copy-pasted into every stack (that copy-pasting is exactly how they drifted out
42
55
  // of sync before: a bugfix or default landing in one stack's copy but not another's).
@@ -44,7 +57,7 @@ const __dirname = dirname(__filename);
44
57
  // (lib/prompt.md, lib/backend-prompt.md, lib/AGENT.md, checks, etc.); shared/build-kit/*
45
58
  // is copied after it, so the shared runtime always wins over any copy a stack still ships.
46
59
  // 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/*
60
+ // — it has no cold-spawn/task-queue runtime at all, so none of shared/build-kit/*
48
61
  // applies to it; see its own templates/kit for its (much smaller) self-contained set.
49
62
  // --- Local-AI runner selection ------------------------------------------------
50
63
  // One runner drives every local/self-hosted backend. What differs between Ollama,
@@ -117,8 +130,8 @@ function resolveAgentPreset(opts, loop) {
117
130
  console.error('❌ --agent and --exec are mutually exclusive — --agent is a shortcut for --exec.');
118
131
  process.exit(1);
119
132
  }
120
- if (resolveLocalAiTarget(opts) !== null || opts.bash) {
121
- console.error('❌ --agent is mutually exclusive with --local-ai/--bash — pick one runner.');
133
+ if (resolveLocalAiTarget(opts) !== null) {
134
+ console.error('❌ --agent is mutually exclusive with --local-ai — pick one runner.');
122
135
  process.exit(1);
123
136
  }
124
137
  const cmd = AGENT_PRESETS[name]?.[loop] ?? null;
@@ -212,7 +225,7 @@ const STACKS = {
212
225
 
213
226
  // Not a stack — no backend scaffold, just skills + the agent loop. Installed via
214
227
  // `init --modeling` instead of the `init --stack <name>` picker.
215
- // useShared:false — unlike build-kit, modeling-kit has no cold-spawn/tasks.json
228
+ // useShared:false — unlike build-kit, modeling-kit has no cold-spawn/task-queue
216
229
  // runtime to reuse from shared/build-kit/*; its only runtime mode is the CLI's
217
230
  // own warm, direct-dispatch loop (`run --modeling`), so its kit dir just needs
218
231
  // lib/config.js for config resolution — see stacks/modeling-kit/templates/kit.
@@ -225,34 +238,9 @@ const MODELING_KIT = {
225
238
  needsBoardId: false,
226
239
  };
227
240
 
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
241
  // Also not a stack — installed via `init --build-kit` instead of `init --stack <name>`.
254
242
  // 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,
243
+ // task-queue engine as-is (lib/ralph.js). Its templates/build-kit/CLAUDE.md,
256
244
  // lib/{prompt,backend-prompt}.md, and templates/.claude/skills/build-*/SKILL.md are
257
245
  // TODO-marked placeholders instead of real stack content (see stacks/blank/templates)
258
246
  // — this is for a stack that isn't built into this CLI yet: fill in the TODOs against
@@ -267,7 +255,7 @@ const BLANK_BUILD_KIT = {
267
255
  needsBoardId: true,
268
256
  };
269
257
 
270
- const KIT_DIR_NAMES = [...new Set([...Object.values(STACKS), MODELING_KIT, BRIDGE_KIT, BLANK_BUILD_KIT].map((s) => s.kitDirName))];
258
+ const KIT_DIR_NAMES = [...new Set([...Object.values(STACKS), MODELING_KIT, BLANK_BUILD_KIT].map((s) => s.kitDirName))];
271
259
 
272
260
  // What `init --demo` installs, for the messages it prints. Kept in step with
273
261
  // shared/demo-slices/ (context.json's name, and the slice folders beside it).
@@ -953,7 +941,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
953
941
  // install TO, versus where we install FROM.
954
942
  const targetDir = options.targetDir ? resolve(options.targetDir) : process.cwd();
955
943
  // `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.
944
+ // other input (STACKS, MODELING_KIT) keeps using the built-in path.
957
945
  const templatesSource = options.templatesSource || join(__dirname, 'stacks', stackKey, 'templates');
958
946
  const sharedBuildKit = join(__dirname, 'shared', 'build-kit');
959
947
  // Skills with no stack-specific content (connect, learn-eventmodelers-api,
@@ -962,15 +950,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
962
950
  // of sync with each other before (e.g. one stack's connect skill silently
963
951
  // missing a bugfix another stack's copy had).
964
952
  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
953
  const isModelingKit = stackKey === MODELING_KIT.key;
973
- const sharedBridgeSkills = isBridge ? join(__dirname, 'shared', 'bridge', options.target) : null;
974
954
 
975
955
  if (!existsSync(templatesSource)) {
976
956
  console.error('❌ Templates directory not found at:', templatesSource);
@@ -983,7 +963,6 @@ async function installStack(stackKey, stackCfg, options = {}) {
983
963
  const claudeSkillsSrc = join(templatesSource, '.claude', 'skills');
984
964
  const installedSkills = [
985
965
  ...(existsSync(sharedSkills) ? readdirSync(sharedSkills) : []),
986
- ...(isBridge && existsSync(sharedBridgeSkills) ? readdirSync(sharedBridgeSkills) : []),
987
966
  ...(existsSync(claudeSkillsSrc) ? readdirSync(claudeSkillsSrc) : []),
988
967
  ];
989
968
  let claudeExtras = [];
@@ -992,14 +971,12 @@ async function installStack(stackKey, stackCfg, options = {}) {
992
971
  const globalSkillsDir = join(homedir(), '.claude', 'skills');
993
972
  console.log('📦 Installing skills globally...');
994
973
  copyDirContents(sharedSkills, globalSkillsDir);
995
- if (isBridge) copyDirContents(sharedBridgeSkills, globalSkillsDir);
996
974
  copyDirContents(claudeSkillsSrc, globalSkillsDir);
997
975
  } else {
998
976
  console.log('📦 Installing skills...');
999
977
  // Shared skills first, the stack's own templates last — so a build kit can override
1000
978
  // a shared skill (e.g. `ui-contract`) just by shipping a skill of the same name.
1001
979
  copyDirContents(sharedSkills, join(targetDir, '.claude', 'skills'));
1002
- if (isBridge) copyDirContents(sharedBridgeSkills, join(targetDir, '.claude', 'skills'));
1003
980
  copyDirContents(join(templatesSource, '.claude'), join(targetDir, '.claude'));
1004
981
  claudeExtras = existsSync(join(templatesSource, '.claude'))
1005
982
  ? readdirSync(join(templatesSource, '.claude')).filter((f) => f !== 'skills')
@@ -1041,7 +1018,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
1041
1018
  }
1042
1019
  }
1043
1020
 
1044
- // A modeling-kit + build-kit (or bridge) combo in the same project is supported
1021
+ // A modeling-kit + build-kit combo in the same project is supported
1045
1022
  // (they share one config.json — see ensureAgentId) but each kit's own instructions
1046
1023
  // now live in its own kit dir (.build-kit/CLAUDE.md, .agent-modeling-kit/CLAUDE.md)
1047
1024
  // instead of root/CLAUDE.md, precisely so a second kit's install can never clobber
@@ -1131,16 +1108,8 @@ async function installStack(stackKey, stackCfg, options = {}) {
1131
1108
  }
1132
1109
  }
1133
1110
 
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
1111
  // Make scripts executable
1143
- for (const script of ['ralph.sh', 'lib/agent.sh', 'ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1112
+ for (const script of ['ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1144
1113
  const p = join(kitDir, script);
1145
1114
  if (existsSync(p)) {
1146
1115
  try { execSync(`chmod +x "${p}"`); } catch {}
@@ -1246,12 +1215,10 @@ async function installStack(stackKey, stackCfg, options = {}) {
1246
1215
  if (options.skipEpilogue) return;
1247
1216
 
1248
1217
  console.log('\n✅ Done! Start your agent:\n');
1249
- if (isBridge) {
1250
- console.log(' npx @eventmodelers/cli bridge\n');
1251
- } else if (isModelingKit) {
1218
+ if (isModelingKit) {
1252
1219
  console.log(' npx @eventmodelers/cli run --modeling\n');
1253
1220
  } else {
1254
- console.log(' npx @eventmodelers/cli run (--local-ai or --bash for other runners)\n');
1221
+ console.log(' npx @eventmodelers/cli run (--local-ai or --exec for other runners)\n');
1255
1222
  }
1256
1223
  console.log('Connect this project to an MCP client (Claude Code, VS Code, ...):\n');
1257
1224
  console.log(` npx @eventmodelers/cli init-mcp\n`);
@@ -1390,7 +1357,7 @@ async function refreshSharedRuntime({ targetDir, kitDir, globalOpts, opts }) {
1390
1357
  if (existsSync(manifestPath)) {
1391
1358
  writeFileSync(manifestPath, JSON.stringify({ ...readJsonSafe(manifestPath), version: CLI_VERSION }, null, 2));
1392
1359
  }
1393
- for (const script of ['ralph.sh', 'lib/agent.sh', 'ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1360
+ for (const script of ['ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1394
1361
  const p = join(kitDir, script);
1395
1362
  if (existsSync(p)) {
1396
1363
  try { execSync(`chmod +x "${p}"`); } catch {}
@@ -1750,6 +1717,22 @@ async function fetchDefaultBoardId(baseUrl, token) {
1750
1717
  }
1751
1718
  }
1752
1719
 
1720
+ // The board whose credentials were written most recently — the best guess for "the board I
1721
+ // was just driving" when nothing in this directory names one. Only offered as the default
1722
+ // of the interactive question, never applied silently.
1723
+ function lastUsedBoardId() {
1724
+ try {
1725
+ const dir = join(GLOBAL_DIR, 'boards');
1726
+ return readdirSync(dir)
1727
+ .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))
1728
+ .map((f) => ({ id: f.slice(0, -5), mtime: statSync(join(dir, f)).mtimeMs, creds: readJsonSafe(join(dir, f)) }))
1729
+ .filter((b) => b.creds.token)
1730
+ .sort((a, b) => b.mtime - a.mtime)[0]?.id ?? null;
1731
+ } catch {
1732
+ return null;
1733
+ }
1734
+ }
1735
+
1753
1736
  // Per-run credentials for the global install. Precedence is this CLI's usual one, with the
1754
1737
  // per-board file slotted in as the most specific *file*: explicit flags beat
1755
1738
  // EVENTMODELERS_* env vars beat ~/.eventmodelers/boards/<board>.json beat the nearest
@@ -1789,6 +1772,7 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print,
1789
1772
  let boardChosen = !!(explicit.boardId || process.env.EVENTMODELERS_BOARD_ID);
1790
1773
  let pasted = null;
1791
1774
  if (!boardChosen && interactive) {
1775
+ boardId = boardId || lastUsedBoardId();
1792
1776
  const answer = await prompt(boardId ? `\n Board ID [${boardId}]: ` : '\n Board ID: ');
1793
1777
  if (answer) {
1794
1778
  pasted = parseCredentialsPaste(answer, ['organizationId', 'token', 'boardId']);
@@ -1916,16 +1900,12 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print,
1916
1900
  process.exit(1);
1917
1901
  }
1918
1902
 
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();
1903
+ // One identity per board and repo — the directory the run is started in — kept in ~/.eventmodelers/agent-ids.json, so a
1904
+ // standalone agent is the same agent on every start: its chat sessions, its star on the board and the prompts it holds
1905
+ // stay addressed to it. This is the id it keeps; whether this process may use it (another live run may already be
1906
+ // under it) is decided in the run action, which also lets `run --id` pin a different one. Not written into the
1907
+ // per-board credentials file below — the id is not a credential.
1908
+ config.agentId = stableGlobalAgentId({ file: join(GLOBAL_DIR, 'agent-ids.json'), boardId: config.boardId, dir: cwd });
1929
1909
  writeBoardCredentials(stored.useGlobal
1930
1910
  ? { boardId: config.boardId, useGlobal: true, agentName: config.agentName }
1931
1911
  : config);
@@ -1963,7 +1943,7 @@ async function ensureGlobalKit(baseUrl) {
1963
1943
  }
1964
1944
 
1965
1945
  // `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
1946
+ // cold-spawn/task-queue loop for this kit (that's a build-kit concept; see the
1967
1947
  // `run` command's build-kit-vs-modeling-kit gate above). It keeps ONE Claude
1968
1948
  // process warm across turns via `--input-format stream-json`, subscribes to the
1969
1949
  // org's realtime channel itself, and writes each prompt straight to that
@@ -1990,6 +1970,27 @@ async function ensureGlobalKit(baseUrl) {
1990
1970
  // untargeted is handed straight back to the queue for another agent to take. It says
1991
1971
  // nothing about the standalone lane — a self-directed turn is nobody's task, so an
1992
1972
  // exclusive standalone agent still works the board on its own initiative.
1973
+ // A new instance of a loop starts by checking how big its event log has grown: at every multiple of 1000 events it
1974
+ // asks whether to prune it (`events --prune` does the same on demand). Only asked where someone can answer.
1975
+ async function offerPrune(kitDir, { interactive }) {
1976
+ let count;
1977
+ try { count = readEvents(kitDir).length; } catch { return; }
1978
+ if (!shouldOfferPrune(kitDir, count)) return;
1979
+ const prunable = prunableEvents(readEvents(kitDir)).length;
1980
+ if (!interactive) {
1981
+ console.error(`ℹ️ The event log has ${count} events (${prunable} of them prunable) — \`eventmodelers events --prune\` removes them.`);
1982
+ return;
1983
+ }
1984
+ 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();
1985
+ if (answer === 'y' || answer === 'yes') {
1986
+ const { removed, kept } = pruneLog(kitDir);
1987
+ rememberPruneAnswer(kitDir, kept);
1988
+ console.log(`Pruned ${removed} events, ${kept} left.\n`);
1989
+ } else {
1990
+ rememberPruneAnswer(kitDir, count);
1991
+ }
1992
+ }
1993
+
1993
1994
  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
1995
  // --worker: only prompts — no chat, and no board-change turns (it has nowhere to propose).
1995
1996
  const chat = !worker;
@@ -2017,7 +2018,17 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2017
2018
  // `run --id` overrides that for this run only — ensureAgentId is skipped rather than
2018
2019
  // overwritten, so the project's own stable id stays on disk and the next run without the
2019
2020
  // flag is the same agent the platform saw before.
2020
- if (!overrides) local.agentId = identity.agentId || ensureAgentId(kitDir, 'MODELING');
2021
+ if (!overrides) {
2022
+ if (identity.agentId) {
2023
+ local.agentId = identity.agentId;
2024
+ } else {
2025
+ // The project's stable id — unless a live process already runs under it (two `run --modeling` in one
2026
+ // project), in which case this one gets a fresh id (agent-lock.js).
2027
+ const { agentId, fellBack } = acquireAgentId({ lockDir: join(projectDir, '.eventmodelers', 'locks'), stableId: ensureAgentId(kitDir, 'MODELING') });
2028
+ local.agentId = agentId;
2029
+ local.agentIdFellBack = fellBack;
2030
+ }
2031
+ }
2021
2032
  if (!local.token || !local.organizationId) {
2022
2033
  console.error('❌ --modeling needs platform credentials in .eventmodelers/config.json (token + organizationId) — run `/connect` once or paste your config first.');
2023
2034
  process.exit(1);
@@ -2026,6 +2037,17 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2026
2037
  // resolveModelingCredentials just minted for this run, which would otherwise win back over
2027
2038
  // an explicit --id.
2028
2039
  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
2040
+ // No name given (--name, agentName in the config): the repository's, so the board shows which agent is live.
2041
+ // (On the global install projectDir is ~/.eventmodelers/kit; the repo is the directory the run was started in.)
2042
+ if (!cfg.agentName) {
2043
+ const base = defaultAgentName(codeDir ?? projectDir, 'MODELING');
2044
+ cfg.agentName = cfg.agentIdFellBack ? withInstanceSuffix(base, cfg.agentId) : base;
2045
+ }
2046
+ // Learnings are kept only on a project install: the global install's kit dir (~/.eventmodelers/kit) is shared by every
2047
+ // board and account, so what one board taught the agent would end up in another's AGENTS.md. The prompt journal has no
2048
+ // such problem — every entry is stamped with its board (`events --board`) — so every run keeps it, standalone included.
2049
+ const keepsRecord = !standalone && !overrides;
2050
+ if (keepsRecord) ensureLearningsGitRules(kitDir);
2029
2051
  if (!cfg.boardId) {
2030
2052
  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
2053
  process.exit(1);
@@ -2051,7 +2073,7 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2051
2073
  let localRunner = null;
2052
2074
  if (localAi) {
2053
2075
  try {
2054
- localRunner = createModelingLocalAiRunner({ cfg, target: localAi, log, verbose, standalone });
2076
+ 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
2077
  } catch (err) {
2056
2078
  console.error(`❌ --local-ai: ${err.message}`);
2057
2079
  process.exit(1);
@@ -2190,6 +2212,19 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2190
2212
  let proc = null;
2191
2213
  let stdoutBuffer = '';
2192
2214
  let pending = null; // one in-flight turn at a time
2215
+ let turnPromptId = null; // the prompt the current turn works, so the board writes it makes are journaled against it
2216
+ // A call is journaled when it ends, so its entry carries how long it took (see journalPromptAction). Only the calls the
2217
+ // journal will keep are tracked at all.
2218
+ const calls = createCallTracker();
2219
+ let localCallSeq = 0;
2220
+ 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 }));
2221
+ const noteToolStart = (id, name, input) => {
2222
+ if (turnPromptId && shouldJournalCall(name, input, verbose)) calls.begin(id, { name, input });
2223
+ };
2224
+ const noteToolEnd = (id, outcome) => {
2225
+ const call = calls.end(id, outcome);
2226
+ if (call) journalCall(call);
2227
+ };
2193
2228
  // The last message of each session this process has handed to Claude. A session the process has
2194
2229
  // never seen is read whole; after that only what came since — each message enters the warm
2195
2230
  // session's context once. Cleared whenever the process is (re)spawned: a fresh process has read
@@ -2248,10 +2283,16 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2248
2283
  let msg;
2249
2284
  try { msg = JSON.parse(line); } catch { return; }
2250
2285
 
2286
+ if (msg.type === 'user' && Array.isArray(msg.message?.content)) {
2287
+ for (const block of msg.message.content) if (block.type === 'tool_result') noteToolEnd(block.tool_use_id, { isError: block.is_error });
2288
+ return;
2289
+ }
2290
+
2251
2291
  if (msg.type === 'assistant') {
2252
2292
  for (const block of msg.message?.content ?? []) {
2253
2293
  if (block.type === 'text' && block.text && verbose) log(block.text);
2254
2294
  if (block.type === 'tool_use') {
2295
+ noteToolStart(block.id, block.name, block.input);
2255
2296
  if (verbose) log(`→ ${describeToolUse(block)}`);
2256
2297
  else if (block.name === 'Skill') log(`→ Skill: ${block.input?.skill ?? ''}`);
2257
2298
  else log(`→ ${block.name}`);
@@ -2381,10 +2422,10 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2381
2422
  log(chat ? 'chat: ON — answering chat messages addressed to this agent' : 'chat: off (--worker)');
2382
2423
  if (exclusive) {
2383
2424
  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,
2425
+ // A run keeps its stable id unless another live process is already running under it (agent-lock.js), in which
2426
+ // case it got a fresh one — and an id someone addressed a prompt to is not this agent. Worth saying out loud here,
2386
2427
  // 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');
2428
+ 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
2429
  }
2389
2430
  // Both warm-ups are the same bet — pay the session's fixed setup cost before a turn arrives
2390
2431
  // rather than making whoever sends the first prompt wait for it. For a local model that cost
@@ -2420,7 +2461,16 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2420
2461
  headers: { 'x-token': cfg.token, 'Content-Type': 'application/json', ...agentHeaders(cfg) },
2421
2462
  body: JSON.stringify({ status: 'ADDED' }),
2422
2463
  });
2423
- if (!res.ok) throw new Error(`prompts/${promptId}/status: HTTP ${res.status}`);
2464
+ if (!res.ok) throw Object.assign(new Error(`prompts/${promptId}/status: HTTP ${res.status}`), { status: res.status });
2465
+ }
2466
+
2467
+ // The prompts the platform shows as held (CLAIMED / IN_PROGRESS) under this agent's id — GET /prompts?claimed_by=,
2468
+ // which reads and claims nothing. At start an agent holds none, so these are what an earlier run left behind.
2469
+ async function fetchHeldPrompts(agentId) {
2470
+ const { url, headers } = heldPromptsRequest({ baseUrl: cfg.baseUrl, organizationId: cfg.organizationId, boardId: cfg.boardId, token: cfg.token, jwt: realtimeToken, agentId });
2471
+ const res = await fetch(url, { headers });
2472
+ if (!res.ok) throw new Error(`prompts list: HTTP ${res.status}`);
2473
+ return ((await res.json()).prompts ?? []).map((p) => ({ id: p.id, status: p.status }));
2424
2474
  }
2425
2475
 
2426
2476
  // ── Chat ───────────────────────────────────────────────────────────────────
@@ -2547,6 +2597,21 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2547
2597
 
2548
2598
  let realtimeToken = await getRealtimeToken();
2549
2599
 
2600
+ // What this agent writes into its own record: events in the kit, stamped with who and where (learnings: see keepsRecord).
2601
+ const journalMeta = { agent_id: cfg.agentId, agent_type: 'MODELING', organization_id: cfg.organizationId, board_id: cfg.boardId };
2602
+ const journal = (write) => {
2603
+ try { write(); } catch (err) { log(`could not write the prompt journal: ${err.message}`); }
2604
+ };
2605
+
2606
+ // Prompts an earlier run of this agent was holding are still CLAIMED / IN_PROGRESS — the platform never re-queues
2607
+ // them by itself. This run holds none yet, so whatever the platform shows as held under this agent's id goes back.
2608
+ {
2609
+ const recovered = await recoverOwnPrompts({ kitDir, meta: journalMeta, agentId: cfg.agentId, fetchHeld: fetchHeldPrompts, release: releasePrompt });
2610
+ if (recovered.released || recovered.settled || recovered.left) {
2611
+ log(`prompts from an earlier run: ${recovered.released} handed back, ${recovered.settled} already settled${recovered.left ? `, ${recovered.left} left for the next start` : ''}`);
2612
+ }
2613
+ }
2614
+
2550
2615
  let draining = false;
2551
2616
  async function drain() {
2552
2617
  if (draining) return;
@@ -2583,10 +2648,28 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2583
2648
  continue;
2584
2649
  }
2585
2650
  log(`prompt received: "${p.prompt}" (board=${p.board_id ?? cfg.boardId ?? 'n/a'}, priority=${p.priority})`);
2651
+ journal(() => journalPromptReceived({ kitDir, prompt: p, meta: journalMeta }));
2586
2652
  try {
2587
- await runTurn(buildTurn(p));
2653
+ turnPromptId = p.id;
2654
+ const reply = await runTurn(buildTurn(p));
2655
+ // The agent cannot run `learn` (locked-down Bash), so it states learnings in its reply and they are recorded here.
2656
+ // Best effort: a failed write never takes the prompt queue down.
2657
+ if (keepsRecord) {
2658
+ try {
2659
+ const n = recordTurnLearnings({ kitDir, reply, meta: journalMeta });
2660
+ if (n) log(`recorded ${n} learning${n === 1 ? '' : 's'} from the turn`);
2661
+ } catch (err) {
2662
+ log(`could not record the turn's learnings: ${err.message}`);
2663
+ }
2664
+ }
2665
+ journal(() => journalPromptFinished({ kitDir, id: p.id, outcome: 'worked', meta: journalMeta }));
2588
2666
  } catch (err) {
2667
+ // Not finished: the entry stays open, so the next start asks the platform about it and hands it back
2668
+ // (or closes it if the agent had set DONE before the turn failed).
2589
2669
  log(`turn failed: ${err.message}`);
2670
+ } finally {
2671
+ for (const call of calls.drain()) journalCall(call); // never ended: the turn died mid-call, so no duration
2672
+ turnPromptId = null;
2590
2673
  }
2591
2674
  }
2592
2675
  } finally {
@@ -3203,12 +3286,41 @@ program
3203
3286
  // dir: a modeling run falls back to the global install (see ensureGlobalKit) rather than
3204
3287
  // requiring one here, and the build-kit branch reports a better-targeted error of its own
3205
3288
  // 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']);
3289
+ const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
3207
3290
 
3208
3291
  // Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
3209
3292
  // front of them would only be noise.
3210
3293
  const NO_KIT_UPDATE_HINT = new Set(['init', 're-init', 'uninstall']);
3211
3294
 
3295
+ // Retired: still recognised, so an old script or muscle memory gets an explanation instead of "unknown option".
3296
+ // The hidden options and the `bridge` command exist only to say so; they do nothing else.
3297
+ const RETIRED_BASH = [
3298
+ '❌ `run --bash` (the bash-only loop, ralph.sh) has been retired.',
3299
+ ' Its task queue (tasks.json) is now an event log inside the kit, which the bash scripts cannot read or write.',
3300
+ ' Use the default loop instead: npx @eventmodelers/cli run',
3301
+ ' (add --exec "<command>" to drive another agent harness, or --local-ai for a local model).',
3302
+ ];
3303
+ const RETIRED_BRIDGE = [
3304
+ '❌ The bridge kit (`init --bridge`, `bridge`) has been retired — it ran on the same tasks.json queue, which is now an event log.',
3305
+ ' To hand slices to Spec Kitty, `fetch --spec-kitty` still works on its own, without a standing loop.',
3306
+ ];
3307
+ function retire(lines) {
3308
+ console.error(lines.join('\n'));
3309
+ process.exit(1);
3310
+ }
3311
+
3312
+ program
3313
+ .command('bridge', { hidden: true })
3314
+ .allowUnknownOption()
3315
+ .allowExcessArguments()
3316
+ .action(() => retire(RETIRED_BRIDGE));
3317
+
3318
+ program.hook('preAction', (_thisCommand, actionCommand) => {
3319
+ const o = actionCommand.opts();
3320
+ if (actionCommand.name() === 'run' && o.bash) retire(RETIRED_BASH);
3321
+ if (actionCommand.name() === 'init' && (o.bridge || o.target || o.hook)) retire(RETIRED_BRIDGE);
3322
+ });
3323
+
3212
3324
  // Startup hint when this CLI is behind npm, or an installed kit here is behind this CLI.
3213
3325
  // Best effort and on stderr, so it never breaks piped output; EVENTMODELERS_NO_UPDATE_CHECK
3214
3326
  // (or CI) turns it off.
@@ -3228,7 +3340,6 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
3228
3340
  console.error(' Run one of these first:');
3229
3341
  console.error(` npx @eventmodelers/cli init --stack <name> (${Object.keys(STACKS).join(', ')})`);
3230
3342
  console.error(' npx @eventmodelers/cli init --modeling');
3231
- console.error(` npx @eventmodelers/cli init --bridge --target <name> (${Object.keys(BRIDGE_TARGETS).join(', ')})`);
3232
3343
  process.exit(1);
3233
3344
  });
3234
3345
 
@@ -3278,15 +3389,15 @@ function identityOverridesFromOpts(opts) {
3278
3389
  credentialFlags(program
3279
3390
  .command('init')
3280
3391
  .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)')
3392
+ .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
3393
  .option('--stack <name>', `Stack to install (${Object.keys(STACKS).join(', ')}), or a name of your choosing when combined with --git`)
3283
3394
  .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
3395
  .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.')
3396
+ .option('--modeling', 'Install skills + the agent loop only — no backend scaffold. Mutually exclusive with --stack/--build-kit.')
3397
+ .addOption(new Option('--bridge').hideHelp())
3398
+ .addOption(new Option('--target <name>').hideHelp())
3399
+ .addOption(new Option('--hook <command>').hideHelp())
3400
+ .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
3401
  .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
3402
  .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
3403
  .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
@@ -3296,10 +3407,10 @@ credentialFlags(program
3296
3407
  .action(async (opts, command) => {
3297
3408
  const globalOpts = command.optsWithGlobals();
3298
3409
 
3299
- if (opts.modeling || opts.bridge || opts.buildKit) {
3300
- const modeCount = [opts.modeling, opts.bridge, opts.buildKit].filter(Boolean).length;
3410
+ if (opts.modeling || opts.buildKit) {
3411
+ const modeCount = [opts.modeling, opts.buildKit].filter(Boolean).length;
3301
3412
  if (opts.stack || opts.git || modeCount > 1) {
3302
- console.error('❌ --stack/--git, --modeling, --bridge, and --build-kit are mutually exclusive — pick one.');
3413
+ console.error('❌ --stack/--git, --modeling and --build-kit are mutually exclusive — pick one.');
3303
3414
  process.exit(1);
3304
3415
  }
3305
3416
  }
@@ -3328,38 +3439,6 @@ credentialFlags(program
3328
3439
  return;
3329
3440
  }
3330
3441
 
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
3442
  if (opts.branch && !opts.git) {
3364
3443
  console.error('❌ --branch only applies to --git — nothing to clone without it.');
3365
3444
  process.exit(1);
@@ -3561,13 +3640,11 @@ program
3561
3640
  .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
3641
  .action(() => {
3563
3642
  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.
3643
+ // Mirrors `run`'s buildKitDir resolution: a modeling-kit install shares KIT_DIR_NAMES but never ships
3644
+ // check-commit-scope.cjs, so prefer whichever installed dir isn't the modeling kit.
3567
3645
  const installedKitDirs = findAllInstalledKitDirs(cwd);
3568
3646
  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];
3647
+ const kitDir = installedKitDirs.find((d) => d !== modelingKitDir) ?? installedKitDirs[0];
3571
3648
 
3572
3649
  const checkScript = join(kitDir, 'lib', 'check-commit-scope.cjs');
3573
3650
  if (!existsSync(checkScript)) {
@@ -3702,10 +3779,10 @@ credentialFlags(program
3702
3779
  .command('run')
3703
3780
  .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
3781
  .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.`)
3782
+ .addOption(new Option('--bash').hideHelp())
3783
+ .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
3784
  .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
3785
  .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)')
3709
3786
  .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
3787
  .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
3788
  .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).')
@@ -3720,7 +3797,7 @@ credentialFlags(program
3720
3797
  .option('--handover', 'Backend build kit: for this run only, finish slices that have a UI at `Handover` instead of `Done` (same as the project setting `handover: true`, which `init --handover` / `config --handover on` save — this flag writes nothing). Without it the project setting applies; without both, slices end at `Done`.')
3721
3798
  .option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned, Handover). Defaults to the kit\'s own pickup status — `Planned` for backend kits, `Handover` for the UI kits. Nothing is written to disk.')
3722
3799
  .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.')
3800
+ .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
3801
  .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
3802
  .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
3803
  .action(async (opts, command) => {
@@ -3779,11 +3856,6 @@ credentialFlags(program
3779
3856
  process.exit(1);
3780
3857
  }
3781
3858
  }
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
3859
  // Both kit dirs can be installed side by side (e.g. running a build-kit and a
3788
3860
  // modeling-kit agent from the same project). findInstalledKitDir only ever
3789
3861
  // returns its first fixed-order match, which would silently prefer one stack
@@ -3791,16 +3863,11 @@ credentialFlags(program
3791
3863
  // we resolve each stack's dir independently instead of relying on that order.
3792
3864
  const installedKitDirs = findAllInstalledKitDirs(cwd);
3793
3865
  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;
3866
+ const buildKitDir = installedKitDirs.find((d) => d !== modelingKitDir) ?? null;
3800
3867
 
3801
3868
  // No overlap between the two stacks' runtimes: modeling-kit only ever runs the
3802
3869
  // 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
3870
+ // cold-spawn/task-queue loop (default, or --local-ai/--exec). Neither falls back
3804
3871
  // to the other's mechanism, so each side is gated explicitly below rather than
3805
3872
  // just being left to fail on a missing file.
3806
3873
  // --standalone implies --modeling: it already refused every other runner, so there
@@ -3809,14 +3876,8 @@ credentialFlags(program
3809
3876
  // no meaning for a build kit, which is scaffolded per project by definition.
3810
3877
  if (opts.modeling || opts.standalone || opts.worker || opts.global) {
3811
3878
  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
- }
3879
+ // --local-ai and --exec name what does a turn, not a queue, and the modeling loop has its own
3880
+ // runner for each (lib/modeling-local-ai.js, lib/modeling-exec.js).
3820
3881
  const modelingLocalAi = resolveLocalAiTarget(opts);
3821
3882
  if (modelingLocalAi !== null && opts.exec) {
3822
3883
  console.error('❌ --local-ai and --exec are mutually exclusive — pick one runner.');
@@ -3865,6 +3926,13 @@ credentialFlags(program
3865
3926
  ...Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v)),
3866
3927
  };
3867
3928
  const config = await resolveModelingCredentials(cwd, flags, globalOpts.config, globalOpts.print, !!opts.nonInteractive, !!opts.force);
3929
+ // The stable id of this board and repo — unless `--id` pins another, or a live process already runs under it,
3930
+ // in which case this run gets a fresh one (agent-lock.js).
3931
+ if (!identity.agentId) {
3932
+ const { agentId, fellBack } = acquireAgentId({ lockDir: join(GLOBAL_DIR, 'locks'), stableId: config.agentId });
3933
+ config.agentId = agentId;
3934
+ config.agentIdFellBack = fellBack;
3935
+ }
3868
3936
  projectDir = await ensureGlobalKit(config.baseUrl);
3869
3937
  kitDir = join(projectDir, MODELING_KIT.kitDirName);
3870
3938
  overrides = config;
@@ -3896,6 +3964,7 @@ credentialFlags(program
3896
3964
  // right after (dynamic imports, config reads) can eat the event-loop tick this write
3897
3965
  // needed to drain, so a piped watcher sees the ping arrive after runModeling's own
3898
3966
  // [modeling] log lines instead of before them.
3967
+ await offerPrune(kitDir, { interactive: !globalOpts.print && !opts.nonInteractive && !!process.stdin.isTTY });
3899
3968
  const shown = relative(cwd, kitDir);
3900
3969
  const runnerLabel = modelingLocalAi ? 'local model' : opts.exec ? 'one agent process per turn' : 'warm Claude process';
3901
3970
  await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (${runnerLabel}) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
@@ -3910,9 +3979,7 @@ credentialFlags(program
3910
3979
 
3911
3980
  if (!buildKitDir) {
3912
3981
  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.`);
3982
+ 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
3983
  } else {
3917
3984
  console.error(`❌ No kit installed in ${cwd} — run \`eventmodelers install\` first.`);
3918
3985
  console.error(' (A modeling agent needs no install at all: eventmodelers run --standalone --board-id <uuid>)');
@@ -3922,17 +3989,19 @@ credentialFlags(program
3922
3989
  const kitDir = buildKitDir;
3923
3990
 
3924
3991
  const localAiTarget = resolveLocalAiTarget(opts);
3925
- const pickedCount = [opts.bash, localAiTarget !== null, !!opts.exec].filter(Boolean).length;
3992
+ const pickedCount = [localAiTarget !== null, !!opts.exec].filter(Boolean).length;
3926
3993
  if (pickedCount > 1) {
3927
- console.error('❌ --bash, --local-ai and --exec are mutually exclusive — pick one runner.');
3994
+ console.error('❌ --local-ai and --exec are mutually exclusive — pick one runner.');
3928
3995
  process.exit(1);
3929
3996
  }
3930
3997
 
3998
+ await offerPrune(kitDir, { interactive: !globalOpts.print && !opts.nonInteractive && !!process.stdin.isTTY });
3999
+
3931
4000
  // The actual agent loop lives in the scaffolded kit dir, not in this package — this
3932
4001
  // is just a thin dispatcher so users don't have to remember the kit-dir name or which
3933
4002
  // runner file to invoke. Users (and the agent itself, via AGENT.md) may customize these
3934
4003
  // 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';
4004
+ const runner = opts.exec ? 'ralph-exec.js' : localAiTarget !== null ? 'ralph-local-ai.js' : 'ralph-claude.js';
3936
4005
  const runnerPath = join(kitDir, runner);
3937
4006
  if (!existsSync(runnerPath)) {
3938
4007
  console.error(`❌ ${relative(cwd, runnerPath)} not found.`);
@@ -3983,77 +4052,16 @@ credentialFlags(program
3983
4052
  console.log(`▶ Starting ${relative(cwd, runnerPath)}...\n`);
3984
4053
  const cmd = runner.endsWith('.sh') ? `"${runnerPath}"` : `node "${runnerPath}"`;
3985
4054
  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.
4055
+ // Only ralph-claude.js reads RALPH_VERBOSE — the local-AI executor has its own separate output
4056
+ // path with no stream-json parsing to gate. RALPH_LOCAL is read by ralph.js's startRalph to force
4057
+ // the local-only branch even when .eventmodelers/config.json has valid credentials.
4058
+ // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read there too.
3992
4059
  execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
3993
4060
  } catch (err) {
3994
4061
  process.exit(err.status || 1);
3995
4062
  }
3996
4063
  });
3997
4064
 
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
4065
  program
4058
4066
  .command('listen')
4059
4067
  .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/')
@@ -4360,6 +4368,236 @@ program
4360
4368
  console.log(`✅ Pushed status change to the board (node ${slice.id}).`);
4361
4369
  });
4362
4370
 
4371
+ // The kit a command that records events works against: a build kit if one is installed, else the modeling
4372
+ // kit. The build kit wins when both are installed — the modeling agent never runs these commands itself
4373
+ // (its Bash is an allowlist); it states its learnings in its reply and `runModeling` records them.
4374
+ function requireBuildKitDir(cwd) {
4375
+ const installed = findAllInstalledKitDirs(cwd);
4376
+ const kitDir = installed.find((d) => !d.endsWith(MODELING_KIT.kitDirName)) ?? installed.find((d) => d.endsWith(MODELING_KIT.kitDirName));
4377
+ if (!kitDir) {
4378
+ console.error('❌ This command needs a build or modeling kit (npx @eventmodelers/cli init --stack <name> or init --modeling).');
4379
+ process.exit(1);
4380
+ }
4381
+ return kitDir;
4382
+ }
4383
+
4384
+ // What the build agent's events are stamped with: the loop exports the agent id into its environment.
4385
+ function agentEventMeta(cwd, kitDir, command) {
4386
+ const { config: cfg } = loadEffectiveConfig(cwd, kitDir, command.optsWithGlobals().config);
4387
+ return {
4388
+ agent_id: process.env.RALPH_AGENT_ID || process.env.EVENTMODELERS_AGENT_ID,
4389
+ agent_type: 'BUILD', // `learn` and `slice-status` are the build agent's commands
4390
+ organization_id: cfg.organizationId,
4391
+ board_id: cfg.boardId,
4392
+ };
4393
+ }
4394
+
4395
+ program
4396
+ .command('slice-status <status>')
4397
+ .description("For a build agent: record the outcome of the slice the run loop is building (Done, Handover, Blocked, ...). The loop owns the slice's status on the board and pushes it when the build ends — never edit index.json or call update-slice-status for a loop build.")
4398
+ .option('--slice <idOrTitle>', 'The slice, when the loop has claimed more than one (default: the one it is building)')
4399
+ .action((status, opts, command) => {
4400
+ const cwd = process.cwd();
4401
+ const canonical = SLICE_STATUSES.find((s) => s.value.toLowerCase() === status.toLowerCase())?.value;
4402
+ if (!canonical) {
4403
+ console.error(`❌ "${status}" is not a slice status. Valid: ${SLICE_STATUSES.map((s) => s.value).join(', ')}`);
4404
+ process.exit(1);
4405
+ }
4406
+ const kitDir = requireBuildKitDir(cwd);
4407
+ const meta = agentEventMeta(cwd, kitDir, command);
4408
+ try {
4409
+ const result = recordAgentStatus({ kitDir, status: canonical, sliceId: opts.slice, meta });
4410
+ console.log(`✅ "${result.title}": recorded ${result.status} — the run loop pushes it to the board when the build ends.`);
4411
+ } catch (err) {
4412
+ console.error(`❌ ${err.message}`);
4413
+ process.exit(1);
4414
+ }
4415
+ });
4416
+
4417
+ program
4418
+ .command('learn [text]')
4419
+ .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`).")
4420
+ .option('--topic <topic>', 'Group the learning under this heading in AGENTS.md')
4421
+ .option('--tag <tag>', 'Label the learning (repeatable)', (tag, tags) => [...tags, tag], [])
4422
+ .option('--scope <scope>', 'system: true of the modeled domain · repo: about the codebase and its tooling (default repo)')
4423
+ .option('--slice <slice>', 'The slice it was learned on')
4424
+ .option('--retract <id>', 'Withdraw the learning with this id (or a unique prefix of it) instead of recording one')
4425
+ .option('--merge <ids>', 'Replace these learnings (comma-separated ids) by one with the given text — the curator\'s tool')
4426
+ .option('--reason <reason>', 'Why — recorded with a retraction or a merge')
4427
+ .action((text, opts, command) => {
4428
+ const cwd = process.cwd();
4429
+ const kitDir = requireBuildKitDir(cwd);
4430
+ const meta = agentEventMeta(cwd, kitDir, command);
4431
+ try {
4432
+ if (opts.retract !== undefined) {
4433
+ const gone = retractLearning({ kitDir, id: opts.retract, reason: opts.reason, meta });
4434
+ console.log(`✅ Retracted: ${gone.text}`);
4435
+ return;
4436
+ }
4437
+ if (opts.merge !== undefined) {
4438
+ const ids = opts.merge.split(',').map((id) => id.trim()).filter(Boolean);
4439
+ const merged = mergeLearnings({ kitDir, ids, text, topic: opts.topic, scope: opts.scope, tags: opts.tag, reason: opts.reason, meta });
4440
+ console.log(`✅ Merged ${ids.length} learnings into one (${merged.id.slice(0, 8)}).`);
4441
+ return;
4442
+ }
4443
+ if (text === undefined) throw new Error('give the learning as text, or --retract <id>, or --merge <ids> <text>');
4444
+ // The curator may only merge and retract — an LLM asked to tidy must not add facts of its own.
4445
+ if (process.env.EVENTMODELERS_CURATING === '1') throw new Error('a curation turn only merges and retracts — new learnings are not recorded during it');
4446
+ const result = recordLearning({ kitDir, text, topic: opts.topic, tags: opts.tag, scope: opts.scope ?? 'repo', slice: opts.slice, meta });
4447
+ 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.`);
4448
+ } catch (err) {
4449
+ console.error(`❌ ${err.message}`);
4450
+ process.exit(1);
4451
+ }
4452
+ });
4453
+
4454
+ program
4455
+ .command('events')
4456
+ .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.")
4457
+ .option('--kit <dir>', "Read this kit directory's log (default: the build kit of the current project, else its modeling kit)")
4458
+ .option('--global', 'Read the log of the global install (~/.eventmodelers/kit) — what runs started without a project install wrote')
4459
+ .option('--board <id>', "Only events stamped with this board (an id prefix) — the global install's log mixes every board")
4460
+ .option('--type <types>', 'Only these event types, comma-separated (e.g. claimed,settled)')
4461
+ .option('--slice <slice>', 'Only events of the slice with this id (prefix) or a title containing this text')
4462
+ .option('--tag <tag>', 'Only events carrying this tag')
4463
+ .option('--last <n>', 'Only the last n events (after the other filters)')
4464
+ .option('--json', 'Print the raw events, one JSON object per line')
4465
+ .option('--verbose', 'Print each event\'s payload under its line — for a tool call, the input it was sent (recorded by runs started with --verbose)')
4466
+ .option('--state', 'Print the state the log folds to (slices, queue, open prompts, learnings) instead of the events')
4467
+ .option('--prune', 'Delete the events nothing reads any more — the journal of finished prompts — and stop. Slice state, learnings and open prompts are kept; the same prune a run offers at every 1000 events.')
4468
+ .option('--follow', 'Keep running and print events as they are appended')
4469
+ .option('--watch', 'Same as --follow')
4470
+ .action(async (opts) => {
4471
+ const cwd = process.cwd();
4472
+ if (opts.global && opts.kit) {
4473
+ console.error('❌ --global and --kit are alternatives — pick one.');
4474
+ process.exit(1);
4475
+ }
4476
+ const globalLog = join(GLOBAL_KIT_DIR, MODELING_KIT.kitDirName);
4477
+ const kitDir = opts.global ? globalLog : opts.kit ? resolve(cwd, opts.kit) : requireBuildKitDir(cwd);
4478
+ if (opts.prune) {
4479
+ try {
4480
+ const { removed, kept } = pruneLog(kitDir);
4481
+ rememberPruneAnswer(kitDir, kept);
4482
+ console.log(removed ? `Pruned ${removed} events, ${kept} left.` : `Nothing to prune — ${kept} events, all of them carry state.`);
4483
+ } catch (err) {
4484
+ console.error(`❌ ${err.message}`);
4485
+ process.exit(1);
4486
+ }
4487
+ return;
4488
+ }
4489
+ let last;
4490
+ if (opts.last !== undefined) {
4491
+ last = Number(opts.last);
4492
+ if (!Number.isInteger(last) || last < 1) {
4493
+ console.error('❌ --last needs a positive number.');
4494
+ process.exit(1);
4495
+ }
4496
+ }
4497
+ const filter = { types: opts.type?.split(',').map((t) => t.trim()).filter(Boolean), slice: opts.slice, tag: opts.tag, board: opts.board };
4498
+ const color = process.stdout.isTTY && !process.env.NO_COLOR;
4499
+ const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
4500
+ 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');
4501
+ const print = (events, titles) => {
4502
+ for (const e of events) {
4503
+ if (opts.json) { console.log(JSON.stringify(e)); continue; }
4504
+ const [time, ...rest] = formatEvent(e, { titles }).split(' ');
4505
+ const type = rest.shift();
4506
+ console.log([paint('2', time), paint(family(e.type), type), ...rest].join(' '));
4507
+ if (opts.verbose) { const payload = formatPayload(e); if (payload) console.log(paint('2', payload)); }
4508
+ }
4509
+ };
4510
+
4511
+ const follow = opts.follow || opts.watch;
4512
+ const events = readEvents(kitDir);
4513
+ const titles = sliceTitles(events);
4514
+ if (opts.state) {
4515
+ if (follow) console.error('ℹ️ --follow/--watch does not apply to --state.');
4516
+ console.log(events.length ? summarize(fold(events), titles) : 'No events yet in this kit.');
4517
+ return;
4518
+ }
4519
+ if (!events.length && !follow) {
4520
+ console.log(`No events yet in ${opts.global ? '~/.eventmodelers/kit' : relative(cwd, kitDir) || kitDir}.`);
4521
+ // Runs started without a project install keep their log in the global kit — say so when it has one.
4522
+ if (!opts.global && !opts.kit && readEvents(globalLog).length) console.log('The global install has a log of its own: `eventmodelers events --global`.');
4523
+ return;
4524
+ }
4525
+ print(filterEvents(events, { ...filter, last }, titles), titles);
4526
+ if (!follow) return;
4527
+
4528
+ // Follow: what has been printed is remembered by event id, and the log is read again twice a second.
4529
+ const seen = new Set(events.map((e) => e.id));
4530
+ setInterval(() => {
4531
+ const all = readEvents(kitDir);
4532
+ const fresh = all.filter((e) => !seen.has(e.id));
4533
+ for (const e of fresh) seen.add(e.id);
4534
+ if (fresh.length) print(filterEvents(fresh, filter, sliceTitles(all)), sliceTitles(all));
4535
+ }, 500);
4536
+ });
4537
+
4538
+ program
4539
+ .command('replay <id>')
4540
+ .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.")
4541
+ .option('--kit <dir>', "Read this kit directory's log (default: the build kit of the current project, else its modeling kit)")
4542
+ .option('--global', 'Read the log of the global install (~/.eventmodelers/kit)')
4543
+ .option('--dry-run', 'Show the request that would be sent and stop — nothing is submitted')
4544
+ .option('--token <uuid>', 'API token (overrides config file / env var)')
4545
+ .option('--organization-id <uuid>', 'Organization ID (overrides config file / env var)')
4546
+ .option('--base-url <url>', 'Platform base URL (overrides config file / env var)')
4547
+ .action(async (id, opts) => {
4548
+ const cwd = process.cwd();
4549
+ if (opts.global && opts.kit) {
4550
+ console.error('❌ --global and --kit are alternatives — pick one.');
4551
+ process.exit(1);
4552
+ }
4553
+ const kitDir = opts.global ? join(GLOBAL_KIT_DIR, MODELING_KIT.kitDirName) : opts.kit ? resolve(cwd, opts.kit) : requireBuildKitDir(cwd);
4554
+ try {
4555
+ const event = findPromptEvent(readEvents(kitDir), id);
4556
+ const boardId = event.prompt.board_id ?? event.meta?.board_id;
4557
+ // The credentials of the board the prompt was for: flags and env vars over the board's own file over the account's.
4558
+ const stored = boardId ? readJsonSafe(boardCredentialsPath(boardId)) : {};
4559
+ const explicit = Object.fromEntries(Object.entries(credentialOverridesFromOpts(opts)).filter(([, v]) => v));
4560
+ const cfg = applyEnvOverrides({ ...loadEffectiveConfig(cwd, null, null).config, ...(stored.useGlobal || stored.useBoard ? {} : stored), ...explicit });
4561
+ cfg.baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
4562
+ if (!cfg.token || !cfg.organizationId) throw new Error('no credentials for this board — pass --token/--organization-id, or run the agent once for it');
4563
+
4564
+ const tokenRes = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`, { headers: { 'x-token': cfg.token } });
4565
+ if (!tokenRes.ok) throw new Error(`realtime-token: HTTP ${tokenRes.status}`);
4566
+ 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 } } });
4567
+ if (opts.dryRun) {
4568
+ console.log(`POST ${url}\n${JSON.stringify(body, null, 2)}`);
4569
+ return;
4570
+ }
4571
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
4572
+ if (!res.ok) throw new Error(`prompts: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
4573
+ const created = await res.json();
4574
+ 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}"`);
4575
+ } catch (err) {
4576
+ console.error(`❌ ${err.message}`);
4577
+ process.exit(1);
4578
+ }
4579
+ });
4580
+
4581
+ program
4582
+ .command('learnings')
4583
+ .description('Print the recorded learnings — what .build-kit/AGENTS.md says. With --ids, one line each with the id to retract it by.')
4584
+ .option('--ids', 'One line per learning: id, topic, text')
4585
+ .action((opts, command) => {
4586
+ const cwd = process.cwd();
4587
+ const kitDir = requireBuildKitDir(cwd);
4588
+ syncLearnings({ kitDir, meta: agentEventMeta(cwd, kitDir, command) });
4589
+ const all = listLearnings(kitDir);
4590
+ if (!all.length) {
4591
+ console.log('No learnings recorded yet.');
4592
+ return;
4593
+ }
4594
+ if (!opts.ids) {
4595
+ console.log(readFileSync(join(kitDir, 'AGENTS.md'), 'utf-8').trimEnd());
4596
+ return;
4597
+ }
4598
+ for (const l of all) console.log(` ${l.id.slice(0, 8)} [${l.topic ?? 'General'}] ${l.text}`);
4599
+ });
4600
+
4363
4601
  program
4364
4602
  .command('stacks')
4365
4603
  .description('List available stacks (for `init --stack`)')
@@ -4455,16 +4693,14 @@ program
4455
4693
  .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
4694
  .option('--build-kit', `Remove ${STACKS.node.kitDirName}/ (the backend-stack kit dir)`)
4457
4695
  .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
4696
  .action((opts) => {
4460
4697
  const cwd = process.cwd();
4461
4698
  let targets;
4462
4699
 
4463
- if (opts.buildKit || opts.modelingKit || opts.bridgeKit) {
4700
+ if (opts.buildKit || opts.modelingKit) {
4464
4701
  targets = [];
4465
4702
  if (opts.buildKit) targets.push(join(cwd, STACKS.node.kitDirName));
4466
4703
  if (opts.modelingKit) targets.push(join(cwd, MODELING_KIT.kitDirName));
4467
- if (opts.bridgeKit) targets.push(join(cwd, BRIDGE_KIT.kitDirName));
4468
4704
  targets = targets.filter((p) => existsSync(p));
4469
4705
  if (!targets.length) {
4470
4706
  console.log('ℹ️ Nothing to remove for the requested option(s).');
@@ -4477,7 +4713,7 @@ program
4477
4713
  return;
4478
4714
  }
4479
4715
  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.');
4716
+ console.log('⚠️ Multiple kit dirs found — re-run with --build-kit and/or --modeling-kit to pick which to remove.');
4481
4717
  targets.forEach((t) => console.log(` ${t}`));
4482
4718
  return;
4483
4719
  }