@eventmodelers/cli 1.0.150 → 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.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/cli.js +20 -36
  3. package/lib/bash-guard.js +39 -1
  4. package/package.json +1 -1
  5. package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +14 -7
  6. package/shared/build-kit/lib/adapters/realtime-adapter.js +65 -2
  7. package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +19 -10
  8. package/shared/build-kit/lib/ralph.js +30 -62
  9. package/shared/skills/learn-eventmodelers-api/SKILL.md +38 -1
  10. package/stacks/axon/templates/.claude/skills/build-automation/SKILL.md +2 -0
  11. package/stacks/axon/templates/.claude/skills/build-state-change/SKILL.md +2 -0
  12. package/stacks/axon/templates/.claude/skills/build-state-view/SKILL.md +2 -0
  13. package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +2 -0
  14. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +2 -0
  15. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -0
  16. package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +2 -0
  17. package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +2 -0
  18. package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +2 -0
  19. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +69 -3
  20. package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
  21. package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +181 -0
  22. package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/index.template.html +490 -0
  23. package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +224 -0
  24. package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/runtime.js +434 -0
  25. package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/slices.example.html +201 -0
  26. package/stacks/modeling-kit/templates/.claude/skills/prototype/references/api-fallback.md +30 -0
  27. package/stacks/modeling-kit/templates/kit/CLAUDE.md +2 -0
  28. package/stacks/node/templates/.claude/skills/build-automation/SKILL.md +2 -0
  29. package/stacks/node/templates/.claude/skills/build-state-change/SKILL.md +2 -0
  30. package/stacks/node/templates/.claude/skills/build-state-view/SKILL.md +2 -0
  31. package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +2 -0
  32. package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +2 -0
  33. package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +2 -0
  34. package/stacks/supabase/templates/.claude/skills/build-state-change/SKILL.md +2 -0
  35. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +2 -0
  36. package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +2 -0
  37. package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +2 -0
  38. package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.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: 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.150",
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 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));
@@ -696,6 +696,8 @@ Append Given-When-Then scenario(s) to a column's spec node — the node is creat
696
696
  title: string
697
697
  vertical?: boolean
698
698
  examples?: unknown[]
699
+ context?: SpecStep[] // READMODELs the command decides against
700
+ contextNote?: string // markdown background — ONLY what events, commands and read models can't express
699
701
  given?: SpecStep[] // EVENTs from this timeline
700
702
  when?: SpecStep[] // at most one COMMAND, or one inline QUERY object; empty if then has a READMODEL
701
703
  then?: SpecStep[] // EVENTs only OR exactly one READMODEL (not mixed)
@@ -705,12 +707,18 @@ Append Given-When-Then scenario(s) to a column's spec node — the node is creat
705
707
  // A step is addressed either way:
706
708
  { id: 'node-uuid' } // a board node
707
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' }] }
708
713
  ```
709
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
+
710
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.
711
718
 
712
719
  **Validation rules**:
713
720
  - `context`: only READMODELs (the state the command decides against)
721
+ - `contextNote`: free markdown, last resort. State belongs in `given` events (or `context` read models), inputs in the `when` command's fields, outcomes in `then` — a note never replaces or repeats any of them. Use it only for background no element can carry: an external condition ("the payment provider is in maintenance"), a business assumption, the reason a case matters. Blank notes are dropped. Exported as `contextNote` on the scenario in slice data
714
722
  - `given`: only EVENTs from same timeline
715
723
  - `when`: max one COMMAND; must be empty when `then` contains a READMODEL (use an inline QUERY item for a state-view)
716
724
  - `then`: all EVENTs OR exactly one READMODEL — never mixed
@@ -765,8 +773,13 @@ Append one or more storylines to a column's spec node. The spec node is auto-cre
765
773
  examples?: unknown[]
766
774
  }>
767
775
  }
776
+
777
+ // A note beat — free markdown anywhere in the sequence, referencing no element:
778
+ { instanceId: 'uuid', refId: '', type: 'STORYLINE_NOTE', title: 'Note', note: 'markdown' }
768
779
  ```
