@eventmodelers/cli 1.0.163 → 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 (128) hide show
  1. package/README.md +48 -36
  2. package/RELEASE_NOTES.md +20 -2
  3. package/cli.js +774 -247
  4. package/lib/adapters/spec-kitty-adapter.js +3 -7
  5. package/lib/build-kits.js +23 -0
  6. package/lib/global-agent-id.js +45 -0
  7. package/lib/modeling-local-ai.js +4 -2
  8. package/lib/scaffold-model.js +71 -0
  9. package/lib/scaffold.js +224 -0
  10. package/lib/supervise.js +60 -0
  11. package/lib/ui-baseline.js +123 -0
  12. package/package.json +1 -1
  13. package/shared/build-kit/README.md +4 -19
  14. package/shared/build-kit/lib/agent-lock.js +75 -0
  15. package/shared/build-kit/lib/agent-name.js +27 -0
  16. package/shared/build-kit/lib/event-prune.js +58 -0
  17. package/shared/build-kit/lib/events-view.js +134 -0
  18. package/shared/build-kit/lib/local-ai-agent.js +14 -26
  19. package/shared/build-kit/lib/mcp-register.js +17 -1
  20. package/shared/build-kit/lib/prompt-journal.js +191 -0
  21. package/shared/build-kit/lib/prompt-replay.js +35 -0
  22. package/shared/build-kit/lib/ralph.js +218 -169
  23. package/shared/build-kit/lib/slice-curation.js +40 -0
  24. package/shared/build-kit/lib/slice-events.js +93 -0
  25. package/shared/build-kit/lib/slice-git.js +86 -0
  26. package/shared/build-kit/lib/slice-learnings.js +197 -0
  27. package/shared/build-kit/lib/slice-projection.js +101 -0
  28. package/shared/build-kit/lib/slice-run.js +181 -0
  29. package/shared/build-kit/lib/slice-state.js +184 -0
  30. package/shared/build-kit/lib/slice-tasks.js +35 -0
  31. package/shared/build-kit/ralph-claude.js +4 -2
  32. package/shared/build-kit/ralph-exec.js +4 -2
  33. package/shared/build-kit/ralph-local-ai.js +5 -3
  34. package/shared/scaffold/LICENSE-em-scaffold +21 -0
  35. package/shared/scaffold/supabase/generate.mjs +290 -0
  36. package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
  37. package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
  38. package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
  39. package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
  40. package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
  41. package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
  42. package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
  43. package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
  44. package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
  45. package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
  46. package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
  47. package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
  48. package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
  49. package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
  50. package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
  51. package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
  52. package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
  53. package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
  54. package/shared/skills/learn-eventmodelers-api/SKILL.md +17 -4
  55. package/shared/skills/request-feedback/SKILL.md +1 -2
  56. package/shared/skills/ui-contract/SKILL.md +7 -5
  57. package/shared/skills/update-slice-status/SKILL.md +1 -1
  58. package/stacks/axon/templates/build-kit/CLAUDE.md +7 -6
  59. package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
  60. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
  61. package/stacks/axon/templates/build-kit/lib/prompt.md +32 -41
  62. package/stacks/blank/templates/build-kit/CLAUDE.md +7 -6
  63. package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
  64. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
  65. package/stacks/blank/templates/build-kit/lib/prompt.md +33 -42
  66. package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
  67. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  68. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  69. package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
  70. package/stacks/classic/templates/build-kit/CLAUDE.md +9 -8
  71. package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
  72. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
  73. package/stacks/classic/templates/build-kit/lib/prompt.md +33 -42
  74. package/stacks/kurrent/templates/build-kit/CLAUDE.md +7 -7
  75. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
  76. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
  77. package/stacks/kurrent/templates/build-kit/lib/prompt.md +32 -41
  78. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  79. package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
  80. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
  81. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
  82. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
  83. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
  84. package/stacks/modeling-kit/templates/kit/CLAUDE.md +18 -28
  85. package/stacks/modeling-kit/templates/kit/README.md +1 -1
  86. package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
  87. package/stacks/node/templates/build-kit/CLAUDE.md +7 -6
  88. package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
  89. package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
  90. package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
  91. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +7 -7
  92. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
  93. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
  94. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +32 -41
  95. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  96. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
  97. package/stacks/react/templates/build-kit/CLAUDE.md +7 -6
  98. package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
  99. package/stacks/react/templates/build-kit/lib/backend-prompt.md +25 -42
  100. package/stacks/react/templates/build-kit/lib/kit.json +1 -1
  101. package/stacks/react/templates/build-kit/lib/prompt.md +47 -43
  102. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
  103. package/stacks/supabase/templates/build-kit/CLAUDE.md +7 -6
  104. package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
  105. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
  106. package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
  107. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +80 -8
  108. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +66 -13
  109. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +8 -7
  110. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
  111. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +25 -42
  112. package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
  113. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +48 -44
  114. package/stacks/umadb/templates/build-kit/CLAUDE.md +7 -6
  115. package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
  116. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
  117. package/stacks/umadb/templates/build-kit/lib/prompt.md +32 -41
  118. package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
  119. package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
  120. package/shared/build-kit/lib/agent.sh +0 -20
  121. package/shared/build-kit/ralph.sh +0 -111
  122. package/shared/build-kit/realtime-agent.js +0 -20
  123. package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
  124. package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
  125. package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
  126. package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
  127. package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
  128. package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
@@ -14,11 +14,8 @@
14
14
  // adapter's whole job is producing a good brief and calling `intake`, nothing
15
15
  // more.
16
16
  //
