@eventmodelers/cli 2.0.1 → 2.0.2

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 (72) hide show
  1. package/README.md +26 -9
  2. package/cli.js +320 -29
  3. package/lib/build-kits.js +23 -0
  4. package/lib/scaffold-model.js +71 -0
  5. package/lib/scaffold.js +224 -0
  6. package/lib/supervise.js +60 -0
  7. package/lib/ui-baseline.js +123 -0
  8. package/package.json +1 -1
  9. package/shared/build-kit/lib/events-view.js +16 -3
  10. package/shared/build-kit/lib/ralph.js +19 -15
  11. package/shared/build-kit/lib/slice-run.js +2 -2
  12. package/shared/build-kit/lib/slice-state.js +5 -3
  13. package/shared/scaffold/LICENSE-em-scaffold +21 -0
  14. package/shared/scaffold/supabase/generate.mjs +290 -0
  15. package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
  16. package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
  17. package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
  18. package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
  19. package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
  20. package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
  21. package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
  22. package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
  23. package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
  24. package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
  25. package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
  26. package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
  27. package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
  28. package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
  29. package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
  30. package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
  31. package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
  32. package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
  33. package/shared/skills/learn-eventmodelers-api/SKILL.md +4 -4
  34. package/shared/skills/ui-contract/SKILL.md +6 -6
  35. package/shared/skills/update-slice-status/SKILL.md +1 -1
  36. package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
  37. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +1 -1
  38. package/stacks/axon/templates/build-kit/lib/prompt.md +14 -0
  39. package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
  40. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +1 -1
  41. package/stacks/blank/templates/build-kit/lib/prompt.md +14 -0
  42. package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
  43. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +1 -1
  44. package/stacks/classic/templates/build-kit/lib/prompt.md +14 -0
  45. package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
  46. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +1 -1
  47. package/stacks/kurrent/templates/build-kit/lib/prompt.md +14 -0
  48. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  49. package/stacks/modeling-kit/templates/kit/CLAUDE.md +9 -4
  50. package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
  51. package/stacks/node/templates/build-kit/lib/backend-prompt.md +1 -1
  52. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
  53. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +1 -1
  54. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +14 -0
  55. package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
  56. package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
  57. package/stacks/react/templates/build-kit/lib/backend-prompt.md +7 -7
  58. package/stacks/react/templates/build-kit/lib/kit.json +1 -1
  59. package/stacks/react/templates/build-kit/lib/prompt.md +29 -2
  60. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
  61. package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
  62. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +1 -1
  63. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
  64. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
  65. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
  66. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
  67. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +7 -7
  68. package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
  69. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +30 -3
  70. package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
  71. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +1 -1
  72. package/stacks/umadb/templates/build-kit/lib/prompt.md +14 -0