769
780
 
781
+ A note beat is the storyline's `contextNote`: only for what the beats themselves can't express (an external condition, an assumption, a time gap like "two weeks later"). Never use one to stand in for an event, command or read model that should be a beat — model the element instead. It is exported as an element `{id: instanceId, type: 'STORYLINE_NOTE', description: note}`; an empty note is rejected (`STORYLINE_NOTE_EMPTY`).
782
+
770
783
  A beat is addressed either way, like a scenario step:
771
784
  ```typescript
772
785
  { refId: 'node-uuid' } // a board node — always wins, never re-resolved
@@ -849,6 +862,26 @@ avgChangesPerSlice }` — `firstTimeRightRate` is the share of the slices that r
849
862
 
850
863
  ---
851
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
+
852
885
  ## 9. Extensions
853
886
 
854
887
  **File**: `src/slices/extensions/routes.ts`
@@ -1140,9 +1173,10 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
1140
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 |
1141
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 |
1142
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 |
1143
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 |
1144
1178
 
1145
- `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.
1146
1180
 
1147
1181
  ### Shapes
1148
1182
  ```typescript
@@ -1196,6 +1230,8 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
1196
1230
  align?: 'left' | 'center' | 'right' }>,
1197
1231
  rows: Array<Array<string | number | true | null // 1–200, one cell per column; true = check mark, null = empty