17
- // Lives here (not only inside a bridge-kit install) so it has exactly one
18
- // entry point regardless of caller: `eventmodelers fetch --spec-kitty` calls
19
- // it directly for a one-shot sync; `eventmodelers bridge` (via
20
- // ralph-static.js) calls the same run() from a copy the bridge-kit installer
21
- // places at .bridge-kit/adapters/ — see installStack in cli.js.
17
+ // Its one entry point is `eventmodelers fetch --spec-kitty`, a one-shot sync. (A standing bridge loop that
18
+ // ran the same run() from a copy in .bridge-kit/ is retired.)
22
19
  //
23
20
  // Exports run({ cfg, projectDir, contextName }).
24
21
 
@@ -43,8 +40,7 @@ function slugify(text) {
43
40
 
44
41
  // Mirrors lib/fetch.js's per-context call rather than importing runFetch
45
42
  // directly — this fetches raw JSON only, it never touches .slices/ (fetch.js
46
- // owns writing that), and a copy of this file also has to run standalone
47
- // inside an installed .bridge-kit/ with no access to lib/fetch.js at all.
43
+ // owns writing that).
48
44
  async function fetchFullSliceData(cfg, contextName) {
49
45
  const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
50
46
  const url = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?contextName=${encodeURIComponent(contextName)}`;
@@ -0,0 +1,23 @@
1
+ // Where the build kits of a project live: the root and up to two directory levels below it, so `run --all` finds
2
+ // both kits of a scaffolded app (root and web/) whether it is started in the app or in the folder holding it.
3
+ // Hidden directories and node_modules are not searched.
4
+ import { existsSync, readdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const MAX_DEPTH = 2;
8
+
9
+ export function findBuildKitDirs(root, kitDirNames) {
10
+ const has = (dir) => kitDirNames.some((name) => existsSync(join(dir, name)));
11
+ const found = [];
12
+ const visit = (dir, depth) => {
13
+ if (has(dir)) found.push(dir);
14
+ if (depth === MAX_DEPTH) return;
15
+ const children = readdirSync(dir, { withFileTypes: true })
16
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
17
+ .map((e) => join(dir, e.name))
18
+ .sort();
19
+ for (const child of children) visit(child, depth + 1);
20
+ };
21
+ visit(root, 0);
22
+ return found;
23
+ }
@@ -0,0 +1,45 @@
1
+ // A run on the global install keeps one agent id per board and repo — "repo" being the directory the run is
2
+ // started in (its git root, or the directory itself outside git). It is the same agent on every start: its chat
3
+ // sessions, its star on the board and the prompts it holds all stay addressed to it. The ids live in one small
4
+ // file next to the per-board credentials; nothing is written into the repo.
5
+
6
+ import { execFileSync } from 'child_process';
7
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'fs';
8
+ import { randomUUID } from 'crypto';
9
+ import { dirname } from 'path';
10
+
11
+ function real(path) {
12
+ try { return realpathSync(path); } catch { return path; }
13
+ }
14
+
15
+ // The git root of `dir`, else `dir` — both with symlinks resolved, so two paths to one repo are one repo.
16
+ export function repoKey(dir) {
17
+ const start = real(dir);
18
+ try {
19
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: start, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
20
+ return top ? real(top) : start;
21
+ } catch {
22
+ return start;
23
+ }
24
+ }
25
+
26
+ function readIds(file) {
27
+ try {
28
+ const ids = JSON.parse(readFileSync(file, 'utf-8'));
29
+ return ids && typeof ids === 'object' && !Array.isArray(ids) ? ids : {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ export function stableGlobalAgentId({ file, boardId, dir, mint = randomUUID }) {
36
+ const key = `${boardId}|${repoKey(dir)}`;
37
+ const ids = readIds(file);
38
+ if (typeof ids[key] === 'string' && ids[key]) return ids[key];
39
+ const id = mint();
40
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
41
+ const tmp = `${file}.${process.pid}.tmp`;
42
+ writeFileSync(tmp, JSON.stringify({ ...ids, [key]: id }, null, 2), 'utf-8');
43
+ renameSync(tmp, file);
44
+ return id;
45
+ }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // The build kit's own local runner (shared/build-kit/lib/local-ai-agent.js) is a
4
4
  // template copied into user projects, so it owns its whole lifecycle: it reads
5
- // config.json off disk, pulls one task out of tasks.json and exits. This one is the
5
+ // config.json off disk, pulls one task off the queue and exits. This one is the
6
6
  // same idea mounted the other way round — the modeling loop already owns the prompt
7
7
  // queue, the standalone board-change lane and the idle review, and only ever needed
8
8
  // something to hand a turn's text to. So this exports a runner with one method,
@@ -175,7 +175,7 @@ function systemPrompt({ boardId, organizationId, standalone }) {
175
175
  ].join('\n');
176
176
  }
177
177
 
178
- export function createModelingLocalAiRunner({ cfg, target, log, verbose = false, standalone = false }) {
178
+ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false, standalone = false, onToolUse = () => () => {} }) {
179
179
  const t = resolveLocalAiTarget({ target, localAi: cfg.localAi || {} });
180
180
  let tools = null; // the MCP tool set, fetched once per session
181
181
 
@@ -269,6 +269,7 @@ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false,
269
269
  : rawArgs;
270
270
 
271
271
  log(verbose ? `→ ${name}(${JSON.stringify(args).slice(0, 120)})` : `→ ${name}`);
272
+ const finish = onToolUse(name, args);
272
273
 
273
274
  let toolResult;
274
275
  try {
@@ -276,6 +277,7 @@ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false,
276
277
  } catch (err) {
277
278
  toolResult = { isError: true, content: [{ type: 'text', text: err.message }] };
278
279
  }
280
+ finish({ isError: !!toolResult.isError });
279
281
  if (verbose) log(` ${JSON.stringify(toolResult).slice(0, 160)}`);
280
282
 
281
283
  messages.push({
@@ -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": "1.0.163",
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": {