@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.
- 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 +38 -1
- package/stacks/axon/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/axon/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/axon/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +69 -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/stacks/node/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/node/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/node/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/supabase/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +2 -0
- package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +2 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +2 -0
- 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:
|
|
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));
|
|
@@ -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 `
|
|
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:
|