@@ -0,0 +1,71 @@
1
+ // Builds the `model.json` the scaffold generators read, from the board's REST API.
2
+ // `api` is `{ get(path), post(path, body) }` rooted at /api/org/:orgId/boards/:boardId, so this
3
+ // stays testable without a server.
4
+
5
+ const SCREEN_BATCH = 20;
6
+ // Class names that only a Bulma mockup uses; any one in a screen's HTML means the screen is Bulma.
7
+ const BULMA_CLASS = /class="[^"]*\b(navbar|is-primary|modal-card|columns|box)\b/;
8
+
9
+ // A route is only taken when the screen's description states one ("route /orders/:orderId").
10
+ export function routeFrom(description) {
11
+ const m = /\broute\s+(\/[^\s,;)]*)/i.exec(description ?? '');
12
+ return m ? m[1].replace(/[.]+$/, '') : undefined;
13
+ }
14
+
15
+ export function isHomeScreen(description) {
16
+ return /\bhome\s*page\b|\b(?:page|screen)\s+(?:shown\s+)?after\s+sign-?\s?in\b|\bshown\s+after\s+sign-?\s?in\b/i.test(description ?? '');
17
+ }
18
+
19
+ const descriptionOf = (node) => node.description ?? node.meta?.description ?? node.data?.description ?? '';
20
+
21
+ export async function buildModel({ api, appTitle }) {
22
+ const { chapters = [] } = await api.get('/chapters/bounds');
23
+ const timelines = chapters.filter((c) => c.title).map(({ title, x, y }) => ({ title, x, y }));
24
+
25
+ // Timelines of one context return the same slices, so slices are keyed by id.
26
+ const slicesById = new Map();
27
+ for (const t of timelines) {
28
+ const { slices = [] } = await api.get(`/slicedata?contextName=${encodeURIComponent(t.title)}&projection=outline`);
29
+ for (const s of slices) {
30
+ if (s.title && !slicesById.has(s.id ?? s.title)) slicesById.set(s.id ?? s.title, s);
31
+ }
32
+ }
33
+
34
+ // One screen node id per distinct screen title is enough to read route, home flag and CSS.
35
+ const screenIdByTitle = new Map();
36
+ for (const s of slicesById.values()) {
37
+ for (const screen of s.screens ?? []) {
38
+ if (screen?.title && screen.id && !screenIdByTitle.has(screen.title)) screenIdByTitle.set(screen.title, screen.id);
39
+ }
40
+ }
41
+ const titleById = new Map([...screenIdByTitle].map(([title, id]) => [id, title]));
42
+ const ids = [...screenIdByTitle.values()];
43
+ const screens = [];
44
+ let bulma = false;
45
+ for (let i = 0; i < ids.length; i += SCREEN_BATCH) {
46
+ const nodes = await api.post('/nodes/query', { ids: ids.slice(i, i + SCREEN_BATCH) });
47
+ for (const node of nodes) {
48
+ const title = titleById.get(node.id);
49
+ if (!title) continue;
50
+ const description = descriptionOf(node);
51
+ const route = routeFrom(description);
52
+ const home = isHomeScreen(description);
53
+ screens.push({ title, ...(route ? { route } : {}), ...(home ? { home: true } : {}) });
54
+ const pages = Array.isArray(node.meta?.pages) ? node.meta.pages : [];
55
+ if (pages.some((html) => typeof html === 'string' && BULMA_CLASS.test(html))) bulma = true;
56
+ }
57
+ }
58
+
59
+ return {
60
+ appTitle: appTitle || 'App',
61
+ css: bulma ? 'bulma' : 'none',
62
+ timelines,
63
+ slices: [...slicesById.values()].map((s) => ({
64
+ title: s.title,
65
+ sliceType: s.sliceType,
66
+ chapter: s.chapter || s.context,
67
+ screens: (s.screens ?? []).map((sc) => sc.title).filter(Boolean),
68
+ })),
69
+ screens,
70
+ };
71
+ }
@@ -0,0 +1,224 @@
1
+ // `eventmodelers scaffold`: turns a board into a runnable app skeleton the build kits then add
2
+ // slices to. A scaffold *type* names the kits it installs, the tools its app needs and the
3
+ // generator that writes the skeleton around them — adding a type (e.g. a Java one) is adding an
4
+ // entry to SCAFFOLD_TYPES plus a generator under shared/scaffold/<type>/.
5
+ //
6
+ // The skeleton generators are adapted from the em-scaffold skill by Adrian Mowat (MIT,
7
+ // shared/scaffold/LICENSE-em-scaffold).
8
+ import { execFileSync } from 'node:child_process';
9
+ import { createServer } from 'node:net';
10
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join, relative, resolve, sep } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const SCAFFOLD_DIR = join(here, '..', 'shared', 'scaffold');
16
+
17
+ export const SCAFFOLD_TYPES = {
18
+ supabase: {
19
+ label: 'Supabase backend + React front end (runs supabase + supabase-react)',
20
+ // Installed in order; `dir` is relative to the target.
21
+ kits: [
22
+ { stack: 'supabase', dir: '.' },
23
+ { stack: 'supabase-react', dir: 'web' },
24
+ ],
25
+ generator: join(SCAFFOLD_DIR, 'supabase', 'generate.mjs'),
26
+ tools: [
27
+ { command: 'docker', name: 'Docker' },
28
+ { command: 'supabase', name: 'Supabase CLI' },
29
+ { command: 'flyway', name: 'Flyway' },
30
+ { command: 'java', name: 'Java (Flyway runtime)' },
31
+ ],
32
+ minNode: 22,
33
+ // Command (run in the app root) that adds a sign-in user; omit it for a type without sign-in — `scaffold add-user` is then a no-op.
34
+ addUser: { requires: 'scripts/create-user.mjs', command: (email, password) => [process.execPath, 'scripts/create-user.mjs', email, password] },
35
+ // Run in the target after the generator; the first failure stops the run.
36
+ installSteps: [
37
+ ['npm', 'install'],
38
+ ['npm', '--prefix', 'web', 'install'],
39
+ ],
40
+ checkSteps: [
41
+ ['npx', 'tsc', '--noEmit'],
42
+ ['npx', 'tsx', '--test', 'src/common/notImplemented.test.ts'],
43
+ ['npm', '--prefix', 'web', 'run', 'build'],
44
+ ['npm', '--prefix', 'web', 'run', 'lint'],
45
+ ],
46
+ // One entry per port the generator takes. `flag` is passed to it with the picked value, `saved` is the
47
+ // key it writes to skeleton.json (so a refresh keeps its ports), `occupies` lists every TCP port the value
48
+ // needs free (default: itself), `render` turns it into the flag's value. A Supabase prefix NNN owns
49
+ // NNN20..NNN29 and can't go above 654.
50
+ ports: [
51
+ { name: 'api', flag: '--api-port', saved: 'apiPort', start: 3100 },
52
+ { name: 'web', flag: '--web-port', saved: 'webPort', start: 5273 },
53
+ { name: 'db', flag: '--db-port-prefix', saved: 'dbPortPrefix', start: 564, limit: 654,
54
+ occupies: (n) => Array.from({ length: 10 }, (_, i) => n * 100 + 20 + i) },
55
+ ],
56
+ },
57
+ };
58
+
59
+ const walkFiles = (dir) => readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walkFiles(join(dir, n)) : [join(dir, n)]));
60
+
61
+ // The type's built-in templates as template path (the path they generate, always '/') -> source file.
62
+ export function builtInTemplates(type) {
63
+ const dir = join(dirname(type.generator), 'template');
64
+ return new Map(walkFiles(dir).sort().map((file) => [relative(dir, file).split(sep).join('/').replace(/\.tmpl$/, ''), file]));
65
+ }
66
+
67
+ // Copies built-in templates into `dest` so they can be edited there; the generator then uses what is in `dest`
68
+ // and falls back to the built-in template for everything else. `only` limits it to paths (a file, or a folder
69
+ // prefix); an existing file is kept unless `force`.
70
+ export function initTemplates(type, dest, { only = [], force = false } = {}) {
71
+ const all = builtInTemplates(type);
72
+ const wanted = only.map((p) => p.replace(/\\/g, '/').replace(/\.tmpl$/, '').replace(/\/+$/, ''));
73
+ const unknown = wanted.filter((p) => ![...all.keys()].some((rel) => rel === p || rel.startsWith(`${p}/`)));
74
+ if (unknown.length) throw new Error(`no built-in template at ${unknown.map((p) => `"${p}"`).join(', ')} — \`init-templates --list\` shows them`);
75
+ const copied = [];
76
+ const kept = [];
77
+ for (const [rel, file] of all) {
78
+ if (wanted.length && !wanted.some((p) => rel === p || rel.startsWith(`${p}/`))) continue;
79
+ const out = join(dest, `${rel}.tmpl`);
80
+ if (existsSync(out) && !force) { kept.push(rel); continue; }
81
+ mkdirSync(dirname(out), { recursive: true });
82
+ copyFileSync(file, out);
83
+ copied.push(rel);
84
+ }
85
+ return { copied, kept };
86
+ }
87
+
88
+ export function scaffoldTypeNames() {
89
+ return Object.keys(SCAFFOLD_TYPES);
90
+ }
91
+
92
+ // How to start `file` on this platform. On Windows npm and npx are .cmd shims that only start through a
93
+ // shell (Node refuses to spawn them directly), so they get one; everything else is a plain executable.
94
+ export function invocation(file, args, platform = process.platform) {
95
+ const shimmed = platform === 'win32' && ['npm', 'npx'].includes(file);
96
+ return shimmed ? { file: [file, ...args].join(' '), args: [], shell: true } : { file, args, shell: false };
97
+ }
98
+
99
+ // The command that answers "is `command` on the PATH": `where` on Windows (it honours PATHEXT), `command -v` elsewhere.
100
+ export function probe(command, platform = process.platform) {
101
+ return platform === 'win32' ? { file: 'where', args: [command] } : { file: 'sh', args: ['-c', `command -v ${command}`] };
102
+ }
103
+
104
+ export function hasCommand(command) {
105
+ const { file, args } = probe(command);
106
+ try {
107
+ execFileSync(file, args, { stdio: 'ignore' });
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ // What the type needs that this machine lacks, as printable names. Node is checked by version.
115
+ export function missingPrerequisites(type, { nodeVersion = process.versions.node, has = hasCommand } = {}) {
116
+ const missing = [];
117
+ if (Number(nodeVersion.split('.')[0]) < type.minNode) missing.push(`Node ${type.minNode} or newer (found ${nodeVersion})`);
118
+ for (const tool of type.tools) if (!has(tool.command)) missing.push(tool.name);
119
+ return missing;
120
+ }
121
+
122
+ // Free means nothing listens on any interface, which is what a Docker-published port looks like too.
123
+ export function isPortFree(port) {
124
+ return new Promise((done) => {
125
+ const server = createServer();
126
+ server.once('error', () => done(false));
127
+ server.listen(port, () => server.close(() => done(true)));
128
+ });
129
+ }
130
+
131
+ // The first value from the spec's start whose ports are all free, one spec at a time. Specs picked in the
132
+ // same run don't share ports as long as their ranges differ, which they do for every type so far.
133
+ export async function pickPorts(specs, { free = isPortFree } = {}) {
134
+ const picked = {};
135
+ for (const spec of specs) {
136
+ const occupies = spec.occupies ?? ((n) => [n]);
137
+ const limit = spec.limit ?? spec.start + 100;
138
+ let value = spec.start;
139
+ while (value <= limit && !(await Promise.all(occupies(value).map(free))).every(Boolean)) value++;
140
+ if (value > limit) throw new Error(`no free "${spec.name}" port from ${spec.start} up to ${limit}`);
141
+ picked[spec.name] = String(value);
142
+ }
143
+ return picked;
144
+ }
145
+
146
+ // An empty/new directory or an earlier skeleton (has skeleton.json) is fair game; anything else is not ours.
147
+ export function targetState(target) {
148
+ if (!existsSync(target)) return 'new';
149
+ if (existsSync(join(target, 'skeleton.json'))) return 'skeleton';
150
+ return readdirSync(target).length === 0 ? 'new' : 'foreign';
151
+ }
152
+
153
+ function run(file, args, { cwd, env, quiet = false } = {}) {
154
+ const inv = invocation(file, args);
155
+ execFileSync(inv.file, inv.args, { cwd, env: { ...process.env, ...env }, stdio: quiet ? 'ignore' : 'inherit', shell: inv.shell });
156
+ }
157
+
158
+ // cfg: { token, organizationId, boardId, baseUrl }. Credentials reach the child installers through
159
+ // EVENTMODELERS_* env vars so the token never shows up in a process list.
160
+ // templates: a folder of *.tmpl files overriding the type's built-in templates; when omitted, `<target>/templates`
161
+ // is used if it exists, so a project's customisations survive every refresh.
162
+ export async function runScaffold({ typeName, target, cfg, model, cliPath, install = true, check = true, templates, ports: portOverrides = {}, log = console.log }) {
163
+ const type = SCAFFOLD_TYPES[typeName];
164
+ const root = resolve(target);
165
+ const state = targetState(root);
166
+ if (state === 'foreign') {
167
+ throw new Error(`${root} is not empty and is not a scaffolded skeleton — pass an empty or new directory`);
168
+ }
169
+
170
+ const unknown = Object.keys(portOverrides).filter((n) => !type.ports.some((p) => p.name === n));
171
+ if (unknown.length) {
172
+ throw new Error(`unknown port ${unknown.map((n) => `"${n}"`).join(', ')} for type ${typeName} (it has: ${type.ports.map((p) => p.name).join(', ')})`);
173
+ }
174
+ // A refresh keeps the ports the skeleton already runs on; only a new one picks free ports.
175
+ const previous = state === 'skeleton' ? JSON.parse(readFileSync(join(root, 'skeleton.json'), 'utf8')) : {};
176
+ const pending = type.ports.filter((p) => previous[p.saved] === undefined && portOverrides[p.name] === undefined);
177
+ const picked = await pickPorts(pending);
178
+ const ports = Object.fromEntries(type.ports.map((p) => [p.name, String(portOverrides[p.name] ?? previous[p.saved] ?? picked[p.name])]));
179
+
180
+ for (const kit of type.kits) mkdirSync(join(root, kit.dir), { recursive: true });
181
+ if (!existsSync(join(root, '.git'))) run('git', ['init', '-q'], { cwd: root });
182
+
183
+ const env = {
184
+ EVENTMODELERS_TOKEN: cfg.token,
185
+ EVENTMODELERS_ORGANIZATION_ID: cfg.organizationId,
186
+ EVENTMODELERS_BOARD_ID: cfg.boardId,
187
+ ...(cfg.baseUrl ? { EVENTMODELERS_BASE_URL: cfg.baseUrl } : {}),
188
+ };
189
+ for (const kit of type.kits) {
190
+ const cwd = join(root, kit.dir);
191
+ if (existsSync(join(cwd, '.build-kit'))) {
192
+ log(`▶ ${kit.stack}: already installed in ${kit.dir}, skipping`);
193
+ continue;
194
+ }
195
+ log(`▶ Installing the ${kit.stack} kit in ${kit.dir}`);
196
+ run(process.execPath, [cliPath, 'init', '--stack', kit.stack, '--print'], { cwd, env });
197
+ }
198
+
199
+ // Next to the target, not in it: the model is an input of the skeleton, not a project file.
200
+ const modelFile = `${root}.model.json`;
201
+ writeFileSync(modelFile, JSON.stringify(model, null, 2) + '\n');
202
+ const templatesDir = templates ? resolve(templates) : existsSync(join(root, 'templates')) ? join(root, 'templates') : null;
203
+ if (templates && !existsSync(templatesDir)) throw new Error(`--templates ${templatesDir} does not exist`);
204
+ log(`▶ Generating the skeleton (model: ${modelFile}${templatesDir ? `, templates: ${templatesDir}` : ''})`);
205
+ run(process.execPath, [
206
+ type.generator, '--model', modelFile, '--target', root,
207
+ ...type.ports.flatMap((p) => [p.flag, ports[p.name]]),
208
+ ...(templatesDir ? ['--templates', templatesDir] : []),
209
+ ]);
210
+
211
+ if (install) {
212
+ for (const [file, ...args] of type.installSteps) run(file, args, { cwd: root });
213
+ if (check) {
214
+ for (const [file, ...args] of type.checkSteps) run(file, args, { cwd: root });
215
+ }
216
+ try {
217
+ run('git', ['add', '-A'], { cwd: root });
218
+ run('git', ['commit', '-q', '-m', 'chore: app skeleton from the event model'], { cwd: root });
219
+ } catch {
220
+ log('ℹ️ Nothing to commit (or git refused) — commit the skeleton yourself.');
221
+ }
222
+ }
223
+ return { root, ports, modelFile };
224
+ }
@@ -0,0 +1,60 @@
1
+ // Runs several long-lived processes side by side in one terminal: output prefixed with the process name,
2
+ // Ctrl+c (or any one of them exiting) stops all of them, children included.
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { invocation } from './scaffold.js';
5
+
6
+ // Stops a process and everything it started. POSIX: the child leads its own process group, so one signal
7
+ // reaches the tree. Windows has no groups, taskkill walks the tree instead.
8
+ export function killTree(pid, signal = 'SIGINT', platform = process.platform) {
9
+ if (platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
10
+ else try { process.kill(-pid, signal); } catch {}
11
+ }
12
+
13
+ // specs: [{ name, file, args, cwd }]. Resolves with the exit code once everything has stopped.
14
+ export function supervise(specs, { log = console.log, graceMs = 3000, handleSignals = true } = {}) {
15
+ const width = Math.max(...specs.map((s) => s.name.length));
16
+ return new Promise((done) => {
17
+ let stopping = false;
18
+ let exitCode = 0;
19
+ let running = specs.length;
20
+ const children = [];
21
+
22
+ const finish = () => done(exitCode);
23
+ const stop = (code = 0) => {
24
+ if (stopping) return;
25
+ stopping = true;
26
+ exitCode = code;
27
+ for (const child of children) if (child.exitCode === null) killTree(child.pid);
28
+ setTimeout(finish, graceMs).unref();
29
+ };
30
+
31
+ for (const spec of specs) {
32
+ const inv = invocation(spec.file, spec.args);
33
+ // POSIX: detached makes the child its own group leader (see killTree). Windows: it would open a console window.
34
+ const child = spawn(inv.file, inv.args, { cwd: spec.cwd, shell: inv.shell, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
35
+ children.push(child);
36
+ for (const out of [child.stdout, child.stderr]) {
37
+ let rest = '';
38
+ out.on('data', (chunk) => {
39
+ const lines = (rest + chunk).split('\n');
40
+ rest = lines.pop();
41
+ for (const line of lines) log(`${spec.name.padEnd(width)} | ${line}`);
42
+ });
43
+ }
44
+ child.on('error', (err) => {
45
+ log(`${spec.name.padEnd(width)} | failed to start: ${err.message}`);
46
+ stop(1);
47
+ });
48
+ child.on('exit', (code) => {
49
+ if (!stopping) log(`${spec.name.padEnd(width)} | exited (${code}), stopping the rest`);
50
+ stop(code ?? 0);
51
+ if (--running === 0) finish();
52
+ });
53
+ }
54
+
55
+ if (handleSignals) {
56
+ process.once('SIGINT', () => stop());
57
+ process.once('SIGTERM', () => stop());
58
+ }
59
+ });
60
+ }
@@ -0,0 +1,123 @@
1
+ // Decides whether a slice's UI needs work: a hash of what the board says about the UI (the screen nodes'
2
+ // node hash, the user's `codeGen.uiPrompts`) is recorded after each
3
+ // successful UI build, and compared on the next one. Only a difference sends the agent back to the UI.
4
+ import { createHash } from 'crypto';
5
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+
8
+ export const BASELINE_FILE = 'ui-baseline.json';
9
+
10
+ const sha = (value) => createHash('sha256').update(stable(value)).digest('hex').slice(0, 16);
11
+
12
+ // JSON with sorted keys, so key order on the board never reads as a change.
13
+ function stable(value) {
14
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
15
+ if (value && typeof value === 'object') {
16
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
17
+ }
18
+ return JSON.stringify(value ?? null);
19
+ }
20
+
21
+ const readJson = (path) => {
22
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
23
+ };
24
+
25
+ // The slice's folder under `.slices/`, found by id or (case-insensitive) title.
26
+ export function findSliceDir(kitDir, idOrTitle) {
27
+ const slicesDir = join(kitDir, '.slices');
28
+ if (!existsSync(slicesDir)) return null;
29
+ const wanted = String(idOrTitle).trim().toLowerCase();
30
+ for (const ctx of readdirSync(slicesDir, { withFileTypes: true })) {
31
+ if (!ctx.isDirectory()) continue;
32
+ for (const dir of readdirSync(join(slicesDir, ctx.name), { withFileTypes: true })) {
33
+ if (!dir.isDirectory()) continue;
34
+ const path = join(slicesDir, ctx.name, dir.name);
35
+ const slice = readJson(join(path, 'slice.json'));
36
+ if (slice && (slice.id === idOrTitle || slice.title?.toLowerCase() === wanted)) return { path, slice };
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ // Lists of the slice that are not board elements (or are hashed on their own).
43
+ const NOT_ELEMENTS = new Set(['screens', 'comments', 'screenImages']);
44
+ const BATCH = 50;
45
+
46
+ // `fetchNodes(ids)` -> the board's nodes for those ids (POST /nodes/query).
47
+ // The board stamps every node with a `hash`; use it as is. Content hash only as a fallback, for a board
48
+ // (or a /nodes/query) that does not return it.
49
+ // scope 'backend' (a kit that builds no UI) leaves out the screens and the UI prompts: a screen edit must not
50
+ // make it rebuild.
51
+ export async function fingerprint({ slice, fetchNodes, scope = 'ui' }) {
52
+ const ui = scope !== 'backend';
53
+ const prompts = ui ? slice.codeGen?.uiPrompts ?? [] : [];
54
+ const screens = ui ? (slice.screens ?? []).filter((s) => s?.id) : [];
55
+ // Every element the slice carries — commands, events, read models, processors, tables, actors, aggregates,
56
+ // specifications, ... — so a new list the export grows is covered without touching this.
57
+ const elements = Object.entries(slice)
58
+ .filter(([k, v]) => !NOT_ELEMENTS.has(k) && Array.isArray(v))
59
+ .flatMap(([, v]) => v)
60
+ .filter((e) => e && typeof e === 'object' && e.id);
61
+ const ids = [...new Set([...screens, ...elements].map((e) => e.id))];
62
+ const byId = new Map();
63
+ for (let i = 0; i < ids.length; i += BATCH) {
64
+ for (const node of await fetchNodes(ids.slice(i, i + BATCH))) byId.set(node.id, node);
65
+ }
66
+ const missing = screens.filter((s) => !byId.has(s.id)).map((s) => s.title ?? s.id);
67
+ if (missing.length) throw new Error(`screen node(s) not found on the board: ${missing.join(', ')}`);
68
+ // One entry per node, so a later check can say *which* ones moved, not just that something did.
69
+ const entry = (kind) => (item) => {
70
+ const node = byId.get(item.id);
71
+ const base = { id: item.id, kind, title: item.title ?? node?.node?.data?.title ?? node?.data?.title ?? item.id };
72
+ if (!node) return { ...base, hash: 'missing' };
73
+ if (node.hash) return { ...base, hash: node.hash };
74
+ return { ...base, hash: sha({ content: node.meta ?? node.data ?? null, fields: item.fields ?? [] }) };
75
+ };
76
+ const screenNodes = screens.map(entry('screen'));
77
+ const elementNodes = Object.entries(slice)
78
+ .filter(([k, v]) => !NOT_ELEMENTS.has(k) && Array.isArray(v))
79
+ .flatMap(([k, v]) => v.filter((e) => e && typeof e === 'object' && e.id).map(entry(k)));
80
+ return {
81
+ screen: screenNodes.length ? sha(screenNodes.map((n) => [n.id, n.hash])) : null,
82
+ elements: elementNodes.length ? sha(elementNodes.map((n) => [n.id, n.hash])) : null,
83
+ prompts: sha(prompts),
84
+ promptList: prompts,
85
+ nodes: [...screenNodes, ...elementNodes],
86
+ };
87
+ }
88
+
89
+ export function readBaseline(sliceDir) {
90
+ return readJson(join(sliceDir, BASELINE_FILE));
91
+ }
92
+
93
+ export function writeBaseline(sliceDir, fp, scope = 'ui') {
94
+ const baseline = { scope, screenHash: fp.screen, elementsHash: fp.elements, uiPromptsHash: fp.prompts, uiPrompts: fp.promptList, nodes: fp.nodes, recordedAt: new Date().toISOString() };
95
+ writeFileSync(join(sliceDir, BASELINE_FILE), JSON.stringify(baseline, null, 2) + '\n');
96
+ return baseline;
97
+ }
98
+
99
+ // What differs node by node, against a baseline that recorded its nodes ({id, kind, title, hash}).
100
+ function diffNodes(before, now) {
101
+ const was = new Map(before.map((n) => [n.id, n]));
102
+ const is = new Map(now.map((n) => [n.id, n]));
103
+ return {
104
+ changedNodes: now.filter((n) => was.has(n.id) && was.get(n.id).hash !== n.hash),
105
+ addedNodes: now.filter((n) => !was.has(n.id)),
106
+ removedNodes: before.filter((n) => !is.has(n.id)),
107
+ };
108
+ }
109
+
110
+ // Verdict: NO_BASELINE | UNCHANGED | CHANGED, with which parts changed, which nodes, and the prompts that are new.
111
+ // A baseline from before elements/nodes were tracked has no elementsHash/nodes — that is not a change.
112
+ export function compare(baseline, fp, scope = 'ui') {
113
+ const none = { changedNodes: [], addedNodes: [], removedNodes: [] };
114
+ // A baseline recorded under another scope says nothing about this one.
115
+ if (!baseline || (baseline.scope ?? 'ui') !== scope) return { verdict: 'NO_BASELINE', screenChanged: true, elementsChanged: true, promptsChanged: fp.promptList.length > 0, newPrompts: fp.promptList, ...none };
116
+ const screenChanged = baseline.screenHash !== fp.screen;
117
+ const elementsChanged = baseline.elementsHash !== undefined && baseline.elementsHash !== fp.elements;
118
+ const promptsChanged = baseline.uiPromptsHash !== fp.prompts;
119
+ const seen = new Set(baseline.uiPrompts ?? []);
120
+ const newPrompts = promptsChanged ? fp.promptList.filter((p) => !seen.has(p)) : [];
121
+ const nodes = Array.isArray(baseline.nodes) ? diffNodes(baseline.nodes, fp.nodes) : none;
122
+ return { verdict: screenChanged || elementsChanged || promptsChanged ? 'CHANGED' : 'UNCHANGED', screenChanged, elementsChanged, promptsChanged, newPrompts, ...nodes };
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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": {
@@ -85,10 +85,23 @@ export function formatPayload(e) {
85
85
  return JSON.stringify(rest, null, 2).split('\n').map((l) => ` ${l}`).join('\n');
86
86
  }
87
87
 
88
+ // The kit's first look at each slice: its first `board-observed` event. A fresh kit records every slice already on
89
+ // the board this way, which says nothing happened — the ids to leave out of the default view of the log.
90
+ export function baselineIds(events) {
91
+ const seen = new Set();
92
+ const ids = new Set();
93
+ for (const e of events) {
94
+ if (e.type !== 'board-observed' || !e.slice || seen.has(e.slice)) continue;
95
+ seen.add(e.slice);
96
+ ids.add(e.id);
97
+ }
98
+ return ids;
99
+ }
100
+
88
101
  // types / slice (an id prefix or part of a title) / tag / board (an id prefix — a global kit's log mixes boards) narrow
89
- // the log; last keeps the final N that remain.
90
- export function filterEvents(events, { types, slice, tag, board, last } = {}, titles = {}) {
91
- let out = events;
102
+ // the log; omit is a set of event ids to leave out (see baselineIds); last keeps the final N that remain.
103
+ export function filterEvents(events, { types, slice, tag, board, last, omit } = {}, titles = {}) {
104
+ let out = omit?.size ? events.filter((e) => !omit.has(e.id)) : events;
92
105
  if (board) out = out.filter((e) => String(e.meta?.board_id ?? '').toLowerCase().startsWith(board.toLowerCase()));
93
106
  if (types?.length) out = out.filter((e) => types.includes(e.type));
94
107
  if (slice) {
@@ -15,7 +15,7 @@ import { randomUUID } from 'crypto';
15
15
  import { createRealtimeAdapter, keepSubscribed } from './adapters/realtime-adapter.js';
16
16
  import { backoffMs, sleep } from './backoff.js';
17
17
  import { adoptAgentEdits, observeBoard, projectStatus, statusOf } from './slice-projection.js';
18
- import { fold } from './slice-state.js';
18
+ import { fold, statusKey } from './slice-state.js';
19
19
  import { readEvents } from './slice-events.js';
20
20
  import { claimSlice, recordEvent, settleSlice, settleLeftoverClaims } from './slice-run.js';
21
21
  import { isTreeClean } from './slice-git.js';
@@ -273,8 +273,8 @@ export async function fetchAndPersistSlices(cfg, kitDir) {
273
273
  if (!activeCtx || !contexts[activeCtx]) {
274
274
  // First run (or the current context disappeared): seed with a context that
275
275
  // has planned work, else the first one. This is the ONLY place we choose it.
276
- const pickup = readPickupStatus(kitDir).toLowerCase();
277
- const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status || '').toLowerCase() === pickup));
276
+ const pickup = statusKey(readPickupStatus(kitDir));
277
+ const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => statusKey(s.status) === pickup));
278
278
  activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
279
279
  writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
280
280
  }
@@ -377,9 +377,9 @@ async function handleSliceChanged(payload, cfg, kitDir) {
377
377
  await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
378
378
  console.error('[agent] Slice persist error:', err),
379
379
  );
380
- // Slices in the kit's pickup status (Planned, or Handover for UI kits) are handled by
380
+ // Slices in the kit's pickup status (Planned, or Waiting for UI kits) are handled by
381
381
  // onPlannedSlice directly — no task needed.
382
- if ((payload.sliceStatus || '').toLowerCase() !== readPickupStatus(kitDir).toLowerCase()) {
382
+ if (statusKey(payload.sliceStatus) !== statusKey(readPickupStatus(kitDir))) {
383
383
  try {
384
384
  queueTask({ kitDir, payload, meta: eventMeta(cfg) });
385
385
  console.log(`[agent] Task queued — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
@@ -490,15 +490,19 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', createAdap
490
490
  // ── Ralph loop ────────────────────────────────────────────────────────────────
491
491
 
492
492
  // The slice status this kit claims work from. A stack declares it in `lib/kit.json`
493
- // ({ "pickupStatus": "Handover" } for the UI kits, which build what a backend kit handed over);
493
+ // ({ "pickupStatus": "Waiting" } for the UI kits, which build what a backend kit handed over);
494
494
  // absent means "Planned", the backend default. `run --status-trigger <status>` (RALPH_STATUS_TRIGGER)
495
495
  // overrides either for that run.
496
+ // `Handover` was renamed `Waiting`; a kit.json or --status-trigger written before that still says it,
497
+ // and a board served by an older platform still reports it.
498
+ const canonicalStatus = (s) => (statusKey(s) === 'waiting' ? 'Waiting' : s);
499
+
496
500
  export function readPickupStatus(kitDir) {
497
501
  const override = (process.env.RALPH_STATUS_TRIGGER || '').trim();
498
- if (override) return override;
502
+ if (override) return canonicalStatus(override);
499
503
  try {
500
504
  const s = JSON.parse(readFileSync(join(kitDir, 'lib', 'kit.json'), 'utf-8')).pickupStatus;
501
- if (typeof s === 'string' && s.trim()) return s.trim();
505
+ if (typeof s === 'string' && s.trim()) return canonicalStatus(s.trim());
502
506
  } catch {}
503
507
  return 'Planned';
504
508
  }
@@ -533,14 +537,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
533
537
  try {
534
538
  others = readdirSync(slicesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
535
539
  } catch {}
536
- const pickup = readPickupStatus(kitDir).toLowerCase();
540
+ const pickup = statusKey(readPickupStatus(kitDir));
537
541
  const candidates = [...(currentCtx ? [currentCtx] : []), ...others.filter((c) => c !== currentCtx)];
538
542
  for (const ctx of candidates) {
539
543
  const indexPath = join(slicesDir, ctx, 'index.json');
540
544
  if (!existsSync(indexPath)) continue;
541
545
  try {
542
546
  const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
543
- const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === pickup && !skipIds.has(s.id));
547
+ const planned = slices && slices.find((s) => statusKey(s.status) === pickup && !skipIds.has(s.id));
544
548
  if (!planned) continue;
545
549
  if (ctx !== currentCtx) {
546
550
  writeFileSync(join(slicesDir, 'current_context.json'), JSON.stringify({ name: ctx }, null, 2), 'utf-8');
@@ -555,14 +559,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
555
559
  // Ids of every slice currently waiting for pickup, across all contexts.
556
560
  function listPlannedIds(kitDir) {
557
561
  const slicesDir = join(kitDir, '.slices');
558
- const pickup = readPickupStatus(kitDir).toLowerCase();
562
+ const pickup = statusKey(readPickupStatus(kitDir));
559
563
  const ids = new Set();
560
564
  try {
561
565
  for (const d of readdirSync(slicesDir, { withFileTypes: true })) {
562
566
  if (!d.isDirectory()) continue;
563
567
  try {
564
568
  const { slices } = JSON.parse(readFileSync(join(slicesDir, d.name, 'index.json'), 'utf-8'));
565
- for (const s of slices ?? []) if ((s.status || '').toLowerCase() === pickup) ids.add(s.id);
569
+ for (const s of slices ?? []) if (statusKey(s.status) === pickup) ids.add(s.id);
566
570
  } catch {}
567
571
  }
568
572
  } catch {}
@@ -666,7 +670,7 @@ async function runWithRetry(label, fn) {
666
670
  // alone, the agent read it as background, answered "what would you like me to help with?" with
667
671
  // no tool calls, and a Planned slice stayed Planned until the stuck-slice guard blocked it. So
668
672
  // every turn ends with the concrete request — here, once, for every runner.
669
- // Whether a backend kit finishes UI-bearing slices at `Handover` instead of `Done`: this run's
673
+ // Whether a backend kit finishes UI-bearing slices at `Waiting` instead of `Done`: this run's
670
674
  // `run --handover` (RALPH_HANDOVER) over the project's `handover` setting in config.json.
671
675
  export function resolveHandover(cfg) {
672
676
  return process.env.RALPH_HANDOVER === '1' || cfg?.handover === true;
@@ -720,8 +724,8 @@ function taskRequest(planned = null, cfg = null, pickupStatus = 'Planned', queue
720
724
  ? `build the ${pickupStatus} slice "${planned.title}" (id=${planned.id}, context="${planned.ctx}")`
721
725
  : `process this queued task (id=${queued?.id}) — the slice change that queued it: ${JSON.stringify(queued?.payload ?? {})}`;
722
726
  // Stated explicitly so the agent never has to work it out from config.json: this run's effective
723
- // value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup Handover) ignore it.
724
- const handover = planned && pickupStatus.toLowerCase() !== 'handover' ? `\nHandover: ${resolveHandover(cfg) ? 'on' : 'off'}\n` : '';
727
+ // value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup Waiting) ignore it.
728
+ const handover = planned && statusKey(pickupStatus) !== 'waiting' ? `\nHandover: ${resolveHandover(cfg) ? 'on' : 'off'}\n` : '';
725
729
  return `\n\n---\n\nYour task now: ${task}, following the instructions above. Start immediately.\n${handover}`;
726
730
  }
727
731
 
@@ -6,7 +6,7 @@
6
6
  // no board (--local): nothing is pushed or read, the log alone decides.
7
7
 
8
8
  import { appendEvent, readEvents } from './slice-events.js';
9
- import { fold, decideSettle, decideRecovery } from './slice-state.js';
9
+ import { fold, decideSettle, decideRecovery, statusKey } from './slice-state.js';
10
10
  import { adoptAgentEdits, projectStatus } from './slice-projection.js';
11
11
  import { discardUnfinishedWork, gitBase } from './slice-git.js';
12
12
 
@@ -71,7 +71,7 @@ export async function claimSlice({ kitDir, planned, board, meta, cleanTree, pid
71
71
  console.error(`[ralph] Could not read the board before claiming "${planned.title}":`, err.message);
72
72
  return 'lost';
73
73
  }
74
- if (current && current.toLowerCase() !== pickupStatus.toLowerCase()) {
74
+ if (current && statusKey(current) !== statusKey(pickupStatus)) {
75
75
  console.log(`[ralph] "${planned.title}" is ${current} on the board, not ${pickupStatus} — someone else got there first.`);
76
76
  recordEvent(kitDir, { type: 'board-observed', slice: planned.id, status: current }, meta);
77
77
  return 'lost';