1198
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)
1199
1235
  ```
1200
1236
 
1201
1237
  ### How to use them
@@ -1212,6 +1248,7 @@ Rule of thumb: if you are about to write "Shall I…?", "Want me to…?", "A or
1212
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.
1213
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.
1214
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.
1215
1252
  - A snippet is shown exactly as you send it — keep secrets and tokens out of `code` and `image`.
1216
1253
 
1217
1254
  ### Custom snippet types
@@ -396,6 +396,8 @@ class {AutomationName}ProcessorTest {
396
396
 
397
397
  ### Storyline-Derived Tests (Optional)
398
398
 
399
+ > **Note beats**: an entry with `type: STORYLINE_NOTE` is a note the board author placed in the walkthrough — its `description` is markdown context (an assumption, an external condition, a time gap), and its `id` is not a board node. Read it as background for the beats around it, but it is never a step: skip it when looking for COMMAND/EVENT/READMODEL beats or judging which beats are adjacent, and never turn it into test setup or an assertion on its own.
400
+
399
401
  `slice.json` may also carry a `storylines[]` array — narrated walkthroughs with an ordered
400
402
  `elements[]` "beats" sequence (EVENT/COMMAND/READMODEL/...). This is a secondary, supplementary
401
403
  source; `specifications[]` above stays the primary and default source of test cases. Most slices
@@ -405,6 +405,8 @@ see [references/axon-test-fixture-patterns.md](references/axon-test-fixture-patt
405
405
 
406
406
  ## Step 7b: Storyline-Derived Tests (Optional)
407
407
 
408
+ > **Note beats**: an entry with `type: STORYLINE_NOTE` is a note the board author placed in the walkthrough — its `description` is markdown context (an assumption, an external condition, a time gap), and its `id` is not a board node. Read it as background for the beats around it, but it is never a step: skip it when looking for COMMAND/EVENT/READMODEL beats or judging which beats are adjacent, and never turn it into test setup or an assertion on its own.
409
+
408
410
  `slice.json` may carry a `storylines[]` array alongside `specifications[]` — narrated walkthroughs
409
411
  where an ordered sequence of `elements[]` "beats" (EVENT/COMMAND/READMODEL/...) shows one use case
410
412
  end to end. This is a secondary, supplementary source — `specifications[]` (Step 7) stays the
@@ -210,6 +210,8 @@ Do not design your own test cases unless specifically instructed to do so.
210
210
 
211
211
  ## Step 4b: Storyline-Derived Tests (Optional)
212
212
 
213
+ > **Note beats**: an entry with `type: STORYLINE_NOTE` is a note the board author placed in the walkthrough — its `description` is markdown context (an assumption, an external condition, a time gap), and its `id` is not a board node. Read it as background for the beats around it, but it is never a step: skip it when looking for COMMAND/EVENT/READMODEL beats or judging which beats are adjacent, and never turn it into test setup or an assertion on its own.
214
+
213
215
  `slice.json` may also carry a `storylines[]` array — narrated walkthroughs where the *same* read
214
216
  model appears as multiple ordered "beats" across one flow (see `elements[]` on each storyline).
215
217
  This is a secondary, supplementary source: `specifications[]` (Step 4) remains the primary and
@@ -39,6 +39,8 @@ Follow `/build-state-change` for this slice's command and events — all of its
39
39
 
40
40
  ### Storyline-derived tests
41
41
 
42
+ > **Note beats**: an entry with `type: STORYLINE_NOTE` is a note the board author placed in the walkthrough — its `description` is markdown context (an assumption, an external condition, a time gap), and its `id` is not a board node. Read it as background for the beats around it, but it is never a step: skip it when looking for COMMAND/EVENT/READMODEL beats or judging which beats are adjacent, and never turn it into test setup or an assertion on its own.
43
+
42
44
  If `storylines[]` includes a beat sequence running through this automation's trigger → this slice's command, the command test for that segment is already covered by build-state-change's own "Storyline-derived tests" step.
43
45
 
44
46
  ## Step 3 — Find the trigger point
@@ -77,6 +77,8 @@ Name each test after the specification title so it can be traced back to the boa
77
77
 
78
78
  ### Storyline-derived tests (optional)
79
79
 
80
+ > **Note beats**: an entry with `type: STORYLINE_NOTE` is a note the board author placed in the walkthrough — its `description` is markdown context (an assumption, an external condition, a time gap), and its `id` is not a board node. Read it as background for the beats around it, but it is never a step: skip it when looking for COMMAND/EVENT/READMODEL beats or judging which beats are adjacent, and never turn it into test setup or an assertion on its own.
81
+
80
82
  `storylines[]` in slice.json (optional — only present when the board author built an explicit walkthrough) can supply additional tests. A storyline embedded in this slice's slice.json already belongs entirely to this slice — no need to match beats against `commands[]` by id/title. Scan each storyline's ordered `elements` for a `type: COMMAND` beat followed by its EVENT beat(s):
81
83
 
82
84
  - `given` — the cumulative ordered events from the start of the storyline up to (not including) the command beat, set up as state as above
@@ -80,6 +80,8 @@ Name each test after the specification title.
80
80
 
81
81
  ### Storyline-derived tests (optional)
82
82
 
83
+ > **Note beats**: an entry with `type: STORYLINE_NOTE` is a note the board author placed in the walkthrough — its `description` is markdown context (an assumption, an external condition, a time gap), and its `id` is not a board node. Read it as background for the beats around it, but it is never a step: skip it when looking for COMMAND/EVENT/READMODEL beats or judging which beats are adjacent, and never turn it into test setup or an assertion on its own.
84
+
83
85
  `storylines[]` in slice.json is optional — present only when the board author explicitly built a walkthrough for this flow. When present, mine it for **additional** tests, on top of (never instead of) the specification tests.
84
86
 
85
87
  A storyline is `{ id, title, elements: [...] }`, where `elements` is an ordered list of beats — the same element can repeat to show its state at different points in the flow. A storyline embedded in this slice's slice.json already belongs entirely to this slice — no need to match beats against `readmodels[]` by id/title. Scan for a pair of adjacent beats that are both `type: READMODEL`, with only EVENT beat(s) between them and no COMMAND beat in that run. That pair is one self-contained test: