@eventmodelers/cli 1.0.90 → 1.0.92

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 (60) hide show
  1. package/README.md +2 -0
  2. package/RELEASE_NOTES.md +3 -0
  3. package/cli.js +308 -23
  4. package/lib/update-check.js +67 -0
  5. package/package.json +4 -1
  6. package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +11 -3
  7. package/shared/build-kit/lib/adapters/realtime-adapter.js +3 -1
  8. package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +14 -3
  9. package/shared/build-kit/lib/ralph.js +136 -59
  10. package/shared/build-kit/lib/redact.js +36 -0
  11. package/shared/build-kit/lib/turn.js +56 -20
  12. package/shared/build-kit/ralph-claude.js +7 -19
  13. package/shared/build-kit/ralph-exec.js +5 -9
  14. package/shared/build-kit/realtime-agent.js +4 -2
  15. package/shared/skills/connect/SKILL.md +14 -9
  16. package/shared/skills/learn-eventmodelers-api/SKILL.md +61 -24
  17. package/shared/skills/load-slice/SKILL.md +3 -1
  18. package/stacks/modeling-kit/templates/.claude/skills/add-next-slice/SKILL.md +4 -4
  19. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +18 -9
  20. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/references/api-fallback.md +31 -11
  21. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +16 -13
  22. package/stacks/modeling-kit/templates/.claude/skills/attributes/references/api-fallback.md +3 -1
  23. package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/SKILL.md +6 -6
  24. package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/references/api-fallback.md +3 -1
  25. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/SKILL.md +4 -4
  26. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/references/api-fallback.md +4 -4
  27. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +8 -12
  28. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/references/api-fallback.md +16 -6
  29. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +3 -3
  30. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/references/api-fallback.md +1 -1
  31. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/SKILL.md +2 -2
  32. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/references/api-fallback.md +4 -4
  33. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +4 -4
  34. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/references/api-fallback.md +4 -4
  35. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +3 -3
  36. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/references/api-fallback.md +3 -1
  37. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/SKILL.md +2 -2
  38. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/references/api-fallback.md +1 -1
  39. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +3 -3
  40. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/references/api-fallback.md +12 -4
  41. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +7 -6
  42. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/references/api-fallback.md +4 -3
  43. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +22 -6
  44. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/references/api-fallback.md +12 -3
  45. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/SKILL.md +18 -6
  46. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/references/api-fallback.md +12 -3
  47. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +2 -2
  48. package/stacks/modeling-kit/templates/.claude/skills/examples/references/api-fallback.md +20 -14
  49. package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +12 -12
  50. package/stacks/modeling-kit/templates/.claude/skills/place-element/references/api-fallback.md +6 -4
  51. package/stacks/modeling-kit/templates/.claude/skills/storyboard/SKILL.md +4 -4
  52. package/stacks/modeling-kit/templates/.claude/skills/storyboard/references/api-fallback.md +2 -2
  53. package/stacks/modeling-kit/templates/.claude/skills/timeline/SKILL.md +10 -10
  54. package/stacks/modeling-kit/templates/.claude/skills/timeline/references/api-fallback.md +4 -4
  55. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +11 -24
  56. package/stacks/modeling-kit/templates/kit/AGENTS.md +3 -3
  57. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +56 -10
  58. package/stacks/modeling-kit/templates/kit/CLAUDE.md +110 -44
  59. package/stacks/react/templates/build-kit/lib/backend-prompt.md +106 -102
  60. package/stacks/react/templates/build-kit/lib/prompt.md +102 -106
package/README.md CHANGED
@@ -635,6 +635,8 @@ npx @eventmodelers/cli re-init --stack supabase # override which stack to r
635
635
 
636
636
  `re-init` re-runs `init` against whichever stack `install-manifest.json` says was installed (no need to pass `--stack` again), but skips step 2 of `init` entirely — the root project scaffold (`package.json`, `src/`, `server.ts`, `docker-compose.yml`, etc.) and the root `CLAUDE.md` router are never touched. Use it after upgrading the CLI to pick up fixes to `ralph.js`/`ralph.sh`/skills without re-scaffolding a project you've since built on top of.
637
637
 
638
+ You don't have to remember to: on startup every command prints a 💡 hint (on stderr) when a newer CLI is on npm, or when a kit in the current directory was installed by an older CLI than the one running — naming the exact `re-init` command to run. The npm lookup happens at most once a day (cached in `~/.eventmodelers/update-check.json`, 1.5 s timeout, silent when offline). Set `EVENTMODELERS_NO_UPDATE_CHECK=1` to turn it off; it is also off when `CI` is set.
639
+
638
640
  Pass `--stack <name>` to override the stack instead of relying on the manifest — useful if the manifest is missing/stale, or you want to point a `.build-kit` install at a different built-in stack's templates. It's mutually exclusive with `--modeling`.
639
641
 
640
642
  Credentials are left alone unless you pass `--force` — same rule `init` already follows when everything required is already configured. `--global` defaults to however skills were originally installed; pass it explicitly to move them.
package/RELEASE_NOTES.md CHANGED
@@ -9,6 +9,9 @@
9
9
  - The `anthropicBaseUrl` option no longer implies Ollama works behind it. That route keeps Claude Code and swaps only the endpoint, so the server has to speak Anthropic's own `/v1/messages`; Ollama does not, and pointing it at `localhost:11434` returns a 404. For Ollama, `--local-ai` is the supported route, and the README now says which of the two to reach for.
10
10
  - `LOCAL_AI_*` is documented for the first time — the `--local-ai` help text had been pointing at "the docs" for vars that appeared nowhere in the README.
11
11
 
12
+ ### Fixes
13
+ - Chat messages are limited to 1000 characters, in both directions — the platform now rejects longer ones (`CHAT_TEXT_TOO_LONG`). The kit instructions say so, and the CLI's fallback reply (a chat turn's final text, posted when the turn didn't reply itself) is cut at the limit instead of failing with a 400 and leaving the message unanswered.
14
+
12
15
  ## v1.0.72
