@eventmodelers/cli 1.0.151 → 1.0.152
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/README.md +1 -0
- package/cli.js +20 -36
- package/lib/bash-guard.js +39 -1
- package/package.json +1 -1
- package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +14 -7
- package/shared/build-kit/lib/adapters/realtime-adapter.js +65 -2
- package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +19 -10
- package/shared/build-kit/lib/ralph.js +30 -62
- package/shared/skills/learn-eventmodelers-api/SKILL.md +30 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +51 -3
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +181 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/index.template.html +490 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +224 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/runtime.js +434 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/slices.example.html +201 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/references/api-fallback.md +30 -0
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +2 -0
package/README.md
CHANGED
|
@@ -119,6 +119,7 @@ Use skills in Claude Code with `/skill-name`:
|
|
|
119
119
|
| `/timeline` | Live event storming facilitator |
|
|
120
120
|
| `/wdyt` | Business analyst review of your event model |
|
|
121
121
|
| `/walkthrough` | Slice-by-slice player through a feature, or through every slice a planned feature needs (existing, to adjust, new) |
|
|
122
|
+
| `/prototype` | Clickable prototype of one context as one platform-served page: built slice by slice with per-slice hashes (only changed slices rebuild), screens as tabs, in-browser event sourcing with a browsable log, logic from the scenarios, automations runnable from their own tab |
|
|
122
123
|
| `/data-journey` | Where one piece of information (e.g. the customer email) originates, is stored, transformed, used and sent out — as a table in the chat |
|
|
123
124
|
| `/domain-story` | Retells chapters as Domain Storytelling — actors, work objects and numbered activities, drawn as a graph per chapter in the chat |
|
|
124
125
|
| `/stressor-analysis` | Residuality-theory stress test of one chapter: wide stressors and their residues, a stressor × element matrix that exposes hidden coupling, redesigns proposed as new slices |
|
package/cli.js
CHANGED
|
@@ -28,7 +28,7 @@ import { modelingPermissions } from './lib/agent-permissions.js';
|
|
|
28
28
|
// copy that every useShared:true stack also gets copied into its installed kit (see
|
|
29
29
|
// copyDirContents in installStack) for ralph.js to import standalone — see
|
|
30
30
|
// shared/build-kit/lib/adapters/realtime-adapter.js for why it lives there instead.
|
|
31
|
-
import { createRealtimeAdapter } from './shared/build-kit/lib/adapters/realtime-adapter.js';
|
|
31
|
+
import { createRealtimeAdapter, keepSubscribed } from './shared/build-kit/lib/adapters/realtime-adapter.js';
|
|
32
32
|
|
|
33
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
34
|
const __dirname = dirname(__filename);
|
|
@@ -3076,8 +3076,8 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
|
|
|
3076
3076
|
|
|
3077
3077
|
let lastTokenRefreshAt = 0;
|
|
3078
3078
|
async function refreshRealtimeToken(reason) {
|
|
3079
|
-
// Guard against hammering the token endpoint:
|
|
3080
|
-
//
|
|
3079
|
+
// Guard against hammering the token endpoint: the three channels below usually fail
|
|
3080
|
+
// together, and each asks for a refresh before rejoining.
|
|
3081
3081
|
if (Date.now() - lastTokenRefreshAt < 5000) return;
|
|
3082
3082
|
lastTokenRefreshAt = Date.now();
|
|
3083
3083
|
try {
|
|
@@ -3100,18 +3100,20 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
|
|
|
3100
3100
|
process.exit(0);
|
|
3101
3101
|
};
|
|
3102
3102
|
|
|
3103
|
+
// Each channel is rejoined after a failure with exponential backoff and a fresh token
|
|
3104
|
+
// (see keepSubscribed) — never left to realtime-js's own fixed-interval rejoin loop.
|
|
3105
|
+
const keep = (topic, handlers, onSubscribed) => keepSubscribed(realtime, topic, handlers, {
|
|
3106
|
+
log,
|
|
3107
|
+
refreshToken: refreshRealtimeToken,
|
|
3108
|
+
onSubscribed,
|
|
3109
|
+
});
|
|
3110
|
+
|
|
3103
3111
|
// The kill signal is broadcast on the BOARD channel (see the platform's agent-kill /
|
|
3104
3112
|
// backoffice/killagent slices), not on the org channel this agent uses for prompts — without
|
|
3105
3113
|
// this second subscription a modeling agent can only be stopped with a kill(1).
|
|
3106
|
-
|
|
3107
|
-
`board:${cfg.boardId}-slicechanged`,
|
|
3108
|
-
{message: exitIfAddressed},
|
|
3109
|
-
(status) => log(`channel "board:${cfg.boardId}-slicechanged": ${status}`),
|
|
3110
|
-
).catch((err) => {
|
|
3111
|
-
log(`board channel subscribe failed, remote kill won't reach this agent: ${err.message}`);
|
|
3112
|
-
});
|
|
3114
|
+
keep(`board:${cfg.boardId}-slicechanged`, {message: exitIfAddressed});
|
|
3113
3115
|
|
|
3114
|
-
|
|
3116
|
+
keep(
|
|
3115
3117
|
channelName,
|
|
3116
3118
|
{
|
|
3117
3119
|
message: exitIfAddressed,
|
|
@@ -3125,33 +3127,15 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
|
|
|
3125
3127
|
drain().catch((err) => log(`drain error: ${err.message}`));
|
|
3126
3128
|
},
|
|
3127
3129
|
},
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
// A bad/stale token otherwise sits in realtime-js's own rejoin-retry loop until the
|
|
3132
|
-
// next scheduled refresh below — up to 10 minutes of failed joins. Refresh immediately
|
|
3133
|
-
// instead of waiting on the clock.
|
|
3134
|
-
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
|
3135
|
-
refreshRealtimeToken(status).catch(() => {});
|
|
3136
|
-
}
|
|
3137
|
-
},
|
|
3138
|
-
).catch((err) => {
|
|
3139
|
-
log(`realtime subscribe failed, prompts won't be pushed live: ${err.message}`);
|
|
3140
|
-
});
|
|
3130
|
+
// Also after a reconnect: prompts created while the channel was down were never pushed.
|
|
3131
|
+
() => drain().catch((err) => log(`initial drain error: ${err.message}`)),
|
|
3132
|
+
);
|
|
3141
3133
|
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3134
|
+
if (!boardLane) log(`channel "board:${cfg.boardId}": events dropped — --worker`);
|
|
3135
|
+
keep(
|
|
3136
|
+
`board:${cfg.boardId}`,
|
|
3145
3137
|
Object.fromEntries(BOARD_CHANGE_EVENTS.map((event) => [event, (payload) => onBoardEvent(event, payload)])),
|
|
3146
|
-
|
|
3147
|
-
log(`channel "${boardChannelName}": ${status}${boardLane ? '' : ' (events dropped — --worker)'}`);
|
|
3148
|
-
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
|
3149
|
-
refreshRealtimeToken(status).catch(() => {});
|
|
3150
|
-
}
|
|
3151
|
-
},
|
|
3152
|
-
).catch((err) => {
|
|
3153
|
-
log(`board subscribe failed, board changes won't be seen live: ${err.message}`);
|
|
3154
|
-
});
|
|
3138
|
+
);
|
|
3155
3139
|
|
|
3156
3140
|
setInterval(() => {
|
|
3157
3141
|
refreshRealtimeToken('scheduled').catch(() => {});
|
package/lib/bash-guard.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// Rules:
|
|
9
9
|
// - Programs: curl (only to the platform's baseUrl), python3 (two exact forms), uuidgen, jq, git (read-only
|
|
10
10
|
// subcommands), and read-only text tools (cat, head, tail, wc, sort, uniq, grep, cut, tr, ls), echo, printf,
|
|
11
|
-
// mkdir, sleep, date, true
|
|
11
|
+
// mkdir, sleep, date, true, and node for exactly one script: the kit's own prototype skill (.claude/skills/prototype/
|
|
12
|
+
// assets/prototype.mjs status|assemble|upload — the agent can't edit .claude/, so what runs is fixed). Nothing else —
|
|
13
|
+
// no sh/bash/eval/env/xargs/find/sed/awk/npm/rm/mv/cp, no other node.
|
|
12
14
|
// - No command substitution ($(…), `…`, $((…)), <(…)) except exactly $(uuidgen) and $(seq N) / $(seq A B) — they mint
|
|
13
15
|
// ids and run nothing else. No subshells, brace groups, brace expansion, background jobs or ~ paths. The only control
|
|
14
16
|
// flow is `for NAME in …; do …; done` (its body checked like any other command; NAME counts as set).
|
|
@@ -395,11 +397,39 @@ function checkTextTool(prog, args, policy) {
|
|
|
395
397
|
}
|
|
396
398
|
}
|
|
397
399
|
|
|
400
|
+
// The prototype skill's script, the one thing the agent may run with node. Resolved against the working directory (the
|
|
401
|
+
// kit) — not the temp roots, where the agent could write its own script.
|
|
402
|
+
const PROTOTYPE_SCRIPT = ['.claude', 'skills', 'prototype', 'assets', 'prototype.mjs'];
|
|
403
|
+
const PROTOTYPE_FLAGS = new Set(['--base', '--org', '--board', '--context', '--out']);
|
|
404
|
+
|
|
405
|
+
function isPrototypeRun(cmd, policy) {
|
|
406
|
+
const [prog, script] = cmd.words;
|
|
407
|
+
return prog?.text === 'node' && !!script && !script.refs.length
|
|
408
|
+
&& resolve(policy.cwd, script.text) === resolve(policy.cwd, ...PROTOTYPE_SCRIPT);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function checkPrototypeRun(cmd, policy) {
|
|
412
|
+
const [, , sub, ...rest] = cmd.words;
|
|
413
|
+
if (!['status', 'assemble', 'upload'].includes(sub?.text)) refuse('the prototype script runs as: status, assemble or upload');
|
|
414
|
+
if (rest.length % 2) refuse('the prototype script takes --flag value pairs');
|
|
415
|
+
for (let k = 0; k < rest.length; k += 2) {
|
|
416
|
+
const flag = rest[k].text;
|
|
417
|
+
const value = rest[k + 1];
|
|
418
|
+
if (!PROTOTYPE_FLAGS.has(flag)) refuse(`prototype script option ${flag} is not allowed`);
|
|
419
|
+
if (flag === '--out') checkPath(value.text, 'prototype output directory', policy);
|
|
420
|
+
else if (flag === '--base') checkUrl(value, policy);
|
|
421
|
+
}
|
|
422
|
+
if (cmd.redirects.some((r) => r.op !== '>&' && r.target.text !== '/dev/null')) refuse('the prototype script runs without redirections');
|
|
423
|
+
}
|
|
424
|
+
|
|
398
425
|
function checkProgram(cmd, policy) {
|
|
399
426
|
const [progWord, ...args] = cmd.words;
|
|
400
427
|
const prog = progWord.text;
|
|
401
428
|
if (progWord.refs.length || prog.includes('/')) refuse(`run programs by plain name — "${prog}"`);
|
|
402
429
|
switch (prog) {
|
|
430
|
+
case 'node':
|
|
431
|
+
if (!isPrototypeRun(cmd, policy)) refuse('node may only run the kit\'s .claude/skills/prototype/assets/prototype.mjs');
|
|
432
|
+
return checkPrototypeRun(cmd, policy);
|
|
403
433
|
case 'curl': return checkCurl(args, policy);
|
|
404
434
|
case 'git': return checkGit(args, policy);
|
|
405
435
|
case 'python3': return checkPython(args, policy);
|
|
@@ -423,6 +453,14 @@ export function checkBashCommand(command, policy) {
|
|
|
423
453
|
const assigned = new Set(unwrapLoops(commands));
|
|
424
454
|
for (const cmd of commands) {
|
|
425
455
|
for (const a of cmd.assignments) {
|
|
456
|
+
// The one place besides curl -H the token may go: the prototype script's EM_TOKEN, nothing else assigned to it.
|
|
457
|
+
if (a.name === 'EM_TOKEN') {
|
|
458
|
+
if (!isPrototypeRun(cmd, policy) || !/^\$(?:TOKEN|EVENTMODELERS_TOKEN)$/.test(a.word.text.slice(a.name.length + 1))) {
|
|
459
|
+
refuse('EM_TOKEN may only be set to the board token for the prototype script');
|
|
460
|
+
}
|
|
461
|
+
a.word.tokenAllowed = true;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
426
464
|
if (PROTECTED_VAR.test(a.name) || TOKEN_VARS.has(a.name)) refuse(`setting ${a.name} is not allowed`);
|
|
427
465
|
if (a.name === 'BASE_URL' && a.value.replace(/\/+$/, '') !== policy.baseUrl.replace(/\/+$/, '')) refuse(`BASE_URL may only be ${policy.baseUrl}`);
|
|
428
466
|
assigned.add(a.name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.152",
|
|
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": {
|
|
@@ -24,21 +24,25 @@ export async function createPocketBaseRealtimeAdapter(cfg, initialToken, deps =
|
|
|
24
24
|
const pb = new PocketBase(cfg.pocketbaseUrl);
|
|
25
25
|
pb.authStore.save(initialToken, null);
|
|
26
26
|
|
|
27
|
-
// subscribe() replaces the previous
|
|
28
|
-
|
|
27
|
+
// One listener per topic; subscribe() replaces the topic's previous one (see realtime-adapter.js).
|
|
28
|
+
const listeners = new Map();
|
|
29
|
+
const remove = async (topic) => {
|
|
30
|
+
const unsubscribe = listeners.get(topic);
|
|
31
|
+
if (!unsubscribe) return;
|
|
32
|
+
listeners.delete(topic);
|
|
33
|
+
await unsubscribe().catch(() => {});
|
|
34
|
+
};
|
|
29
35
|
|
|
30
36
|
return {
|
|
31
37
|
async subscribe(topic, handlers, onStatus) {
|
|
32
|
-
|
|
33
|
-
await unsubscribe().catch(() => {});
|
|
34
|
-
unsubscribe = null;
|
|
35
|
-
}
|
|
38
|
+
await remove(topic);
|
|
36
39
|
for (let attempt = 0; ; attempt++) {
|
|
37
40
|
try {
|
|
38
|
-
unsubscribe = await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
|
|
41
|
+
const unsubscribe = await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
|
|
39
42
|
if (e.action !== 'create' || e.record.topic !== topic) return;
|
|
40
43
|
handlers[e.record.event]?.(e.record.payload);
|
|
41
44
|
});
|
|
45
|
+
listeners.set(topic, unsubscribe);
|
|
42
46
|
onStatus?.('SUBSCRIBED');
|
|
43
47
|
return;
|
|
44
48
|
} catch (err) {
|
|
@@ -48,6 +52,9 @@ export async function createPocketBaseRealtimeAdapter(cfg, initialToken, deps =
|
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
},
|
|
55
|
+
unsubscribe(topic) {
|
|
56
|
+
return remove(topic);
|
|
57
|
+
},
|
|
51
58
|
setAuth(token) {
|
|
52
59
|
pb.authStore.save(token, null);
|
|
53
60
|
},
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
//
|
|
5
5
|
// subscribe(topic, handlers, onStatus?) — handlers is a { [eventName]: (payload) => void }
|
|
6
6
|
// map; onStatus?.(status) fires once the subscription is live ('SUBSCRIBED'). Calling it
|
|
7
|
-
// again
|
|
8
|
-
// longer reports status.
|
|
7
|
+
// again for the same topic replaces that topic's subscription — never adds to it — and a
|
|
8
|
+
// replaced one no longer reports status. Different topics live side by side.
|
|
9
|
+
// unsubscribe(topic) — drop the topic's subscription (a no-op if there is none).
|
|
9
10
|
// setAuth(token) — refresh the short-lived realtime token on the live connection.
|
|
10
11
|
//
|
|
11
12
|
// cfg.realtimeProvider absent (older servers, predating PocketBase support) means
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
|
|
21
22
|
import { createSupabaseRealtimeAdapter } from './supabase-realtime-adapter.js';
|
|
22
23
|
import { createPocketBaseRealtimeAdapter } from './pocketbase-realtime-adapter.js';
|
|
24
|
+
import { backoffMs } from '../backoff.js';
|
|
23
25
|
|
|
24
26
|
export async function createRealtimeAdapter(cfg, initialToken) {
|
|
25
27
|
const provider = cfg.realtimeProvider ?? 'supabase';
|
|
@@ -27,3 +29,64 @@ export async function createRealtimeAdapter(cfg, initialToken) {
|
|
|
27
29
|
? createPocketBaseRealtimeAdapter(cfg, initialToken)
|
|
28
30
|
: createSupabaseRealtimeAdapter(cfg, initialToken);
|
|
29
31
|
}
|
|
32
|
+
|
|
33
|
+
const FAILED = new Set(['CHANNEL_ERROR', 'TIMED_OUT', 'CLOSED']);
|
|
34
|
+
// A subscription has to hold this long before its failures are forgiven. Resetting on the
|
|
35
|
+
// bare SUBSCRIBED let a channel that flaps (SUBSCRIBED, CHANNEL_ERROR, SUBSCRIBED, …) retry
|
|
36
|
+
// at the shortest delay forever.
|
|
37
|
+
const STABLE_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
// Keeps one topic subscribed for the life of the process. A failed channel is removed at
|
|
40
|
+
// once — otherwise realtime-js keeps rejoining it on its own fixed schedule, which is where
|
|
41
|
+
// the flood of SUBSCRIBED/CHANNEL_ERROR lines came from — and re-joined after an exponential
|
|
42
|
+
// backoff, with a fresh token, for as long as it takes.
|
|
43
|
+
//
|
|
44
|
+
// onSubscribed({ recovered }) runs on every successful join; recovered is true when it
|
|
45
|
+
// follows a failure (broadcasts sent while the channel was down are gone).
|
|
46
|
+
export function keepSubscribed(realtime, topic, handlers, {
|
|
47
|
+
log = console.log,
|
|
48
|
+
refreshToken = async () => {},
|
|
49
|
+
onSubscribed = () => {},
|
|
50
|
+
backoff = { baseMs: 2_000, capMs: 5 * 60_000 },
|
|
51
|
+
} = {}) {
|
|
52
|
+
let failures = 0;
|
|
53
|
+
let down = false;
|
|
54
|
+
let retryPending = false;
|
|
55
|
+
let stableTimer = null;
|
|
56
|
+
|
|
57
|
+
const onFailure = (reason) => {
|
|
58
|
+
// One failure can be reported more than once (a channel erroring while its retry is
|
|
59
|
+
// already scheduled); a second resubscribe on top would join the channel twice.
|
|
60
|
+
if (retryPending) return;
|
|
61
|
+
retryPending = true;
|
|
62
|
+
down = true;
|
|
63
|
+
clearTimeout(stableTimer);
|
|
64
|
+
failures += 1;
|
|
65
|
+
const delay = backoffMs(failures, backoff);
|
|
66
|
+
log(`channel "${topic}": ${reason} (attempt ${failures}) — resubscribing in ${Math.round(delay / 1000)}s`);
|
|
67
|
+
realtime.unsubscribe(topic).catch(() => {});
|
|
68
|
+
setTimeout(async () => {
|
|
69
|
+
// A failed refresh is the caller's to log; rejoining on the old token is still worth a try.
|
|
70
|
+
await refreshToken(`channel ${reason}`).catch(() => {});
|
|
71
|
+
retryPending = false;
|
|
72
|
+
subscribe();
|
|
73
|
+
}, delay);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const onStatus = (status) => {
|
|
77
|
+
if (FAILED.has(status)) return onFailure(status);
|
|
78
|
+
if (status !== 'SUBSCRIBED') return log(`channel "${topic}": ${status}`);
|
|
79
|
+
const recovered = down;
|
|
80
|
+
down = false;
|
|
81
|
+
log(`channel "${topic}": SUBSCRIBED${recovered ? ` — recovered after ${failures} failed attempt(s)` : ''}`);
|
|
82
|
+
clearTimeout(stableTimer);
|
|
83
|
+
stableTimer = setTimeout(() => { failures = 0; }, STABLE_MS);
|
|
84
|
+
onSubscribed({ recovered });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const subscribe = () => {
|
|
88
|
+
realtime.subscribe(topic, handlers, onStatus)
|
|
89
|
+
.catch((err) => onFailure(`subscribe failed: ${err.message}`));
|
|
90
|
+
};
|
|
91
|
+
subscribe();
|
|
92
|
+
}
|
|
@@ -10,23 +10,32 @@ export async function createSupabaseRealtimeAdapter(cfg, initialToken, deps = {}
|
|
|
10
10
|
});
|
|
11
11
|
await supabase.realtime.setAuth(initialToken);
|
|
12
12
|
|
|
13
|
-
// supabase.channel(topic) hands back the existing channel for a
|
|
14
|
-
// re-subscribing on it would stack another set of handlers — the
|
|
15
|
-
|
|
13
|
+
// One live channel per topic. supabase.channel(topic) hands back the existing channel for a
|
|
14
|
+
// topic it already knows, so re-subscribing on it would stack another set of handlers — the
|
|
15
|
+
// old channel is removed first.
|
|
16
|
+
const channels = new Map();
|
|
17
|
+
const remove = async (topic) => {
|
|
18
|
+
const channel = channels.get(topic);
|
|
19
|
+
if (!channel) return;
|
|
20
|
+
// Detach before removing: the removal itself makes the old channel report CLOSED, and a
|
|
21
|
+
// late status from a removed channel must not count as the topic's current subscription's.
|
|
22
|
+
channels.delete(topic);
|
|
23
|
+
await supabase.removeChannel(channel).catch(() => {});
|
|
24
|
+
};
|
|
16
25
|
|
|
17
26
|
return {
|
|
18
27
|
async subscribe(topic, handlers, onStatus) {
|
|
19
|
-
|
|
20
|
-
// late status from a replaced channel must not count as this subscription's.
|
|
21
|
-
const previous = current;
|
|
22
|
-
current = null;
|
|
23
|
-
if (previous) await supabase.removeChannel(previous).catch(() => {});
|
|
28
|
+
await remove(topic);
|
|
24
29
|
let channel = supabase.channel(topic, { config: { private: true } });
|
|
25
30
|
for (const [event, handler] of Object.entries(handlers)) {
|
|
26
31
|
channel = channel.on('broadcast', { event }, (msg) => handler(msg.payload));
|
|
27
32
|
}
|
|
28
|
-
|
|
29
|
-
channel.subscribe((status) => { if (
|
|
33
|
+
channels.set(topic, channel);
|
|
34
|
+
channel.subscribe((status) => { if (channels.get(topic) === channel) onStatus?.(status); });
|
|
35
|
+
},
|
|
36
|
+
// Also stops realtime-js's own rejoin loop for an errored channel.
|
|
37
|
+
unsubscribe(topic) {
|
|
38
|
+
return remove(topic);
|
|
30
39
|
},
|
|
31
40
|
setAuth(token) {
|
|
32
41
|
return supabase.realtime.setAuth(token);
|
|
@@ -11,11 +11,11 @@ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, rename
|
|
|
11
11
|
import { join, dirname } from 'path';
|
|
12
12
|
import { homedir } from 'os';
|
|
13
13
|
import { randomUUID } from 'crypto';
|
|
14
|
-
import { createRealtimeAdapter } from './adapters/realtime-adapter.js';
|
|
14
|
+
import { createRealtimeAdapter, keepSubscribed } from './adapters/realtime-adapter.js';
|
|
15
15
|
import { backoffMs, sleep } from './backoff.js';
|
|
16
16
|
import { redactConsole } from './redact.js';
|
|
17
17
|
|
|
18
|
-
// Backoff for calls to the platform that failed for a reason on its side (startup
|
|
18
|
+
// Backoff for calls to the platform that failed for a reason on its side (startup).
|
|
19
19
|
const PLATFORM_BACKOFF = { baseMs: 2_000, capMs: 5 * 60_000 };
|
|
20
20
|
|
|
21
21
|
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
@@ -446,66 +446,34 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
446
446
|
return refreshing;
|
|
447
447
|
};
|
|
448
448
|
|
|
449
|
-
// A
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
channelName,
|
|
478
|
-
{
|
|
479
|
-
// A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
|
|
480
|
-
// Anything that doesn't name this agent is ignored — a broadcast reaches every agent on
|
|
481
|
-
// the board, and the older signal (the bare string "Exit") took all of them down at once.
|
|
482
|
-
// That string form is gone for good, not just unhandled: Supabase's broadcast API rejects
|
|
483
|
-
// a non-object payload with 422, so it never actually arrived here.
|
|
484
|
-
message: (payload) => {
|
|
485
|
-
if (payload?.type !== 'kill' || payload?.id !== cfg.agentId) return;
|
|
486
|
-
console.log(`[agent] ${ts()} Received kill (instruction: ${payload.instruction ?? 'exit'}) — shutting down`);
|
|
487
|
-
process.exit(0);
|
|
488
|
-
},
|
|
489
|
-
'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
|
|
490
|
-
},
|
|
491
|
-
(status) => {
|
|
492
|
-
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') return onChannelFailure(status);
|
|
493
|
-
if (status !== 'SUBSCRIBED' || channelErrorStreak === 0) {
|
|
494
|
-
console.log(`[agent] ${ts()} Channel "${channelName}": ${status}`);
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
console.log(`[agent] ${ts()} Channel "${channelName}": ${status} — recovered after ${channelErrorStreak} failed attempt(s)`);
|
|
498
|
-
channelErrorStreak = 0;
|
|
499
|
-
// Broadcasts sent while the channel was down are gone. Re-read the slices once so a
|
|
500
|
-
// slice set to Planned during the gap still gets built — .slices/ is what ralphLoop
|
|
501
|
-
// picks Planned work from.
|
|
502
|
-
fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
503
|
-
console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
|
|
504
|
-
);
|
|
505
|
-
},
|
|
506
|
-
).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
|
|
507
|
-
};
|
|
508
|
-
subscribeChannel();
|
|
449
|
+
// A channel that errors (often a stale token) needs a fresh token AND a new join —
|
|
450
|
+
// keepSubscribed does both, with backoff, for as long as it takes. (A revoked token ends
|
|
451
|
+
// the process anyway: refreshToken's fetch gets the 401.)
|
|
452
|
+
keepSubscribed(realtime, channelName, {
|
|
453
|
+
// A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
|
|
454
|
+
// Anything that doesn't name this agent is ignored — a broadcast reaches every agent on
|
|
455
|
+
// the board, and the older signal (the bare string "Exit") took all of them down at once.
|
|
456
|
+
// That string form is gone for good, not just unhandled: Supabase's broadcast API rejects
|
|
457
|
+
// a non-object payload with 422, so it never actually arrived here.
|
|
458
|
+
message: (payload) => {
|
|
459
|
+
if (payload?.type !== 'kill' || payload?.id !== cfg.agentId) return;
|
|
460
|
+
console.log(`[agent] ${ts()} Received kill (instruction: ${payload.instruction ?? 'exit'}) — shutting down`);
|
|
461
|
+
process.exit(0);
|
|
462
|
+
},
|
|
463
|
+
'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
|
|
464
|
+
}, {
|
|
465
|
+
log: (msg) => console.log(`[agent] ${ts()} ${msg}`),
|
|
466
|
+
refreshToken,
|
|
467
|
+
onSubscribed: ({ recovered }) => {
|
|
468
|
+
if (!recovered) return;
|
|
469
|
+
// Broadcasts sent while the channel was down are gone. Re-read the slices once so a
|
|
470
|
+
// slice set to Planned during the gap still gets built — .slices/ is what ralphLoop
|
|
471
|
+
// picks Planned work from.
|
|
472
|
+
fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
473
|
+
console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
|
|
474
|
+
);
|
|
475
|
+
},
|
|
476
|
+
});
|
|
509
477
|
|
|
510
478
|
setInterval(() => {
|
|
511
479
|
refreshToken('scheduled 10min refresh').catch((err) => console.error(`[agent] ${ts()} Scheduled token refresh failed:`, err));
|
|
@@ -707,8 +707,13 @@ Append Given-When-Then scenario(s) to a column's spec node — the node is creat
|
|
|
707
707
|
// A step is addressed either way:
|
|
708
708
|
{ id: 'node-uuid' } // a board node
|
|
709
709
|
{ title: 'OrderPlaced', type: 'EVENT' } // resolved against this timeline's own elements
|
|
710
|
+
|
|
711
|
+
// Either form also carries the step's values for this scenario — the fields essential to it, each with its example:
|
|
712
|
+
{ title: 'OrderPlaced', type: 'EVENT', fields: [{ name: 'orderId', example: 'o-42' }, { name: 'placedAt', example: '2026-10-12' }] }
|
|
710
713
|
```
|
|
711
714
|
|
|
715
|
+
`fields` is what the board shows on the step and what generated tests use as the scenario's data. Fill the values the scenario depends on (what the rule decides on, linking ids, the asserted outcome) and leave the rest out, so it is clear which data is relevant — a value that only appears in `contextNote` is not part of the specification.
|
|
716
|
+
|
|
712
717
|
A step given as `{title, type}` is resolved server-side, so the names you already know need **no `get_spec_info`/`get_nodes` call first**. A title matching more than one element is rejected (`SCENARIO_ITEM_AMBIGUOUS`) with the candidates rather than guessed at, and resolution runs before anything is written — a bad name leaves no empty spec node behind.
|
|
713
718
|
|
|
714
719
|
**Validation rules**:
|
|
@@ -857,6 +862,26 @@ avgChangesPerSlice }` — `firstTimeRightRate` is the share of the slices that r
|
|
|
857
862
|
|
|
858
863
|
---
|
|
859
864
|
|
|
865
|
+
### POST `/api/org/:orgId/boards/:boardId/prototype/:contextId`
|
|
866
|
+
**File**: `src/slices/change/api-prototypes/routes.ts`
|
|
867
|
+
|
|
868
|
+
Store a context's generated prototype page (the `prototype` skill). REST only, no MCP tool — `prototype.mjs upload` calls it.
|
|
869
|
+
|
|
870
|
+
**Request**: the page as the raw body, `Content-Type: text/html`, at most 5 MB. `contextId` is a MODEL_CONTEXT node or a
|
|
871
|
+
timeline; a timeline resolves to its effective context (the rule `slicedata` uses), so every timeline of one context
|
|
872
|
+
stores to the same place.
|
|
873
|
+
|
|
874
|
+
The page is sanitized — inline scripts and the `text/plain` slice blocks stay byte for byte; removed are scripts and
|
|
875
|
+
stylesheets from anywhere but `/vendor/`, module scripts, frames, objects, `base`, `<meta http-equiv>`, `on*` attributes,
|
|
876
|
+
`javascript:` URLs and HTML comments — then gzipped and stored in the organization's bucket at
|
|
877
|
+
`<boardId>/<effective context id>.gz`, replacing the previous version.
|
|
878
|
+
|
|
879
|
+
**Response**: `201` — `{ contextId, contextName, path, bytes, gzippedBytes, removed: string[] }` (`removed`: what the
|
|
880
|
+
sanitizer took out, one line each). `400` `PROTOTYPE_HTML_REQUIRED` (empty body / not `text/html`) /
|
|
881
|
+
`PROTOTYPE_HTML_INVALID` (no `<body>`) · `404` `CONTEXT_NOT_FOUND` · `413` larger than 5 MB.
|
|
882
|
+
|
|
883
|
+
---
|
|
884
|
+
|
|
860
885
|
## 9. Extensions
|
|
861
886
|
|
|
862
887
|
**File**: `src/slices/extensions/routes.ts`
|
|
@@ -1148,9 +1173,10 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
|
|
|
1148
1173
|
| to answer where one piece of information comes from and where it ends up ("where is the customer email used?") | `dataJourney` | nothing — element names pan the canvas |
|
|
1149
1174
|
| to show a diagram you drew yourself — a context map, a domain story, a sketch, any picture that isn't a chart | `svg` | nothing |
|
|
1150
1175
|
| to lay out rows and columns — a comparison, a matrix (stressors × elements), a short list with a few attributes | `table` | nothing — linked cells pan the canvas |
|
|
1176
|
+
| to show a prototype you just built and uploaded (the `prototype` skill), running in the chat | `prototype` | nothing — **Full screen** opens the board's prototype player |
|
|
1151
1177
|
| something the customer defined their own snippet type for (a risk card, a sign-off — see `list_snippet_types`) | `custom` | the clicked action's `answer`; `context.snippetReply.then` says what to do |
|
|
1152
1178
|
|
|
1153
|
-
`confirm`, `poll`, `tasks` and `changes` are questions: a person answers once, then they show as answered. `link`, `image`, `code`, `report`, `walkthrough`, `screen`, `dataJourney`, `svg` and `
|
|
1179
|
+
`confirm`, `poll`, `tasks` and `changes` are questions: a person answers once, then they show as answered. `link`, `image`, `code`, `report`, `walkthrough`, `screen`, `dataJourney`, `svg`, `table` and `prototype` are display only and never wait for anything.
|
|
1154
1180
|
|
|
1155
1181
|
### Shapes
|
|
1156
1182
|
```typescript
|
|
@@ -1204,6 +1230,8 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
|
|
|
1204
1230
|
align?: 'left' | 'center' | 'right' }>,
|
|
1205
1231
|
rows: Array<Array<string | number | true | null // 1–200, one cell per column; true = check mark, null = empty
|
|
1206
1232
|
| { text: string, nodeId?: string }>> } // a cell linking to an element; shown 5 rows per page
|
|
1233
|
+
{ kind: 'prototype', timelineId: string, // the context the prototype was uploaded for: a MODEL_CONTEXT or timeline id
|
|
1234
|
+
title?: string, caption?: string } // (html: string ≤ 60000 chars instead of timelineId exists, but a built page never fits)
|
|
1207
1235
|
```
|
|
1208
1236
|
|
|
1209
1237
|
### How to use them
|
|
@@ -1220,6 +1248,7 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
|
|
|
1220
1248
|
- **`svg`** is for a picture you draw yourself: give the root a `viewBox` (and `width`/`height` for its natural size), inline colours and fonts — it is shown as an image, so scripts, `<a>` links and external resources do nothing. To make parts of it clickable, list them in `links`: a rectangle in the viewBox coordinates you drew in plus the element it stands for (only real ids from this board); the app lays a clickable area over each that zooms the canvas there. Prefer `report` for numbers. A domain story (who does what with which work object) is an `svg` you draw — the `domain-story` skill (modeling kit) has the procedure and the drawing style. Every SVG snippet has a download-as-PNG button.
|
|
1221
1249
|
- **"Place it on the board" after you drew an SVG always means: put that picture on the board as an image** — never rebuild it from elements (stickies, lanes, a timeline), never a SCREEN, never a new snippet. `place_image` with `imageBase64` = the SVG document itself, base64-encoded, `mimeType: "image/svg+xml"`, `title` = the snippet's title: it becomes an OUTLINE (the drawing tools' image element), free-floating at `x`/`y`, in no chapter and connected to nothing. Place it where the person says, else in free space next to the chapter it is about (`get_chapter_bounds`: right of or below it). Add a white background `<rect>` first if the SVG has none, or it is unreadable on a dark board. One SVG = one image. The image on the board has no clickable areas — `links` stay in the chat.
|
|
1222
1250
|
- **`table`** is for anything that is rows and columns and not a chart: a comparison of options, an incidence matrix (rows = stressors, columns = elements, `true` where it hits), a short list with a few attributes per item. It computes nothing — totals are a row you add. Put element ids in `nodeId` (header or cell) so names are links; a row longer than the columns is cut, a shorter one padded, an empty one dropped. Prefer `report` for numbers you want *seen* as a trend or share, `dataJourney` for one piece of data's path.
|
|
1251
|
+
- **`prototype`** runs a prototype in the chat: the page the platform stored for `timelineId` (the `contextId` the `prototype` skill uploaded under — any timeline of that context works too), in a sandboxed iframe with **Restart** (empty event log) and **Full screen** (the same player as the timeline's ▶). Send it only after `upload` succeeded — before that it shows "No prototype has been built for this yet". The `text` is the short report of what was built; the prototype itself is the snippet.
|
|
1223
1252
|
- A snippet is shown exactly as you send it — keep secrets and tokens out of `code` and `image`.
|
|
1224
1253
|
|
|
1225
1254
|
### Custom snippet types
|
package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md
CHANGED
|
@@ -15,6 +15,19 @@ This step applies the shared element rules in **`eventmodeling-core-rules`** —
|
|
|
15
15
|
|
|
16
16
|
Prefer `mcp__eventmodelers__*` tools when available (registered by the `connect` skill) — the curl blocks below are the fallback for sessions without MCP connected.
|
|
17
17
|
|
|
18
|
+
## Examples Are the Scenario — Non-Negotiable
|
|
19
|
+
|
|
20
|
+
**A scenario without concrete example values is not a scenario.** The element names in
|
|
21
|
+
`given`/`when`/`then` only say *which* facts are involved; the example values say *what* happened
|
|
22
|
+
and *why* the outcome follows — they are the actual specification, and the only thing a generated
|
|
23
|
+
test can assert against. Every value **essential to the scenario** — the ones the rule decides on
|
|
24
|
+
and the ones that tie the steps together — is filled in as `fields: [{name, example}]` on the
|
|
25
|
+
step it belongs to (`given` events, the `when` command, `then` events, `context`/`then` read
|
|
26
|
+
models, storyline beats), and list read models carry their expected rows in `examples` (or
|
|
27
|
+
`expectEmptyList: true`). Never post a scenario, never report one done, and never mark this step
|
|
28
|
+
complete while an essential value is missing. Details: "Fill the values essential to the scenario"
|
|
29
|
+
below.
|
|
30
|
+
|
|
18
31
|
## GWT vs. Storyline — Decision Rule
|
|
19
32
|
|
|
20
33
|
A GWT scenario asserts one isolated transition (one precondition → one action → one outcome). A
|
|
@@ -65,6 +78,37 @@ assumption or regulation behind the case, why the case matters, a time gap betwe
|
|
|
65
78
|
cart total is 120 €" (→ field examples), "an email is sent" (→ a `then` event), restating the
|
|
66
79
|
title. Most scenarios need no note; keep any note to a few lines.
|
|
67
80
|
|
|
81
|
+
### Fill the values essential to the scenario — mandatory
|
|
82
|
+
|
|
83
|
+
The goal is not to fill every field, but every value **the scenario depends on**: change one of
|
|
84
|
+
them and the outcome changes, or the steps no longer belong together. Essential are:
|
|
85
|
+
|
|
86
|
+
- **the values the rule decides on** — the dates of an expiry rule, the wrong token, the outcome
|
|
87
|
+
`Unclear` that routes a check into review, the second registration's duplicate email;
|
|
88
|
+
- **the ids linking the steps** — the same `customerId` in the given events, the command and the
|
|
89
|
+
outcome, so it is clear they are about the same thing;
|
|
90
|
+
- **the asserted outcome** — the `then` event's or read model's values that the scenario expects
|
|
91
|
+
(the `status: Rejected` and its reason, the row that appears in the list).
|
|
92
|
+
|
|
93
|
+
**Leave every other field empty — that is best practice, not laziness.** The empty fields are
|
|
94
|
+
what make it clear which data is relevant: a reader sees at a glance that this case turns on the
|
|
95
|
+
dates and the token, not on the name, address or phone number. Padding every step with every field
|
|
96
|
+
buries the values that carry the rule. The test for the result: someone reading only the filled
|
|
97
|
+
values can tell why the outcome follows. A
|
|
98
|
+
scenario whose essential values are missing is incomplete, even when a note explains the
|
|
99
|
+
situation: the fields are the specification, the note is not.
|
|
100
|
+
|
|
101
|
+
The values are what make the rule visible and testable. For an expiry rule, the dates go into the
|
|
102
|
+
fields — `CustomerRegistered.registeredAt: 2026-10-12`, `ConfirmEmail.confirmedAt: 2026-10-13` —
|
|
103
|
+
not into a note saying "registered on 2026-10-12, confirms on 2026-10-13". Keep ids consistent
|
|
104
|
+
across the steps of one scenario (the same `customerId` in every given event, the command and the
|
|
105
|
+
outcome), and pick values that sit on the interesting side of the rule (one day past the limit, not
|
|
106
|
+
a vague "long ago").
|
|
107
|
+
|
|
108
|
+
Read the elements' field names once (`get_nodes` with `projection: "fields"`, scoped by
|
|
109
|
+
`chapterId`) and pick the essential ones per scenario from that. If a value the rule depends on has no field on any element (e.g. the expiry rule needs a
|
|
110
|
+
timestamp no event carries), add that field to the element — don't move the value into the note.
|
|
111
|
+
|
|
68
112
|
### Storyline data shape
|
|
69
113
|
|
|
70
114
|
```json
|
|
@@ -466,7 +510,8 @@ Rejection scenario example:
|
|
|
466
510
|
"expectError": true,
|
|
467
511
|
"errorDescription": "reservation date is in the past",
|
|
468
512
|
"given": [],
|
|
469
|
-
"when": [{"id": "<commandNodeId>", "title": "Reservation Command", "type": "COMMAND"
|
|
513
|
+
"when": [{"id": "<commandNodeId>", "title": "Reservation Command", "type": "COMMAND",
|
|
514
|
+
"fields": [{"name": "reservationId", "example": "r-123"}, {"name": "date", "example": "2026-10-12"}]}],
|
|
470
515
|
"then": [],
|
|
471
516
|
"examples": [],
|
|
472
517
|
"expectEmptyList": false
|
|
@@ -480,8 +525,10 @@ State-violation rejection (given events establish the wrong state):
|
|
|
480
525
|
"title": "Reject confirming an already-confirmed reservation",
|
|
481
526
|
"expectError": true,
|
|
482
527
|
"errorDescription": "reservation already confirmed",
|
|
483
|
-
"given": [{"id": "<eventNodeId>", "title": "ReservationConfirmed", "type": "EVENT"
|
|
484
|
-
|
|
528
|
+
"given": [{"id": "<eventNodeId>", "title": "ReservationConfirmed", "type": "EVENT",
|
|
529
|
+
"fields": [{"name": "reservationId", "example": "r-123"}, {"name": "confirmedAt", "example": "2026-10-12"}]}],
|
|
530
|
+
"when": [{"id": "<commandNodeId>", "title": "Confirm Reservation", "type": "COMMAND",
|
|
531
|
+
"fields": [{"name": "reservationId", "example": "r-123"}]}],
|
|
485
532
|
"then": [],
|
|
486
533
|
"examples": [],
|
|
487
534
|
"expectEmptyList": false
|
|
@@ -511,6 +558,7 @@ After posting, tell the user:
|
|
|
511
558
|
**No command has only 2 scenarios unless all other types were reviewed and found inapplicable.**
|
|
512
559
|
|
|
513
560
|
**Per read model — this is a separate, equally mandatory pass, not an afterthought of the command pass above:**
|
|
561
|
+
- [ ] **Essential example values filled (blocking — no exceptions)** — every value the rule decides on, the ids linking the steps and the asserted outcome carry a concrete `example` on their step; every other field left empty, so it is clear which data is relevant; a scenario whose values only appear in its note is not done
|
|
514
562
|
- [ ] **Notes carry nothing an element could** — every `contextNote` and note beat holds only background no event, command field or read model can express; anything else is moved into `given`/`when`/`then`/`context` and the note removed
|
|
515
563
|
- [ ] **Every READMODEL on the board has at least one view scenario** — GWT (`given`: source EVENTs, `when`: empty, `then`: the READMODEL) or a storyline. A model with dozens of command scenarios and 0 read-model scenarios is not a complete Step 7 — it's easy to walk away thinking coverage is thorough because the command side looks exhaustive, so check the read-model side explicitly before reporting this step done.
|
|
516
564
|
- [ ] **Population scenario** — the view shows correct data after its source event(s)
|
|
@@ -138,7 +138,7 @@ mcp__eventmodelers__add_scenario {
|
|
|
138
138
|
}
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
Same rules apply (context=READMODELs only, given=EVENTs only, when=at most one COMMAND or QUERY, then=EVENTs only or exactly one READMODEL, `expectError`+`errorDescription` for error cases). This auto-creates the spec node if the cell is empty, same as the curl endpoint.
|
|
141
|
+
Same rules apply (context=READMODELs only, given=EVENTs only, when=at most one COMMAND or QUERY, then=EVENTs only or exactly one READMODEL, `expectError`+`errorDescription` for error cases). The values essential to the scenario — what the rule decides on, the ids linking the steps, the asserted outcome — go into each step's `fields: [{name, example}]`; every other field stays empty so it is clear which data is relevant — see `eventmodeling-elaborating-scenarios` "Fill the values essential to the scenario". This auto-creates the spec node if the cell is empty, same as the curl endpoint.
|
|
142
142
|
|
|
143
143
|
**Fallback (no MCP):** see `references/api-fallback.md` — "Step 4a — SCENARIO: append scenarios via the spec endpoint" for the full curl call and example payload.
|
|
144
144
|
|