@eventmodelers/cli 2.0.50 β†’ 2.0.53

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.
package/cli.js CHANGED
@@ -42,7 +42,7 @@ import { recordAgentStatus } from './shared/build-kit/lib/slice-run.js';
42
42
  import { findSliceDir, fingerprint, readBaseline, writeBaseline, compare } from './lib/ui-baseline.js';
43
43
  import { defaultAgentName, withInstanceSuffix } from './shared/build-kit/lib/agent-name.js';
44
44
  import { acquireAgentId } from './shared/build-kit/lib/agent-lock.js';
45
- import { readEvents } from './shared/build-kit/lib/slice-events.js';
45
+ import { readEvents, appendEvent, locationOf } from './shared/build-kit/lib/slice-events.js';
46
46
  import { findPromptEvent, replayRequest } from './shared/build-kit/lib/prompt-replay.js';
47
47
  import { prunableEvents, pruneLog, rememberPruneAnswer, shouldOfferPrune } from './shared/build-kit/lib/event-prune.js';
48
48
  import { fold } from './shared/build-kit/lib/slice-state.js';
@@ -893,7 +893,16 @@ function loadEffectiveConfig(cwd, kitDir, explicitPath) {
893
893
  const kitConfigExists = kitConfigPath && existsSync(kitConfigPath);
894
894
  const parentConfigPath = findConfigInParents(cwd);
895
895
 
896
- const merged = { ...readJsonSafe(parentConfigPath), ...(kitConfigExists ? readJsonSafe(kitConfigPath) : {}) };
896
+ // Credentials are inherited from a parent config, an agent identity never is: every project keeps its
897
+ // own `agentIds` (see ensureAgentId). Callers write this merged object back into the project's own
898
+ // config.json, so inheriting it would copy the parent's id into a nested project (an app and its `web/`
899
+ // UI kit) and both loops would then run as one agent and fight over each other's claims.
900
+ const parentConfig = readJsonSafe(parentConfigPath);
901
+ if (parentConfigPath && resolve(parentConfigPath) !== resolve(cwd, '.eventmodelers', 'config.json')) {
902
+ delete parentConfig.agentIds;
903
+ delete parentConfig.agentId;
904
+ }
905
+ const merged = { ...parentConfig, ...(kitConfigExists ? readJsonSafe(kitConfigPath) : {}) };
897
906
  const sources = [parentConfigPath, kitConfigExists ? kitConfigPath : null].filter(Boolean);
898
907
 
899
908
  return {
@@ -1051,15 +1060,28 @@ async function resolveStack(cliStack) {
1051
1060
  );
1052
1061
  }
1053
1062
 
1063
+ // Names what is installed now (from the kit's install manifest) and what this run installs.
1064
+ function printInstallVersions(kitDir, stackKey) {
1065
+ const prev = readJsonSafe(join(kitDir, '.eventmodelers', 'install-manifest.json'));
1066
+ if (prev.version) {
1067
+ const same = prev.version === CLI_VERSION;
1068
+ console.log(`Installed: v${prev.version}${prev.stack ? ` (${prev.stack})` : ''}`);
1069
+ console.log(`Installing: v${CLI_VERSION}${stackKey ? ` (${stackKey})` : ''}${same ? ' β€” same version, files are refreshed' : ''}\n`);
1070
+ } else {
1071
+ console.log(`Installed: nothing yet\nInstalling: v${CLI_VERSION}${stackKey ? ` (${stackKey})` : ''}\n`);
1072
+ }
1073
+ }
1074
+
1054
1075
  async function installStack(stackKey, stackCfg, options = {}) {
1055
- console.log('πŸš€ Eventmodelers CLI\n');
1056
- console.log(`Using: ${stackKey} (${stackCfg.label})\n`);
1076
+ console.log(`πŸš€ Eventmodelers CLI v${CLI_VERSION}\n`);
1077
+ console.log(`Using: ${stackKey} (${stackCfg.label})`);
1057
1078
 
1058
1079
  // Almost always the cwd. The exception is the global modeling install, which is
1059
1080
  // scaffolded into ~/.eventmodelers/kit from wherever `run --standalone` was invoked
1060
1081
  // (see ensureGlobalKit) β€” the mirror image of options.templatesSource below: where we
1061
1082
  // install TO, versus where we install FROM.
1062
1083
  const targetDir = options.targetDir ? resolve(options.targetDir) : process.cwd();
1084
+ printInstallVersions(join(targetDir, stackCfg.kitDirName), stackKey);
1063
1085
  // `init --git <url>` passes a resolved clone dir's templates/ here instead β€” every
1064
1086
  // other input (STACKS, MODELING_KIT) keeps using the built-in path.
1065
1087
  const templatesSource = options.templatesSource || join(__dirname, 'stacks', stackKey, 'templates');
@@ -1478,6 +1500,8 @@ async function configureCredentials({ config, configPath, targetDir, requiredFie
1478
1500
  // `re-init` for a build kit: re-copy shared/build-kit (ralph.js, the runners, tracing, adapters)
1479
1501
  // over the installed kit, then re-run the config steps. Stack templates and skills are untouched.
1480
1502
  async function refreshSharedRuntime({ targetDir, kitDir, globalOpts, opts }) {
1503
+ console.log(`πŸš€ Eventmodelers CLI v${CLI_VERSION}\n`);
1504
+ printInstallVersions(kitDir, readJsonSafe(join(kitDir, '.eventmodelers', 'install-manifest.json')).stack);
1481
1505
  console.log(`πŸ“¦ Refreshing the shared runtime in ${relative(targetDir, kitDir)}/ (stack files untouched)...`);
1482
1506
  copyDirContents(join(__dirname, 'shared', 'build-kit'), kitDir);
1483
1507
  // The kit is now this CLI's runtime β€” record that, so the startup "kit is outdated" hint stops.
@@ -5341,6 +5365,10 @@ program
5341
5365
  if (!wanted) {
5342
5366
  const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
5343
5367
  if (claimed.length !== 1) {
5368
+ if (!claimed.length) {
5369
+ // Where it looked: an agent running from another kit's directory finds no claim in that kit's log.
5370
+ try { appendEvent(kitDir, { type: 'claim-not-found', command: 'build-baseline', claimed: 0, ...locationOf(kitDir, cwd) }, agentEventMeta(cwd, kitDir, command)); } catch { /* best effort */ }
5371
+ }
5344
5372
  console.error(`❌ ${claimed.length ? `${claimed.length} slices are claimed` : 'No slice is claimed by the run loop'} β€” name the slice with --slice.`);
5345
5373
  process.exit(1);
5346
5374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "2.0.50",
3
+ "version": "2.0.53",
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": {
@@ -26,8 +26,10 @@ function describe(e) {
26
26
  return [e.slice, e.status ?? ''];
27
27
  case 'claimed': {
28
28
  const tree = e.cleanTree === true ? 'clean tree' : e.cleanTree === false ? 'dirty tree' : 'not a git tree';
29
- return [e.slice, `pid ${e.pid}, ${tree}${e.base ? `, ${e.base.branch}@${String(e.base.head).slice(0, 8)}` : ''}${e.ticketNumber ? `, ticket ${e.ticketNumber}` : ''}${e.assignee ? `, assignee ${e.assignee}` : ''}`];
29
+ return [e.slice, `pid ${e.pid}, ${tree}${e.kitDir ? `, kit ${e.kitDir}` : ''}${e.base ? `, ${e.base.branch}@${String(e.base.head).slice(0, 8)}` : ''}${e.ticketNumber ? `, ticket ${e.ticketNumber}` : ''}${e.assignee ? `, assignee ${e.assignee}` : ''}`];
30
30
  }
31
+ case 'claim-not-found':
32
+ return [e.slice ?? e.wanted ?? '', `${e.command ?? 'command'} looked in ${e.kitDir ?? '?'} (cwd ${e.cwd ?? '?'}), ${e.claimed ?? 0} claimed there`];
31
33
  case 'claim-confirmed':
32
34
  return [e.slice, ''];
33
35
  case 'released':
@@ -491,9 +491,21 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', createAdap
491
491
  }, 10 * 60 * 1000);
492
492
 
493
493
  let lastPingFailed = false;
494
+ // Backoff: a failing ping (e.g. 402 AGENT_LIMIT_REACHED) doubles the wait, 15s β†’ 5min, so a refused agent
495
+ // doesn't hammer the platform and flood the log. Any success resets it. `shutting_down` always goes out.
496
+ const PING_BASE_MS = 15_000, PING_MAX_MS = 5 * 60_000;
497
+ let pingDelayMs = PING_BASE_MS;
498
+ let nextPingAt = 0;
499
+ const failPing = () => {
500
+ lastPingFailed = true;
501
+ nextPingAt = Date.now() + pingDelayMs;
502
+ console.error(`[agent] ${ts()} Next ping in ${Math.round(pingDelayMs / 1000)}s`);
503
+ pingDelayMs = Math.min(pingDelayMs * 2, PING_MAX_MS);
504
+ };
494
505
  // `status`: 'shutting_down' is the last ping before an exit the board asked for; every other ping says what the agent is
495
506
  // doing right now β€” building (modeling, for a MODELING agent) while a turn runs, idle otherwise.
496
507
  const ping = async (status) => {
508
+ if (status !== 'shutting_down' && Date.now() < nextPingAt) return;
497
509
  const agentStatus = status === 'shutting_down' ? status : turnsInFlight > 0 ? (agentType === 'MODELING' ? 'modeling' : 'building') : 'idle';
498
510
  try {
499
511
  const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
@@ -504,7 +516,8 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', createAdap
504
516
  });
505
517
  if (!res.ok) {
506
518
  console.error(`[agent] ${ts()} Ping failed: ${res.status} ${await res.text().catch(() => '')}`);
507
- lastPingFailed = true;
519
+ failPing();
520
+ agentRefused = res.status === 402 || res.status === 403 ? `HTTP ${res.status}` : null;
508
521
  if (res.status === 401) {
509
522
  await refreshToken('ping-401').catch((err) => console.error(`[agent] ${ts()} Token refresh after 401 ping failed:`, err));
510
523
  }
@@ -512,9 +525,12 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', createAdap
512
525
  }
513
526
  if (lastPingFailed) console.log(`[agent] ${ts()} Ping recovered`);
514
527
  lastPingFailed = false;
528
+ agentRefused = null;
529
+ pingDelayMs = PING_BASE_MS;
530
+ nextPingAt = 0;
515
531
  } catch (err) {
516
532
  console.error(`[agent] ${ts()} Ping error:`, err);
517
- lastPingFailed = true;
533
+ failPing();
518
534
  }
519
535
  };
520
536
  await ping();
@@ -713,6 +729,9 @@ export async function blockStuckSlice(kitDir, cfg, credentialed, planned, attemp
713
729
  // otherwise. A change is reported at once (`reportStatus`, set once the heartbeat exists) instead of at the next 15s ping.
714
730
  let turnsInFlight = 0;
715
731
  let reportStatus = () => {};
732
+ // Set while the platform refuses this agent's heartbeat with 402/403 (e.g. AGENT_LIMIT_REACHED): the agent isn't
733
+ // a connected build agent, so the loop must not claim slices or change their status until a ping is accepted.
734
+ let agentRefused = null;
716
735
 
717
736
  async function runWithRetry(label, fn) {
718
737
  for (let attempt = 1; ; attempt++) {
@@ -865,6 +884,13 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false,
865
884
  while (true) {
866
885
  let didWork = false;
867
886
 
887
+ // Not accepted as a connected agent (license/agent limit): touch nothing on the board, just wait for the heartbeat to recover.
888
+ if (credentialed && agentRefused) {
889
+ console.log(`[ralph] Heartbeat refused by the platform (${agentRefused}) β€” not claiming slices until it is accepted.`);
890
+ await sleep(30_000);
891
+ continue;
892
+ }
893
+
868
894
  // A claim still open here is a leftover: its process died mid-build, or settling failed last time.
869
895
  await settleLeftoverClaims({ kitDir, board, meta, pickupStatus: readPickupStatus(kitDir) });
870
896
 
@@ -8,6 +8,7 @@
8
8
  import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from 'fs';
9
9
  import { randomUUID } from 'crypto';
10
10
  import { join } from 'path';
11
+ import { homedir } from 'os';
11
12
 
12
13
  export const EVENTS_DIR = 'events';
13
14
  export const INDEX_FILE = 'events.log';
@@ -85,6 +86,14 @@ function nextSeq(kitDir) {
85
86
  // concurrent processes never interleave however large the event. After a torn index line the new id
86
87
  // starts on a fresh line instead of being glued onto the fragment. Every event gets the next sequence number
87
88
  // (`seq`), which β€” not the timestamp β€” defines the order. Returns the event id.
89
+ // Where a process ran, for events: the kit it works against and its working directory. The log is shared with
90
+ // everyone who can open the board, so the home directory is shown as `~` rather than the user's name.
91
+ export function locationOf(kitDir, cwd = process.cwd()) {
92
+ const home = homedir();
93
+ const tidy = (p) => (home && p && (p === home || p.startsWith(home + '/')) ? `~${p.slice(home.length)}` : p);
94
+ return { kitDir: tidy(kitDir), cwd: tidy(cwd) };
95
+ }
96
+
88
97
  export function appendEvent(kitDir, event, meta) {
89
98
  const dir = join(kitDir, EVENTS_DIR);
90
99
  mkdirSync(dir, { recursive: true });
@@ -5,7 +5,7 @@
5
5
  // `board` is injected: { setStatus(sliceId, status), claim(sliceId, fromStatus), readStatus(sliceId) β†’ status | null }. null means
6
6
  // no board (--local): nothing is pushed or read, the log alone decides.
7
7
 
8
- import { appendEvent, readEvents } from './slice-events.js';
8
+ import { appendEvent, readEvents, locationOf } from './slice-events.js';
9
9
  import { fold, decideSettle, decideRecovery, statusKey } from './slice-state.js';
10
10
  import { adoptAgentEdits, projectStatus } from './slice-projection.js';
11
11
  import { discardUnfinishedWork, gitBase } from './slice-git.js';
@@ -35,17 +35,25 @@ export function isProcessAlive(pid) {
35
35
  // take the stale file for an edit.
36
36
  export function recordAgentStatus({ kitDir, status, sliceId, meta }) {
37
37
  const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
38
+ // A refusal is recorded with where it looked: the usual cause is an agent running from another kit's
39
+ // directory (an app with a root kit and a web/ kit), whose log has no claim for the slice.
40
+ const refuse = (message) => {
41
+ try {
42
+ appendEvent(kitDir, { type: 'claim-not-found', ...(sliceId ? { wanted: sliceId } : {}), claimed: claimed.length, command: 'slice-status', ...locationOf(kitDir) }, meta);
43
+ } catch { /* best effort: the refusal itself is what matters */ }
44
+ throw new Error(message);
45
+ };
38
46
  let match;
39
47
  if (sliceId) {
40
48
  const wanted = sliceId.trim().toLowerCase();
41
49
  match = claimed.find(([id, s]) => id === sliceId || s.claim.title?.toLowerCase() === wanted);
42
- if (!match) throw new Error(`slice "${sliceId}" is not claimed by the run loop β€” only a slice the loop is building has its status recorded here`);
50
+ if (!match) refuse(`slice "${sliceId}" is not claimed by the run loop β€” only a slice the loop is building has its status recorded here`);
43
51
  } else if (claimed.length === 1) {
44
52
  match = claimed[0];
45
53
  } else if (claimed.length > 1) {
46
54
  throw new Error(`${claimed.length} slices are claimed (${claimed.map(([, s]) => s.claim.title).join(', ')}) β€” name one with --slice`);
47
55
  } else {
48
- throw new Error('no slice is claimed by the run loop. For a task from tasks.json, set the status with the update-slice-status skill instead');
56
+ refuse('no slice is claimed by the run loop. For a task from tasks.json, set the status with the update-slice-status skill instead');
49
57
  }
50
58
  const [id, { claim }] = match;
51
59
  recordEvent(kitDir, { type: 'agent-recorded', slice: id, status }, meta);
@@ -78,7 +86,7 @@ export async function claimSlice({ kitDir, planned, board, meta, cleanTree, pid
78
86
  }
79
87
  }
80
88
  const state = recordEvent(kitDir, {
81
- type: 'claimed', slice: planned.id, ctx: planned.ctx, title: planned.title, pid, base: gitBase(kitDir), cleanTree,
89
+ type: 'claimed', slice: planned.id, ctx: planned.ctx, title: planned.title, pid, base: gitBase(kitDir), cleanTree, ...locationOf(kitDir),
82
90
  // What the board says about the slice at build time, when it says anything: both can change between builds.
83
91
  ...(planned.ticketNumber ? { ticketNumber: planned.ticketNumber } : {}),
84
92
  ...(planned.assignee ? { assignee: planned.assignee } : {}),
@@ -1,7 +1,7 @@
1
1
  // A slice build's tool calls as events in the slice-state log, so `run --sync` can show them on the board. Any
2
2
  // runner that sees its agent's individual tool calls records them through here, so every runner logs the same shape.
3
3
 
4
- import { appendEvent } from './slice-events.js';
4
+ import { appendEvent, locationOf } from './slice-events.js';
5
5
 
6
6
  // Only the tool's name is recorded β€” never its input. A Bash command, a skill's arguments or a path can hold
7
7
  // credentials or local details, and this log is shared with everyone who can open the board. Best effort, like the
@@ -9,7 +9,7 @@ import { appendEvent } from './slice-events.js';
9
9
  export function recordToolCall({ kitDir, slice, tool, meta }) {
10
10
  if (!slice?.sliceId) return;
11
11
  try {
12
- appendEvent(kitDir, { type: 'tool-call', slice: slice.sliceId, tool }, meta);
12
+ appendEvent(kitDir, { type: 'tool-call', slice: slice.sliceId, tool, ...locationOf(kitDir) }, meta);
13
13
  } catch (err) {
14
14
  console.error(`[ralph] could not record a tool call (${err.message})`);
15
15
  }
@@ -43,8 +43,8 @@ Write tools take their items as an array (`nodeIds[]`, `elements[]`, `connection
43
43
  | `get_board_outline` | `boardId`, `chapterId` | One chapter's structure, compact: per-column node lists (`{id, type, title, lane, cellName}`) + a flat edge list, no HTML pages / field bodies / meta. `cellName` is each node's real cell address (column letter + row number) β€” the only read that returns cell names at all; use it when a placement needs a cell address. For an empty target cell, take the column's `letter` plus the lane's 1-based position in the outline's `lanes` list (e.g. `letter: "C"`, second lane β†’ `C2`) β€” never convert array indexes yourself. The cheap "what is where and how is it wired" read β€” prefer over `get_nodes` (no projection) for orientation checks | β€” (MCP-only convenience) |
44
44
  | `get_connected_nodes` | `boardId`, `nodeId`, `chapterId?`, `direction?` (`inbound`/`outbound`/`both`), `depth?`, `types?`, `includeFields?` | Neighbours of **one** node β€” what feeds it and what it feeds. Answers from a single anchor, unlike `get_attribute_chain` (which needs both ends of the chain up front). `depth` follows a whole chain; `types` filters the result only, never the traversal. Each neighbour carries `via`: `"edge"` for a real connection, `"layout"` when the node has none in that direction and the neighbour was inferred from the grid using auto-connect's own window (own column + adjacent one, forward-only pairs). Real edges always win. The `layout` fallback is what makes hand-built/imported chapters β€” which routinely carry **zero** edges β€” readable instead of falsely empty | β€” (MCP-only convenience) |
45
45
  | `validate_model` | `boardId`, `chapterId`, `checks?[]` | Server-side Event Modeling structural checklist over one chapter β€” compact `findings` only. Checks: unplaced nodes, backward arrows (with the todo-list `EVENTβ†’READMODEL` exception), zero/multi-issuer commands, sourceless read models, two-screens-in-a-column, missing scenarios. Replaces the manual per-type `get_nodes` + `get_node projection=edges` validation pass | β€” (MCP-only convenience) |
46
- | `add_scenario` | `boardId`, `timelineId`, `columnId`, `scenarios[]`, `compact?` | Append GWT scenario(s) to a column's spec node β€” created automatically, and a scenario `id` is generated when omitted. A given/when/then step may be addressed by `{title, type}` instead of a node id, resolved against that timeline, so no `get_spec_info` call is needed first (an ambiguous title is reported with its candidates). `compact: true` returns `{specNodeId, added, scenarioCount, isNewNode}` instead of echoing every scenario back | Β§6 `POST .../scenarios` |
47
- | `add_storyline` | `boardId`, `timelineId`, `columnId`, `storylines[]`, `compact?` | Append storyline(s) (ordered, branchable beats over existing elements) to a column's spec node. Storyline and beat ids are generated when omitted, and a beat may name its element by `{title, type}` instead of `refId` (same addressing as `add_scenario`; an ambiguous title is reported with its candidates). Use whenever `eventmodeling-elaborating-scenarios`'s GWT-vs-storyline decision rule calls for one (e.g. a todo list's open→close lifecycle) — not only when a user explicitly names "storyline"; that skill's own per-read-model judgment is the trigger, this catalog entry isn't a stricter gate on top of it. `compact: true` suppresses the full storyline echo | §6 `POST .../storylines` |
46
+ | `add_scenario` | `boardId`, `timelineId`, `columnId`, `scenarios[]`, `compact?` | Append GWT scenario(s) to a column's spec node β€” **the column of the original element, never of a `meta.linkedTo` copy** (follow `linkedTo` to the origin first); created automatically, and a scenario `id` is generated when omitted. A given/when/then step may be addressed by `{title, type}` instead of a node id, resolved against that timeline, so no `get_spec_info` call is needed first (an ambiguous title is reported with its candidates). `compact: true` returns `{specNodeId, added, scenarioCount, isNewNode}` instead of echoing every scenario back | Β§6 `POST .../scenarios` |
47
+ | `add_storyline` | `boardId`, `timelineId`, `columnId`, `storylines[]`, `compact?` | Append storyline(s) (ordered, branchable beats over existing elements) to a column's spec node — **the original's column, never a `meta.linkedTo` copy's**. Storyline and beat ids are generated when omitted, and a beat may name its element by `{title, type}` instead of `refId` (same addressing as `add_scenario`; an ambiguous title is reported with its candidates). Use whenever `eventmodeling-elaborating-scenarios`'s GWT-vs-storyline decision rule calls for one (e.g. a todo list's open→close lifecycle) — not only when a user explicitly names "storyline"; that skill's own per-read-model judgment is the trigger, this catalog entry isn't a stricter gate on top of it. `compact: true` suppresses the full storyline echo | §6 `POST .../storylines` |
48
48
  | `set_connection` | `boardId`, `connections[]` (each `source`, `target`, `action` `'connect'\|'remove'`), `compact?` | Add or remove type-checked directed edges, applied in order (a later entry can rely on an earlier edge); answers `{results}`, or with `compact: true` a `{connected, existed, removed, notFound, failed, errors}` tally | β€” (via `edges` on Β§3 events) |
49
49
  | `auto_connect_node` | `boardId`, `nodeIds[]` | Re-run auto-connect for nodes β€” incompatible neighbours come back in `skipped`, not as an error; answers `{results}` | Β§3 `POST .../nodes/:nodeId/auto-connect` |
50
50
  | `link_element` | `boardId`, `nodeId`, plus either `targetNodeId` or `timelineId` (+ `columnIndex?`, `lane?`) | Turn a node into a linked copy of `nodeId` β€” it receives a full copy of that node's meta plus `meta.linkedTo`. Name an existing `targetNodeId`, or pass `timelineId` to have the copy placed and linked in this one call (inheriting the original's type and title), which is what a translation or automation chain wants | Β§3 `POST .../nodes/:nodeId/link` |
@@ -66,6 +66,8 @@ Get the elements a scenario's given/when/then steps may legally reference (EVENT
66
66
  An ordered, branchable walkthrough of existing board elements ("beats"), stored alongside GWT
67
67
  scenarios on the same SCENARIO spec node, in a sibling `meta.storylines` collection.
68
68
 
69
+ > **Scenarios and storylines belong to the original element's column.** If the node has `meta.linkedTo` it is a copy: follow it to the origin and post to the origin's timeline/column. The API does not reject a copy's column, so the caller must resolve it.
70
+
69
71
  ### POST `/api/org/:orgId/boards/:boardId/timelines/:timelineId/columns/:columnId/storylines`
70
72
  Append one or more storylines to a column's spec node. The spec node is auto-created if missing
71
73
  (shared with scenarios).
@@ -346,6 +346,8 @@ if Conway's Law boundaries are not relevant to the project.
346
346
 
347
347
  Invoke `eventmodeling-elaborating-scenarios` with the Skill tool **before the first `add_scenario` call** β€” its scenario types, business-rules-only rule and payload shape are not repeated here, so scenarios written from this file alone come out wrong.
348
348
 
349
+ **Linked copies get no scenarios.** A command/read model with `meta.linkedTo` is a copy; add its scenarios and storylines to the origin's column (its own timeline), never the copy's.
350
+
349
351
  **Input**: Commands and read models.
350
352
  **Output to carry forward**: Given-When-Then specifications (or storylines, for
351
353
  walkthrough-style coverage) for every command **and every read model**, posted
@@ -128,6 +128,8 @@ After resolving `columnId` (from step 3, or from step 5 if a new column was just
128
128
 
129
129
  **Prefer MCP:**
130
130
 
131
+ > **Original only:** if the target COMMAND/READMODEL has `meta.linkedTo` it is a copy β€” resolve to the origin (no `linkedTo`) and use the origin's `timelineId`/`columnId`; never put a spec on a copy's column. Same for `add_storyline`. See `eventmodeling-core-rules`.
132
+
131
133
  ```
132
134
  mcp__eventmodelers__add_scenario {
133
135
  "boardId": "<BOARD_ID>",