13
16
 
14
17
  ### Features
package/cli.js CHANGED
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
21
21
  import { runFetch, FetchAuthError } from './lib/fetch.js';
22
22
  import { createModelingLocalAiRunner } from './lib/modeling-local-ai.js';
23
23
  import { run as runSpecKittyAdapter } from './lib/adapters/spec-kitty-adapter.js';
24
+ import { latestPublishedVersion, updateHints } from './lib/update-check.js';
24
25
  // Not a root-level adapter like spec-kitty-adapter.js above: this is the one canonical
25
26
  // copy that every useShared:true stack also gets copied into its installed kit (see
26
27
  // copyDirContents in installStack) for ralph.js to import standalone — see
@@ -1282,6 +1283,11 @@ async function configureCredentials({ config, configPath, targetDir, requiredFie
1282
1283
  async function refreshSharedRuntime({ targetDir, kitDir, globalOpts, opts }) {
1283
1284
  console.log(`đŸ“Ļ Refreshing the shared runtime in ${relative(targetDir, kitDir)}/ (stack files untouched)...`);
1284
1285
  copyDirContents(join(__dirname, 'shared', 'build-kit'), kitDir);
1286
+ // The kit is now this CLI's runtime — record that, so the startup "kit is outdated" hint stops.
1287
+ const manifestPath = join(kitDir, '.eventmodelers', 'install-manifest.json');
1288
+ if (existsSync(manifestPath)) {
1289
+ writeFileSync(manifestPath, JSON.stringify({ ...readJsonSafe(manifestPath), version: CLI_VERSION }, null, 2));
1290
+ }
1285
1291
  for (const script of ['ralph.sh', 'lib/agent.sh', 'ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1286
1292
  const p = join(kitDir, script);
1287
1293
  if (existsSync(p)) {
@@ -1861,7 +1867,9 @@ async function ensureGlobalKit(baseUrl) {
1861
1867
  // untargeted is handed straight back to the queue for another agent to take. It says
1862
1868
  // nothing about the standalone lane — a self-directed turn is nobody's task, so an
1863
1869
  // exclusive standalone agent still works the board on its own initiative.
1864
- async function runModeling(kitDir, projectDir, { verbose = false, standalone = false, exclusive = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS, identity = {}, localAi = null } = {}) {
1870
+ async function runModeling(kitDir, projectDir, { verbose = false, standalone = false, exclusive = false, worker = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS, identity = {}, localAi = null } = {}) {
1871
+ // --worker: only prompts — no chat, and no board-change turns (it has nowhere to propose).
1872
+ const chat = !worker;
1865
1873
  const configLibPath = join(kitDir, 'lib', 'config.js');
1866
1874
  if (!existsSync(configLibPath)) {
1867
1875
  console.error(`❌ ${relative(process.cwd(), configLibPath)} not found — --modeling needs a kit installed via \`init --modeling\`.`);
@@ -1931,7 +1939,10 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
1931
1939
  'IMPORTANT: You are running autonomously — no human is available to answer questions. ' +
1932
1940
  'If you need clarification to proceed, do NOT pause or ask interactively. Instead, post your question ' +
1933
1941
  'as a QUESTION-type comment (via /handle-comment with action=place and type=QUESTION) on the most ' +
1934
- 'relevant slice or column node on the board, then continue with your best interpretation of the prompt.\n\n';
1942
+ 'relevant slice or column node on the board, then continue with your best interpretation of the prompt. ' +
1943
+ 'The one exception is a CHAT turn: there a person is waiting in the chat, so you ask back with post_chat_message ' +
1944
+ '(see "Chat turns" in .agent-modeling-kit/CLAUDE.md), and never by pausing the session. ' +
1945
+ 'A chat message is at most 1000 characters.\n\n';
1935
1946
 
1936
1947
  // Sent once, on the first turn only — it's what tells the agent to follow
1937
1948
  // .agent-modeling-kit/CLAUDE.md's per-turn steps for this warm session (instead
@@ -1943,13 +1954,16 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
1943
1954
  // board-change turn can just as well be the first turn a (re)spawned process
1944
1955
  // ever sees, so both turn builders go through this rather than buildTurn owning it.
1945
1956
  function withSessionHeader(body) {
1957
+ // token= names the env var, never its value: claudeEnv already carries EVENTMODELERS_TOKEN,
1958
+ // and a literal token here would sit in every session transcript (the connect skill treats
1959
+ // the reference as an inline token — see its Step 0).
1946
1960
  // Nothing to prepend for a local model: it has no CLAUDE.md to read and no /connect to run
1947
1961
  // (its tools are already authenticated), and handing it the raw token would put credentials
1948
1962
  // in a context that has no way to use them.
1949
1963
  if (localRunner) return body;
1950
1964
  if (!firstTurn) return body;
1951
1965
  firstTurn = false;
1952
- return `MODE=modeling token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''} subagent_model=${subagentModel}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
1966
+ return `MODE=modeling token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''} subagent_model=${subagentModel}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
1953
1967
  }
1954
1968
 
1955
1969
  function buildTurn(p) {
@@ -1960,13 +1974,15 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
1960
1974
  p.timeline_id ? `timeline_id=${p.timeline_id}` : null,
1961
1975
  p.comment_id ? `comment_id=${p.comment_id}` : null,
1962
1976
  p.node_id ? `node_id=${p.node_id}` : null,
1977
+ // Work the agent created out of a chat message: where to report back when it's done.
1978
+ p.origin_session_id ? `origin_session_id=${p.origin_session_id}` : null,
1979
+ p.origin_message_id ? `origin_message_id=${p.origin_message_id}` : null,
1963
1980
  ].filter(Boolean).join(' ');
1964
1981
  // What the user had selected and on screen when they submitted (selectedCell,
1965
1982
  // selectedNodes, timelineId, focusArea). CLAUDE.md's step 3 resolves CELL_ID/NODE_ID/
1966
- // TIMELINE_ID from it in preference to the flat fields above, and a canvas "poke" —
1967
- // whose prompt text is the bare word `Focus` — is nothing BUT this context: drop it and
1968
- // the turn says "Focus" and names nowhere to look. Sent as JSON on its own line because
1969
- // it is structured, unlike the flat k=v fields.
1983
+ // TIMELINE_ID from it in preference to the flat fields above, and it is what "this" or
1984
+ // "here" in the prompt refers to. Sent as JSON on its own line because it is structured,
1985
+ // unlike the flat k=v fields.
1970
1986
  const context = p.context && typeof p.context === 'object' && Object.keys(p.context).length
1971
1987
  ? `\ncontext=${JSON.stringify(p.context)}`
1972
1988
  : '';
@@ -1987,6 +2003,21 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
1987
2003
  let proc = null;
1988
2004
  let stdoutBuffer = '';
1989
2005
  let pending = null; // one in-flight turn at a time
2006
+ // The last message of each session this process has handed to Claude. A session the process has
2007
+ // never seen is read whole; after that only what came since — each message enters the warm
2008
+ // session's context once. Cleared whenever the process is (re)spawned: a fresh process has read
2009
+ // nothing.
2010
+ const chatCursor = new Map();
2011
+ // --modeling: the people this agent has taken a chat message from — the only ones whose board
2012
+ // edits it proposes on. Everyone else's edits cost no turn: an agent one person started must not
2013
+ // spend their money reacting to the whole board, nor write to people who never talked to it. Kept
2014
+ // across respawns (it is about people, not the Claude process), lost when the CLI restarts.
2015
+ const chatPartners = new Set();
2016
+ // When each partner was last sent a proposal. The backend lets an unanswered proposal stand for
2017
+ // UNPROMPTED_COOLDOWN_MS before another; waiting here keeps a turn from being spent on one it
2018
+ // would refuse. Cleared when the person writes in the chat.
2019
+ const lastProposalAt = new Map();
2020
+
1990
2021
  let lastTurnEndedAt = 0; // when the last turn finished — the standalone lane's echo window (see below)
1991
2022
  let warmUp = null; // this process's session warm-up turn (see warmUpSession) — null until one is started
1992
2023
  let warmingUp = false; // the in-flight turn is the warm-up: it only reads, so its writes can't echo
@@ -2056,6 +2087,7 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2056
2087
  proc = spawn('claude', claudeArgs, { cwd: projectDir, env: claudeEnv, stdio: ['pipe', 'pipe', 'inherit'] });
2057
2088
  stdoutBuffer = '';
2058
2089
  warmUp = null; // a fresh process has connected to nothing and read nothing
2090
+ chatCursor.clear(); // â€Ļand has read no chat session either
2059
2091
  proc.stdout.on('data', (chunk) => {
2060
2092
  stdoutBuffer += chunk.toString();
2061
2093
  const lines = stdoutBuffer.split('\n');
@@ -2154,8 +2186,11 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2154
2186
  log(
2155
2187
  standalone
2156
2188
  ? `standalone: ON — reacting to direct prompts AND to board changes on its own initiative (${localRunner ? 'work is done inline — a local model has no subagents' : `max ${maxAgents} subagent(s) per self-directed turn`})`
2157
- : 'standalone: off — reacting to direct prompts only (board changes are dropped)',
2189
+ : worker
2190
+ ? 'worker: ON — working prompts only (no chat, board changes are dropped)'
2191
+ : 'standalone: off — board changes get a proposal in the chat, nothing is changed without a yes',
2158
2192
  );
2193
+ log(chat ? 'chat: ON — answering chat messages addressed to this agent' : 'chat: off (--worker)');
2159
2194
  if (exclusive) {
2160
2195
  log(`exclusive: ON — only prompts addressed to ${cfg.agentId} are worked; every untargeted prompt is handed back to the queue`);
2161
2196
  // A global/standalone run mints its id per run (see resolveModelingCredentials), so an id
@@ -2198,6 +2233,92 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2198
2233
  if (!res.ok) throw new Error(`prompts/${promptId}/status: HTTP ${res.status}`);
2199
2234
  }
2200
2235
 
2236
+ // ── Chat ───────────────────────────────────────────────────────────────────
2237
+ //
2238
+ // Chat messages are conversation, not work: a person talks to this agent in the board's chat
2239
+ // panel, and each message addressed to it is claimed here (`chat/next` flips it to picked_up)
2240
+ // and handed to Claude as a CHAT turn. Claude decides — answer, ask back, or create prompts for
2241
+ // the work — and replies with post_chat_message; any prompts it creates are addressed to this
2242
+ // agent and worked by the same drain pass right after. Off with --worker.
2243
+ async function fetchNextChatMessage(jwtToken) {
2244
+ try {
2245
+ const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/chat/next?board_id=${encodeURIComponent(cfg.boardId)}`, {
2246
+ headers: { 'x-token': cfg.token, Authorization: `Bearer ${jwtToken}`, ...agentHeaders(cfg) },
2247
+ });
2248
+ // 404 is "nothing waiting" — and also what a backend without chat answers, which is fine.
2249
+ if (res.status === 404) return null;
2250
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2251
+ return res.json();
2252
+ } catch (err) {
2253
+ // Never let chat take the prompt queue down with it: log, skip chat for this pass.
2254
+ log(`chat/next failed, skipping chat this pass: ${err.message}`);
2255
+ return null;
2256
+ }
2257
+ }
2258
+
2259
+ function buildChatTurn(m) {
2260
+ const after = chatCursor.get(m.sessionId);
2261
+ const fields = [
2262
+ `CHAT message_id=${m.id}`,
2263
+ `session_id=${m.sessionId}`,
2264
+ `board_id=${cfg.boardId}`,
2265
+ `organization_id=${cfg.organizationId}`,
2266
+ after ? `after_message_id=${after}` : 'first_read=true',
2267
+ m.nodeId ? `node_id=${m.nodeId}` : null,
2268
+ m.commentId ? `comment_id=${m.commentId}` : null,
2269
+ ].filter(Boolean).join(' ');
2270
+ const context = m.context && typeof m.context === 'object' && Object.keys(m.context).length
2271
+ ? `\ncontext=${JSON.stringify(m.context)}`
2272
+ : '';
2273
+ return withSessionHeader(`${fields}${context}\n\n${m.text}`);
2274
+ }
2275
+
2276
+ // The backend rejects a chat message over this many characters (CHAT_TEXT_TOO_LONG).
2277
+ const CHAT_TEXT_MAX_LENGTH = 1000;
2278
+
2279
+ // Posted only if the turn didn't answer — the backend checks (only_if_unanswered), so a reply
2280
+ // the agent did post is never doubled. What makes "every message gets an answer" hold even for a
2281
+ // turn that errored or forgot its post_chat_message.
2282
+ async function replyIfUnanswered(m, text) {
2283
+ try {
2284
+ const res = await fetch(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${encodeURIComponent(cfg.boardId)}/chat/agent-messages`, {
2285
+ method: 'POST',
2286
+ headers: { 'x-token': cfg.token, 'Content-Type': 'application/json', ...agentHeaders(cfg) },
2287
+ // A turn's final text can run long; cut it rather than lose the reply to a 400.
2288
+ body: JSON.stringify({
2289
+ text: text.length > CHAT_TEXT_MAX_LENGTH ? `${text.slice(0, CHAT_TEXT_MAX_LENGTH - 1)}â€Ļ` : text,
2290
+ reply_to: m.id,
2291
+ only_if_unanswered: true,
2292
+ }),
2293
+ });
2294
+ if (!res.ok) log(`chat fallback reply failed: HTTP ${res.status}`);
2295
+ else if (res.status === 201) log(`chat turn left message ${m.id} unanswered — posted its final text as the reply`);
2296
+ } catch (err) {
2297
+ log(`chat fallback reply failed: ${err.message}`);
2298
+ }
2299
+ }
2300
+
2301
+ async function runChatTurn(m) {
2302
+ log(`chat message received: "${oneLine(m.text, 80)}" (session=${m.sessionId})`);
2303
+ if (m.userId) {
2304
+ chatPartners.add(m.userId);
2305
+ lastProposalAt.delete(m.userId);
2306
+ }
2307
+ let result = '';
2308
+ let failed = false;
2309
+ try {
2310
+ result = await runTurn(buildChatTurn(m));
2311
+ } catch (err) {
2312
+ failed = true;
2313
+ log(`chat turn failed: ${err.message}`);
2314
+ }
2315
+ chatCursor.set(m.sessionId, m.id);
2316
+ const text = String(result ?? '').replace(/<promise>[^<]*<\/promise>/g, '').trim();
2317
+ await replyIfUnanswered(m, text && !failed ? text : 'Sorry — I could not finish that just now. Please try again.');
2318
+ // Edits they made while their last proposal waited for this answer get their turn now.
2319
+ if (m.userId && proposalBuffer.has(m.userId)) armProposalTurn(STANDALONE_DEBOUNCE_MS);
2320
+ }
2321
+
2201
2322
  let realtimeToken = await getRealtimeToken();
2202
2323
 
2203
2324
  let draining = false;
@@ -2208,8 +2329,16 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2208
2329
  // handed back once the pass is over (see below).
2209
2330
  const handBack = [];
2210
2331
  try {
2211
- let p;
2212
- while ((p = await fetchNextPrompt(realtimeToken)) !== null) {
2332
+ for (;;) {
2333
+ // A person waiting in the chat goes first; a prompt the chat turn created is picked up
2334
+ // by the fetch below on this same pass.
2335
+ const m = chat ? await fetchNextChatMessage(realtimeToken) : null;
2336
+ if (m) {
2337
+ await runChatTurn(m);
2338
+ continue;
2339
+ }
2340
+ const p = await fetchNextPrompt(realtimeToken);
2341
+ if (p === null) break;
2213
2342
  // The queue can't filter by addressee for us: `prompts/next` hands an agent both the
2214
2343
  // prompts addressed to it and every untargeted one (`agent_id IS NULL`) — claiming is
2215
2344
  // what reveals which kind arrived — so an exclusive run claims as usual and gives back
@@ -2323,8 +2452,18 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2323
2452
  // session rather than by another agent. It is what lets a human's edit skip the
2324
2453
  // MIN_INTERVAL floor: a person cannot be this agent's feedback loop, whereas two agents on
2325
2454
  // one board can ping-pong, so another agent's write keeps waiting its turn.
2455
+ // Whether board changes get a turn at all, and what that turn may do. --standalone acts on its
2456
+ // own initiative, on everyone's edits. A plain --modeling agent only proposes, and only to the
2457
+ // people talking to it in the chat — about their own edits (the propose lane below). A --worker
2458
+ // takes neither: it only works prompts.
2459
+ const boardLane = standalone || chat;
2460
+ const proposeOnly = !standalone;
2461
+
2326
2462
  const observed = new Map();
2327
2463
  let observedCount = 0;
2464
+ // --standalone: the person behind the latest human edit in the buffer — who a blocking question
2465
+ // may be put to in the chat.
2466
+ let lastChangedBy = null;
2328
2467
  let seqLo = null;
2329
2468
  let seqHi = null;
2330
2469
  let standaloneTimer = null;
@@ -2347,11 +2486,12 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2347
2486
  seqLo = null;
2348
2487
  seqHi = null;
2349
2488
  firstObservedAt = 0;
2489
+ lastChangedBy = null;
2350
2490
  }
2351
2491
 
2352
2492
  function onBoardEvent(type, payload) {
2353
- if (!standalone) {
2354
- if (verbose) log(`board event ${type} dropped — not running with --standalone`);
2493
+ if (!boardLane) {
2494
+ if (verbose) log(`board event ${type} dropped — a --worker only works prompts`);
2355
2495
  return;
2356
2496
  }
2357
2497
  const sinceTurn = Date.now() - lastTurnEndedAt;
@@ -2366,6 +2506,10 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2366
2506
  // editing while this agent worked had their change written off as an echo of it.
2367
2507
  const writerAgent = payload?.agent_id || null;
2368
2508
  const writerUser = payload?.user_id || null;
2509
+ if (proposeOnly) {
2510
+ onProposableEvent(type, payload, writerUser, writerAgent);
2511
+ return;
2512
+ }
2369
2513
  const origin =
2370
2514
  writerAgent && cfg.agentId && writerAgent === cfg.agentId
2371
2515
  ? 'own'
@@ -2380,7 +2524,10 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2380
2524
  entry.count += 1;
2381
2525
  entry[origin] += 1;
2382
2526
  // A browser session id and no agent id: a human at the canvas.
2383
- if (writerUser && !writerAgent) entry.person += 1;
2527
+ if (writerUser && !writerAgent) {
2528
+ entry.person += 1;
2529
+ lastChangedBy = writerUser;
2530
+ }
2384
2531
  observed.set(nodeId, entry);
2385
2532
  observedCount += 1;
2386
2533
  if (!firstObservedAt) firstObservedAt = Date.now();
@@ -2474,6 +2621,111 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2474
2621
  `actually needs doing, and then ${FAN_OUT} If the model genuinely needs nothing right now, spawn ` +
2475
2622
  'nothing, change nothing and reply <promise>NOOP</promise>.';
2476
2623
 
2624
+ // --modeling: the same judgement as a standalone turn, but it changes nothing — it puts what it
2625
+ // would do to the person in the chat and waits. A "yes" comes back as a CHAT turn, which is where
2626
+ // the work is actually created (as prompts).
2627
+ const PROPOSE_TASK =
2628
+ 'Nobody asked you for this — the board just changed, and you are a modeling agent that proposes rather ' +
2629
+ 'than acts: you may NOT change the board in this turn, and you dispatch no Agents. The change list above ' +
2630
+ 'is a notification, not the task. Judge the changed areas in their context (slice, chain, timeline) the ' +
2631
+ 'way .agent-modeling-kit/CLAUDE-STANDALONE.md describes — read it now if you have not this session, and ' +
2632
+ 'follow its "Propose mode" section. Read what this needs and no more: every nodeId above in one get_nodes, ' +
2633
+ 'plus one get_board_outline per chapter you have not read this session. Every change above is by one person, ' +
2634
+ 'changed_by, who is talking to you in the chat. ALWAYS post ONE short chat message to them with ' +
2635
+ 'post_chat_message (no sessionId, no replyTo, preferUserId = changed_by; at most 1000 characters) — never stay silent after their ' +
2636
+ 'edit. A fresh, half-finished element (placeholder name, no ' +
2637
+ 'fields, names still changing) is the reason to speak, not to wait. Something worth doing → say concretely ' +
2638
+ 'what you would do (element names) and ask whether to go ahead. Nothing concrete → say what you saw and ' +
2639
+ 'that you are not sure what to do with it, and ask ("You renamed Orders to take — not sure what to do with ' +
2640
+ 'it yet. Want fields on it?"). Fold anything your earlier unanswered message proposed into this one instead ' +
2641
+ 'of repeating it. One to three sentences. Only CHAT_NO_ACTIVE_SESSION (they cleared the conversation or ' +
2642
+ 'switched to another agent) or CHAT_AWAITING_REPLY (you wrote to them less than 3 minutes ago) keep you ' +
2643
+ 'silent: then change nothing and reply <promise>NOOP</promise>.';
2644
+
2645
+ // ── Propose lane (--modeling) ─────────────────────────────────────────────
2646
+ //
2647
+ // Per person: what each chat partner changed since their last proposal. Each gets their own turn
2648
+ // and their own proposal, about their own edits only — nobody hears about someone else's work.
2649
+ const PROPOSAL_COOLDOWN_MS = 3 * 60_000; // the backend's UNPROMPTED_COOLDOWN_MS
2650
+ const proposalBuffer = new Map(); // userId -> Map(nodeId -> { types: Set<string>, count })
2651
+ let proposalFirstAt = 0; // start of the current burst — MAX_WAIT is measured from here
2652
+ let proposalTimer = null;
2653
+
2654
+ function onProposableEvent(type, payload, writerUser, writerAgent) {
2655
+ const nodeId = payload?.node_id ?? '(board)';
2656
+ // Only a person at the canvas who is talking to this agent. Agents' writes (this one's
2657
+ // included) and unattributed ones name nobody to propose to.
2658
+ if (!writerUser || writerAgent || !chatPartners.has(writerUser)) {
2659
+ if (verbose) log(`board change: ${type} node=${nodeId} — ignored, not by a person chatting with this agent`);
2660
+ return;
2661
+ }
2662
+ const nodes = proposalBuffer.get(writerUser) ?? new Map();
2663
+ const entry = nodes.get(nodeId) ?? { types: new Set(), count: 0 };
2664
+ entry.types.add(type);
2665
+ entry.count += 1;
2666
+ nodes.set(nodeId, entry);
2667
+ proposalBuffer.set(writerUser, nodes);
2668
+ if (!proposalFirstAt) proposalFirstAt = Date.now();
2669
+ log(`board change: ${type} node=${nodeId} — by a chat partner`);
2670
+ armProposalTurn(Math.max(0, Math.min(STANDALONE_DEBOUNCE_MS, STANDALONE_MAX_WAIT_MS - (Date.now() - proposalFirstAt))));
2671
+ }
2672
+
2673
+ function armProposalTurn(delayMs) {
2674
+ if (proposalTimer) clearTimeout(proposalTimer);
2675
+ proposalTimer = setTimeout(() => {
2676
+ proposalTimer = null;
2677
+ dispatchProposals().catch((err) => log(`proposal dispatch error: ${err.message}`));
2678
+ }, delayMs);
2679
+ }
2680
+
2681
+ function buildProposalTurn(userId, nodes) {
2682
+ const lines = [...nodes.entries()].map(([nodeId, entry]) => `- ${nodeId}: ${[...entry.types].join(', ')} (${entry.count}×)`);
2683
+ const events = [...nodes.values()].reduce((sum, entry) => sum + entry.count, 0);
2684
+ const header = [
2685
+ 'BOARD_CHANGE',
2686
+ 'mode=propose',
2687
+ `changed_by=${userId}`,
2688
+ `board_id=${cfg.boardId}`,
2689
+ `organization_id=${cfg.organizationId}`,
2690
+ `events=${events}`,
2691
+ `nodes=${nodes.size}`,
2692
+ ].join(' ');
2693
+ return withSessionHeader(`${header}\nchanged:\n${lines.join('\n')}\n\n${PROPOSE_TASK}`);
2694
+ }
2695
+
2696
+ async function dispatchProposals() {
2697
+ // A direct message always outranks the agent's own initiative.
2698
+ if (pending || draining) {
2699
+ armProposalTurn(STANDALONE_DEBOUNCE_MS);
2700
+ return;
2701
+ }
2702
+ const now = Date.now();
2703
+ const due = [];
2704
+ let waitMs = Infinity;
2705
+ for (const [userId, nodes] of proposalBuffer) {
2706
+ // Their last proposal is still unanswered: keep collecting, fold it all into the next one.
2707
+ const left = (lastProposalAt.get(userId) ?? 0) + PROPOSAL_COOLDOWN_MS - now;
2708
+ if (left > 0) {
2709
+ waitMs = Math.min(waitMs, left);
2710
+ continue;
2711
+ }
2712
+ due.push([userId, nodes]);
2713
+ proposalBuffer.delete(userId);
2714
+ }
2715
+ if (!proposalBuffer.size) proposalFirstAt = 0;
2716
+ for (const [userId, nodes] of due) {
2717
+ log(`propose turn: ${nodes.size} node(s) changed by ${userId.slice(0, 8)}`);
2718
+ lastProposalAt.set(userId, Date.now());
2719
+ try {
2720
+ await runTurn(buildProposalTurn(userId, nodes));
2721
+ } catch (err) {
2722
+ log(`propose turn failed: ${err.message}`);
2723
+ }
2724
+ }
2725
+ // Still waiting out a cooldown, or edits that arrived while these turns ran.
2726
+ if (proposalBuffer.size) armProposalTurn(Number.isFinite(waitMs) && !due.length ? waitMs : STANDALONE_DEBOUNCE_MS);
2727
+ }
2728
+
2477
2729
  function buildStandaloneTurn() {
2478
2730
  const lines = [...observed.entries()].map(([nodeId, entry]) => {
2479
2731
  const origin =
@@ -2488,6 +2740,8 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2488
2740
  });
2489
2741
  const header = [
2490
2742
  'BOARD_CHANGE',
2743
+ 'mode=act',
2744
+ lastChangedBy ? `changed_by=${lastChangedBy}` : null,
2491
2745
  `board_id=${cfg.boardId}`,
2492
2746
  `organization_id=${cfg.organizationId}`,
2493
2747
  seqLo !== null ? `seq=${seqLo}${seqHi !== seqLo ? `..${seqHi}` : ''}` : null,
@@ -2650,6 +2904,12 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2650
2904
  'prompt:created': () => {
2651
2905
  drain().catch((err) => log(`drain error: ${err.message}`));
2652
2906
  },
2907
+ // Sent for every chat message on the org; only one addressed to this agent on this board
2908
+ // is worth a drain pass.
2909
+ 'chat:created': (payload) => {
2910
+ if (!chat || payload?.agent_id !== cfg.agentId || payload?.board_id !== cfg.boardId) return;
2911
+ drain().catch((err) => log(`drain error: ${err.message}`));
2912
+ },
2653
2913
  },
2654
2914
  (status) => {
2655
2915
  log(`channel "${channelName}": ${status}`);
@@ -2670,7 +2930,7 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2670
2930
  boardChannelName,
2671
2931
  Object.fromEntries(BOARD_CHANGE_EVENTS.map((event) => [event, (payload) => onBoardEvent(event, payload)])),
2672
2932
  (status) => {
2673
- log(`channel "${boardChannelName}": ${status}${standalone ? '' : ' (events dropped — no --standalone)'}`);
2933
+ log(`channel "${boardChannelName}": ${status}${boardLane ? '' : ' (events dropped — --worker)'}`);
2674
2934
  if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
2675
2935
  refreshRealtimeToken(status).catch(() => {});
2676
2936
  }
@@ -2688,7 +2948,10 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
2688
2948
  const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
2689
2949
  method: 'POST',
2690
2950
  headers: { Authorization: `Bearer ${realtimeToken}`, 'Content-Type': 'application/json' },
2691
- body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: 'MODELING', agent_id: cfg.agentId, ...(cfg.agentName ? { agent_name: cfg.agentName } : {}) }),
2951
+ // chat_enabled tells the board this agent answers chat messages (the panel only offers chat
2952
+ // with one that does); a --worker reports false, and older CLIs send nothing, which the
2953
+ // backend reads as false too.
2954
+ body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: 'MODELING', agent_id: cfg.agentId, chat_enabled: chat, ...(cfg.agentName ? { agent_name: cfg.agentName } : {}) }),
2692
2955
  signal: AbortSignal.timeout(10_000),
2693
2956
  });
2694
2957
  if (!res.ok) log(`ping failed: ${res.status} ${await res.text().catch(() => '')}`);
@@ -2705,7 +2968,7 @@ const program = new Command();
2705
2968
  program
2706
2969
  .name('eventmodelers')
2707
2970
  .description('Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack')
2708
- .version('1.0.0')
2971
+ .version(CLI_VERSION)
2709
2972
  .option('--config <path>', 'Path to an explicit config.json, overriding directory-based resolution (individual fields can also be set via EVENTMODELERS_* env vars, which always win)')
2710
2973
  .option('--print', 'Print follow-up commands (e.g. claude mcp add) instead of prompting to run them');
2711
2974
 
@@ -2725,6 +2988,21 @@ program
2725
2988
  // than this generic gate can.
2726
2989
  const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run']);
2727
2990
 
2991
+ // Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
2992
+ // front of them would only be noise.
2993
+ const NO_KIT_UPDATE_HINT = new Set(['init', 're-init', 'uninstall']);
2994
+
2995
+ // Startup hint when this CLI is behind npm, or an installed kit here is behind this CLI.
2996
+ // Best effort and on stderr, so it never breaks piped output; EVENTMODELERS_NO_UPDATE_CHECK
2997
+ // (or CI) turns it off.
2998
+ program.hook('preAction', async (_thisCommand, actionCommand) => {
2999
+ if (process.env.EVENTMODELERS_NO_UPDATE_CHECK || process.env.CI) return;
3000
+ const latestVersion = await latestPublishedVersion({ cachePath: join(GLOBAL_DIR, 'update-check.json') });
3001
+ const kitDirs = NO_KIT_UPDATE_HINT.has(actionCommand.name()) ? [] : findAllInstalledKitDirs(process.cwd());
3002
+ const hints = updateHints({ currentVersion: CLI_VERSION, latestVersion, kitDirs });
3003
+ if (hints.length) console.error(`${hints.join('\n')}\n`);
3004
+ });
3005
+
2728
3006
  program.hook('preAction', (_thisCommand, actionCommand) => {
2729
3007
  if (NO_INIT_REQUIRED.has(actionCommand.name())) return;
2730
3008
  if (findInstalledKitDir(process.cwd())) return;
@@ -3197,9 +3475,10 @@ credentialFlags(program
3197
3475
  .option('--exec [command]', 'Hand each prompt to an external agent command instead of the default Claude runner, via ralph-exec.js (build-kit stacks only) — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto" or "opencode run". 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. 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.')
3198
3476
  .option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
3199
3477
  .option('--modeling', 'Keep one Claude process warm across prompts instead of spawning a fresh one per task, for low-latency voice/live use. 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.')
3200
- .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/--global).')
3201
- .option('--standalone', 'Let the modeling agent work the board in the background, on its own initiative: on top of direct prompts it subscribes to the board\'s change channel (like the build agents do) and, whenever the board goes quiet after an edit — or has simply been idle for a while — it takes a turn nobody asked for. Changed nodes are a notification, not the task: it judges the model as a whole and fans the work out over parallel subagents, one per changed area (examples on a new node, specs for a new command or read model, a missing attribute along a chain, a screen, a question comment). Filling that detail in while the human keeps modeling is the point — it does not wait for the board to be finished. Implies --modeling.')
3478
+ .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).')
3479
+ .option('--standalone', 'Let the modeling agent act on board changes on its own initiative, without asking: it subscribes to the board\'s change channel and, whenever the board goes quiet after an edit — or has simply been idle for a while — it takes a turn nobody asked for, judges the model as a whole and fans the work out over parallel subagents, one per changed area (examples on a new node, specs for a new command or read model, a missing attribute along a chain, a screen, a question). Filling that detail in while the human keeps modeling is the point. A plain --modeling agent only proposes, and only to the people chatting with it: after their edits it says in the chat what it would do and waits for a yes; everyone else\'s edits are ignored. Implies --modeling.')
3202
3480
  .option('--max-agents <n>', 'Cap how many subagents a self-directed --standalone turn may dispatch at once, to bound what an unattended agent can spend per turn. The agent merges work that shares a slice or chain first, then takes the most valuable pieces up to this many and leaves the rest for a later turn. 1 makes it do the single most valuable piece itself, without spawning anything. Default 5. Ignored without --standalone — prompt turns are one piece of work by definition.', '5')
3481
+ .option('--worker', 'A modeling agent that only works prompts: it neither takes part in the board\'s chat (the heartbeat reports chat_enabled=false, so the chat panel does not offer it) nor reacts to board changes — it waits for prompts and does them. For a dedicated agent a supervisor or another agent feeds, often paired with --exclusive and --id. Implies --modeling; cannot be combined with --standalone.')
3203
3482
  .option('--exclusive', 'Work only the prompts addressed to this agent\'s id — the board\'s "preferred agent" (the star in the prompts panel) — and hand every untargeted prompt straight back to the queue for another agent to take. Without it an agent also works everything nobody addressed to anyone, which is what you want for a single agent and exactly what you do not want for a dedicated one (a board with a general agent plus a specialist, or an agent a supervisor drives by id). Pair it with --id so the same agent is addressable across restarts — --global/--standalone otherwise mint a fresh id per run, and prompts addressed to the previous run\'s id are never claimed. Leaves --standalone alone: a self-directed turn is nobody\'s prompt, so an exclusive standalone agent still works the board on its own initiative.')
3204
3483
  .option('--global', 'Run the modeling agent from the global install (~/.eventmodelers/kit), initializing it on first use, and ignore any kit in this directory. This is also what --modeling/--standalone fall back to on their own when nothing is installed here — pass it explicitly to prefer the global install over a local one. Credentials come from the flags below, EVENTMODELERS_* env vars, or ~/.eventmodelers/boards/<board>.json, so nothing is written into the current directory.')
3205
3484
  .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)')
@@ -3219,8 +3498,14 @@ credentialFlags(program
3219
3498
  }
3220
3499
  // The build-kit runners claim their work from the same queue but have no addressee
3221
3500
  // filter, so the flag would silently do nothing there rather than half of what it says.
3222
- if (opts.exclusive && !(opts.modeling || opts.standalone || opts.global)) {
3223
- console.log('â„šī¸ --exclusive only applies to the modeling loop (--modeling/--standalone/--global); ignoring it here.');
3501
+ if (opts.exclusive && !(opts.modeling || opts.standalone || opts.worker || opts.global)) {
3502
+ console.log('â„šī¸ --exclusive only applies to the modeling loop (--modeling/--standalone/--worker/--global); ignoring it here.');
3503
+ }
3504
+ // A worker is defined by what it doesn't do on its own — --standalone is exactly that, so the
3505
+ // two contradict each other rather than one quietly winning.
3506
+ if (opts.worker && opts.standalone) {
3507
+ console.error('❌ --worker and --standalone are mutually exclusive — a worker never acts on board changes.');
3508
+ process.exit(1);
3224
3509
  }
3225
3510
  // --id/--name are what the platform will see for this run, so a blank one is a
3226
3511
  // mistake worth failing on rather than silently falling back to the stored identity.
@@ -3262,8 +3547,8 @@ credentialFlags(program
3262
3547
  // was never a second thing it could have selected, and requiring both flags only made
3263
3548
  // the shorter, more obvious command fail. --global picks the modeling loop too — it has
3264
3549
  // no meaning for a build kit, which is scaffolded per project by definition.
3265
- if (opts.modeling || opts.standalone || opts.global) {
3266
- const picked = opts.modeling ? '--modeling' : opts.standalone ? '--standalone' : '--global';
3550
+ if (opts.modeling || opts.standalone || opts.worker || opts.global) {
3551
+ const picked = opts.modeling ? '--modeling' : opts.standalone ? '--standalone' : opts.worker ? '--worker' : '--global';
3267
3552
  // --bash/--exec stay build-kit only: they drive the cold-spawn tasks.json loop, which the
3268
3553
  // modeling loop has no equivalent of. --local-ai is different — it names a *model*, not a
3269
3554
  // queue, and the modeling loop has its own runner for one (lib/modeling-local-ai.js).
@@ -3313,7 +3598,7 @@ credentialFlags(program
3313
3598
  const runnerLabel = modelingLocalAi ? 'local model' : 'warm Claude process';
3314
3599
  await new Promise((res) => process.stdout.write(`â–ļ Starting modeling loop (${runnerLabel}) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
3315
3600
  try {
3316
- await runModeling(kitDir, projectDir, { verbose: !!opts.verbose, standalone: !!opts.standalone, exclusive: !!opts.exclusive, overrides, maxAgents, identity, localAi: modelingLocalAi });
3601
+ await runModeling(kitDir, projectDir, { verbose: !!opts.verbose, standalone: !!opts.standalone, exclusive: !!opts.exclusive, worker: !!opts.worker, overrides, maxAgents, identity, localAi: modelingLocalAi });
3317
3602
  } catch (err) {
3318
3603
  console.error('[modeling] Fatal:', err);
3319
3604
  process.exit(1);
@@ -0,0 +1,67 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { basename, dirname, join, relative } from 'path';
3
+
4
+ const REGISTRY_URL = 'https://registry.npmjs.org/@eventmodelers/cli/latest';
5
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
6
+ const FETCH_TIMEOUT_MS = 1500;
7
+
8
+ // Kit dirs `re-init` can refresh, and how to ask it to.
9
+ const REINIT_COMMANDS = {
10
+ '.build-kit': 'eventmodelers re-init',
11
+ '.agent-modeling-kit': 'eventmodelers re-init --modeling',
12
+ };
13
+
14
+ // Numeric compare of x.y.z versions — negative when a < b. Pre-release suffixes are ignored.
15
+ export function compareVersions(a, b) {
16
+ const pa = String(a).split('-')[0].split('.').map(Number);
17
+ const pb = String(b).split('-')[0].split('.').map(Number);
18
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
19
+ const d = (pa[i] || 0) - (pb[i] || 0);
20
+ if (d) return d;
21
+ }
22
+ return 0;
23
+ }
24
+
25
+ function readJson(path) {
26
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; }
27
+ }
28
+
29
+ // The latest published version, asked of the npm registry at most once a day — in between,
30
+ // the answer cached at cachePath is reused. Best effort: offline, slow or broken registry
31
+ // responses just yield whatever was cached last (or null).
32
+ export async function latestPublishedVersion({ cachePath, now = Date.now(), fetchImpl = fetch } = {}) {
33
+ const cache = readJson(cachePath);
34
+ if (cache.latest && now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS) return cache.latest;
35
+
36
+ try {
37
+ const res = await fetchImpl(REGISTRY_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
38
+ if (!res.ok) return cache.latest || null;
39
+ const { version } = await res.json();
40
+ if (!version) return cache.latest || null;
41
+ try {
42
+ mkdirSync(dirname(cachePath), { recursive: true });
43
+ writeFileSync(cachePath, JSON.stringify({ latest: version, checkedAt: now }));
44
+ } catch {}
45
+ return version;
46
+ } catch {
47
+ return cache.latest || null;
48
+ }
49
+ }
50
+
51
+ // Hint lines for everything that's behind: this CLI against npm, and each installed kit
52
+ // against the CLI running now (its install manifest records the version that wrote it).
53
+ export function updateHints({ currentVersion, latestVersion, kitDirs = [], cwd = process.cwd() }) {
54
+ const hints = [];
55
+ if (latestVersion && compareVersions(currentVersion, latestVersion) < 0) {
56
+ hints.push(`💡 A new eventmodelers CLI is available: ${currentVersion} → ${latestVersion}. Update with: npm i -g @eventmodelers/cli@latest (or use npx @eventmodelers/cli@latest)`);
57
+ }
58
+ for (const kitDir of kitDirs) {
59
+ const reinit = REINIT_COMMANDS[basename(kitDir)];
60
+ const manifestPath = join(kitDir, '.eventmodelers', 'install-manifest.json');
61
+ if (!reinit || !existsSync(manifestPath)) continue;
62
+ const kitVersion = readJson(manifestPath).version;
63
+ if (kitVersion && compareVersions(kitVersion, currentVersion) >= 0) continue;
64
+ hints.push(`💡 ${relative(cwd, kitDir) || kitDir}/ was installed by CLI ${kitVersion || '(unknown version)'} — refresh it to ${currentVersion} with: ${reinit}`);
65
+ }
66
+ return hints;
67
+ }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.90",
3
+ "version": "1.0.92",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "eventmodelers": "cli.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js"
11
+ },
9
12
  "files": [
10
13
  "cli.js",
11
14
  "lib",