@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 +32 -4
- package/package.json +1 -1
- package/shared/build-kit/lib/events-view.js +3 -1
- package/shared/build-kit/lib/ralph.js +28 -2
- package/shared/build-kit/lib/slice-events.js +9 -0
- package/shared/build-kit/lib/slice-run.js +12 -4
- package/shared/build-kit/lib/tool-call-events.js +2 -2
- package/shared/skills/learn-eventmodelers-api/references/mcp-tool-catalog.md +2 -2
- package/shared/skills/learn-eventmodelers-api/references/s06-specifications.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +2 -0
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
|
-
|
|
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(
|
|
1056
|
-
console.log(`Using: ${stackKey} (${stackCfg.label})
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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>",
|