@eventmodelers/cli 1.0.151 → 1.0.153

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 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);
@@ -2349,7 +2349,7 @@ async function runModeling(kitDir, projectDir, { codeDir = null, verbose = false
2349
2349
  }
2350
2350
 
2351
2351
  if (!otherRunner) spawnProcess();
2352
- log(`agent: ${cfg.agentName ? `${cfg.agentName} (${cfg.agentId})` : cfg.agentId}`);
2352
+ log(`agent: ${cfg.agentName ? `${cfg.agentName} (${cfg.agentId})` : cfg.agentId} — eventmodelers-cli v${CLI_VERSION}`);
2353
2353
  if (localRunner) log(`runner: local model — ${localRunner.describe()} (board tools over MCP; no skills, no subagents)`);
2354
2354
  if (execRunner) log(`runner: ${execRunner.describe()} — one process per turn (skills from the kit; no subagents)`);
2355
2355
  log(
@@ -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: a rejected channel retries every
3080
- // ~14s on its own, so without this a bad token would trigger a refresh call per retry.
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
- realtime.subscribe(
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
- realtime.subscribe(
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
- (status) => {
3129
- log(`channel "${channelName}": ${status}`);
3130
- if (status === 'SUBSCRIBED') drain().catch((err) => log(`initial drain error: ${err.message}`));
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
- const boardChannelName = `board:${cfg.boardId}`;
3143
- realtime.subscribe(
3144
- boardChannelName,
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
- (status) => {
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. Nothing else — no sh/bash/eval/env/xargs/find/sed/awk/node/npm/rm/mv/cp.
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.151",
3
+ "version": "1.0.153",
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 listener (see realtime-adapter.js).
28
- let unsubscribe = null;
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
- if (unsubscribe) {
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 replaces the previous subscription — never adds to it — and a replaced one no
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 topic it already knows, so
14
- // re-subscribing on it would stack another set of handlers — the old channel is removed first.
15
- let current = null;
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
- // Detach before removing: the removal itself makes the old channel report CLOSED, and a
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
- current = channel;
29
- channel.subscribe((status) => { if (channel === current) onStatus?.(status); });
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, the channel).
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 subscribe that errors on a stale token needs a fresh token AND a new join
450
- // attempt — setAuth alone doesn't re-join a channel that already errored out, and nothing
451
- // else ever re-joins it, so this retries for as long as it takes, with backoff so a
452
- // lasting failure doesn't hammer the platform. (A revoked token ends the process anyway:
453
- // refreshToken's fetch gets the 401.)
454
- let channelErrorStreak = 0;
455
- let resubscribePending = false;
456
- const onChannelFailure = (reason) => {
457
- // One failure can be reported more than once (a channel erroring while its retry is
458
- // already scheduled); a second resubscribe on top would join the channel twice.
459
- if (resubscribePending) return;
460
- resubscribePending = true;
461
- channelErrorStreak += 1;
462
- const delay = backoffMs(channelErrorStreak, PLATFORM_BACKOFF);
463
- console.warn(`[agent] ${ts()} Channel "${channelName}": ${reason} (attempt ${channelErrorStreak}) — resubscribing in ${Math.round(delay / 1000)}s`);
464
- setTimeout(async () => {
465
- try {
466
- await refreshToken(`channel ${reason}`);
467
- } catch {
468
- // Logged by refreshToken. Rejoining on the old token is still worth a try —
469
- // if it fails too, that's just the next failure, with a longer delay.
470
- }
471
- resubscribePending = false;
472
- subscribeChannel();
473
- }, delay);
474
- };
475
- const subscribeChannel = () => {
476
- realtime.subscribe(
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 `table` are display only and never wait for anything.
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
@@ -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
- "when": [{"id": "<commandNodeId>", "title": "Confirm Reservation", "type": "COMMAND"}],
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)