@eventmodelers/cli 2.0.10 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -695,6 +695,7 @@ Flow: `Ready → Planned → InProgress → Waiting → InProgress → Done`. Pr
695
695
  - **The platform must know the `Ready` / `Waiting` statuses.**
696
696
  - A kit declares what it claims in `lib/kit.json` — `{ "pickupStatus": "Waiting" }`; absent means `Planned`.
697
697
  - `run --status-trigger <status>` overrides that pickup status for a single run (e.g. `--status-trigger Ready`); nothing is saved.
698
+ - `run --feature <name>` picks up only the slices that belong to that feature (the names set per slice on the board, matched case-insensitively); without it every slice in the pickup status is built. Slices without a feature wait for a run without the flag. Nothing is saved.
698
699
 
699
700
  The contract itself is the shared `ui-contract` skill's job (see above); it also owns the rule for which status a slice ends at.
700
701
 
package/cli.js CHANGED
@@ -3305,7 +3305,7 @@ program
3305
3305
  // dir: a modeling run falls back to the global install (see ensureGlobalKit) rather than
3306
3306
  // requiring one here, and the build-kit branch reports a better-targeted error of its own
3307
3307
  // than this generic gate can.
3308
- const NO_INIT_REQUIRED = new Set(['init', 'scaffold', 'init-templates', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
3308
+ const NO_INIT_REQUIRED = new Set(['init', 'scaffold', 'init-templates', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'export', 'activate-context', 'set-slice-status', 'release-notes', 'run', 'listen', 'events', 'replay', 'bridge']);
3309
3309
 
3310
3310
  // Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
3311
3311
  // front of them would only be noise.
@@ -3816,6 +3816,7 @@ credentialFlags(program
3816
3816
  .option('--browser', 'Give the locked-down modeling agent a browser: the Playwright MCP server (npx @playwright/mcp) next to the eventmodelers one, for discover-storyboard. Off by default because a browser can reach any site, which the lockdown otherwise rules out. Default Claude runner only; --dangerously-skip-permissions already loads every MCP server the machine configures.')
3817
3817
  .option('--handover', 'Backend build kit: for this run only, finish slices that have a UI at `Waiting` instead of `Done` (same as the project setting `handover: true`, which `init --handover` / `config --handover on` save — this flag writes nothing). Without it the project setting applies; without both, slices end at `Done`.')
3818
3818
  .option('--status-trigger <status>', 'Build kit: the slice status the agent picks work up from, for this run only (e.g. Ready, Planned, Waiting). Defaults to the kit\'s own pickup status — `Planned` for backend kits, `Handover` for the UI kits. Nothing is written to disk.')
3819
+ .option('--feature <name>', 'Build kit: only pick up slices that belong to this feature (the free-text names set per slice on the board, matched case-insensitively), for this run only. Other slices wait for a run without it or with their own feature. Nothing is written to disk.')
3819
3820
  .option('--sync', 'Build kit: share this agent\'s event log with the board — events that name a slice are uploaded constantly (POST …/buildlog) for a live dashboard. The platform keeps one iteration per slice, 1000 entries at most. A no-op for --modeling/--standalone, and with --local.')
3820
3821
  .option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
3821
3822
  .option('--id <id>', 'Pin the agent id this run identifies itself with on the platform. Without it an agent keeps one id: a project install per project, a --global/--standalone run per board and repo (the directory it is started in) — the same agent on every restart, with its chat, its star on the board and the prompts it holds. If another live process already runs under that id, this one gets a fresh id instead (two agents must not share a heartbeat row). Pass --id when a supervisor already knows the id you want, or for a second agent on purpose; it is used as given, with no such check. Per-run only: nothing is written to disk.')
@@ -4106,7 +4107,7 @@ credentialFlags(program
4106
4107
  // path with no stream-json parsing to gate. RALPH_LOCAL is read by ralph.js's startRalph to force
4107
4108
  // the local-only branch even when .eventmodelers/config.json has valid credentials.
4108
4109
  // RALPH_AGENT_ID/RALPH_AGENT_NAME (--id/--name) are read there too.
4109
- execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_SYNC: opts.sync ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
4110
+ execSync(cmd, { cwd: kitDir, stdio: 'inherit', env: { ...process.env, RALPH_VERBOSE: opts.verbose ? '1' : '', RALPH_SYNC: opts.sync ? '1' : '', RALPH_LOCAL: opts.local ? '1' : '', RALPH_HANDOVER: opts.handover ? '1' : '', RALPH_STATUS_TRIGGER: opts.statusTrigger ? String(opts.statusTrigger).trim() : '', RALPH_FEATURE: opts.feature ? String(opts.feature).trim() : '', RALPH_AGENT_ID: identity.agentId ?? '', RALPH_AGENT_NAME: identity.agentName ?? '', ...(typeof localAiTarget === 'string' ? { LOCAL_AI_TARGET: localAiTarget } : {}), ...(typeof opts.exec === 'string' ? { RALPH_EXEC_CMD: opts.exec } : {}) } });
4110
4111
  } catch (err) {
4111
4112
  process.exit(err.status || 1);
4112
4113
  }
@@ -4210,6 +4211,139 @@ program
4210
4211
  }
4211
4212
  });
4212
4213
 
4214
+ const EXPORT_TYPES = ['eventcatalog'];
4215
+
4216
+ program
4217
+ .command('export')
4218
+ .description('Export the event model into another tool\'s format. --type eventcatalog writes an EventCatalog project (domain, flow, events, commands, queries). Reads the board by default; --local reads .slices/ or config.json instead')
4219
+ .requiredOption('--type <type>', `Export target: ${EXPORT_TYPES.join(', ')}`)
4220
+ .option('--context <name>', 'Name or id of the context to export. Omitted (or not found) on the board: pick from the board\'s contexts. Filters --local')
4221
+ .option('--local [path]', 'Read local files instead of the board: ./.slices/, then ./config.json, or the given path')
4222
+ .option('--out <dir>', 'Directory to write into (the catalog root)', '.')
4223
+ .option('--owner <id>', 'Owner (team or user id from the catalog) set on every exported resource; repeat for several', (v, all) => [...all, v], [])
4224
+ .option('--check', 'Compare the model with what the last export wrote and exit 1 if they differ — writes nothing (for CI)')
4225
+ .option('-f, --force', 'Overwrite resources that already exist in the catalog, and re-prompt for credentials when reading the board')
4226
+ .action(async (opts, command) => {
4227
+ if (!EXPORT_TYPES.includes(opts.type)) {
4228
+ console.error(`❌ Unknown export type "${opts.type}". Supported: ${EXPORT_TYPES.join(', ')}`);
4229
+ process.exit(1);
4230
+ }
4231
+ const cwd = process.cwd();
4232
+ const ext = await import('./extensions/eventcatalog/index.js');
4233
+ // Checked first, before any credentials or board access: the export only makes sense inside a catalog.
4234
+ if (!ext.isCatalog(resolve(cwd, opts.out))) {
4235
+ console.error(`❌ ${resolve(cwd, opts.out)} is not an EventCatalog project (no eventcatalog.config.* and no @eventcatalog/core dependency).`);
4236
+ console.error(' Run this inside your catalog, or point --out at it. Create one with: npx @eventcatalog/create-eventcatalog@latest my-catalog --empty');
4237
+ process.exit(1);
4238
+ }
4239
+ let slices;
4240
+ try {
4241
+ if (opts.local) {
4242
+ slices = ext.loadFromLocal({ cwd, local: opts.local, context: opts.context });
4243
+ } else {
4244
+ const kitDir = findInstalledKitDir(cwd);
4245
+ const globalOpts = command.optsWithGlobals();
4246
+ const explicitConfig = globalOpts.config;
4247
+ let cfg = loadEffectiveConfig(cwd, kitDir, explicitConfig).config;
4248
+ const configPath = explicitConfig ? resolve(cwd, explicitConfig) : join(cwd, '.eventmodelers', 'config.json');
4249
+ const requiredFields = ['organizationId', 'boardId', 'token'];
4250
+ const promptForCredentials = async (force = false) => {
4251
+ cfg = await configureCredentials({ config: cfg, configPath, targetDir: cwd, requiredFields, boardIdOptional: false, overrides: {}, print: globalOpts.print, force });
4252
+ if (requiredFields.some((f) => !cfg[f])) {
4253
+ console.error('❌ Still missing token/organizationId/boardId — re-run `eventmodelers export` once configured.');
4254
+ process.exit(1);
4255
+ }
4256
+ };
4257
+ // --force re-asks for credentials, like `init --force` / `init-config --force`.
4258
+ if (opts.force || requiredFields.some((f) => !cfg[f])) await promptForCredentials(opts.force);
4259
+ // A 401/403 re-asks for the token, like `fetch`; any other failure goes to the caller.
4260
+ const withAuthRetry = async (fn) => {
4261
+ try {
4262
+ return await fn();
4263
+ } catch (err) {
4264
+ if (err.status !== 401 && err.status !== 403 && err.status !== 404) throw err;
4265
+ console.error(`❌ ${err.message}`);
4266
+ if (err.status === 404) delete cfg.boardId;
4267
+ else delete cfg.token;
4268
+ await promptForCredentials();
4269
+ return fn();
4270
+ }
4271
+ };
4272
+ const pickContext = async (question) => {
4273
+ const contexts = await withAuthRetry(() => ext.listContexts({ cfg }));
4274
+ if (!contexts.length) throw new Error(`Board ${cfg.boardId} has no contexts with slices.`);
4275
+ return selectPrompt(question, contexts.map((c) => ({ label: `${c.name} (${c.slices} slice${c.slices === 1 ? '' : 's'})`, value: c.name })));
4276
+ };
4277
+ let context = opts.context || (await pickContext('Which context do you want to export?'));
4278
+ try {
4279
+ console.log(`▶ Reading context "${context}" from the board...`);
4280
+ slices = await withAuthRetry(() => ext.loadFromBoard({ cfg, context }));
4281
+ } catch (err) {
4282
+ if (!(err instanceof ext.ContextNotFoundError)) throw err;
4283
+ console.error(`❌ ${err.message}`);
4284
+ context = await pickContext(`No context named "${context}" — pick one of the contexts on this board:`);
4285
+ console.log(`▶ Reading context "${context}" from the board...`);
4286
+ slices = await withAuthRetry(() => ext.loadFromBoard({ cfg, context }));
4287
+ }
4288
+ opts.context = context;
4289
+ }
4290
+ } catch (err) {
4291
+ console.error(`❌ ${err.message}`);
4292
+ process.exit(1);
4293
+ }
4294
+ const outDir = resolve(cwd, opts.out);
4295
+ if (opts.check) {
4296
+ const result = ext.check({ slices, outDir, context: opts.context });
4297
+ if (!result) {
4298
+ console.log('ℹ️ No slices found — nothing to check.');
4299
+ return;
4300
+ }
4301
+ if (result.new.length) console.log(`new (${result.new.length}):\n${result.new.map((l) => ` ${l}`).join('\n')}`);
4302
+ if (result.changed.length) {
4303
+ console.log(`changed (${result.changed.length}):`);
4304
+ for (const { label, changes, renamedFrom } of result.changed) console.log(` ${label}${renamedFrom ? ` (renamed from ${renamedFrom})` : ''}\n${changes.map((c) => ` ${c}`).join('\n')}`);
4305
+ }
4306
+ if (result.removed.length) console.log(`removed from the model (${result.removed.length}):\n${result.removed.map((l) => ` ${l}`).join('\n')}`);
4307
+ if (result.inSync) {
4308
+ console.log(`✅ "${result.context}" is in sync with the catalog (${result.unchanged.length} resources).`);
4309
+ return;
4310
+ }
4311
+ console.log(`❌ "${result.context}" is out of sync. Run \`eventmodelers export --type eventcatalog --context "${result.context}"\` (add --force to update resources already in the catalog).`);
4312
+ process.exit(1);
4313
+ }
4314
+ // The SDK comes from the catalog, never from this CLI. Missing → offer to install it there.
4315
+ let sdk = await ext.loadSdk(outDir);
4316
+ if (!sdk) {
4317
+ console.log(`@eventcatalog/sdk is not installed in ${outDir}.`);
4318
+ const install = await selectPrompt('Install @eventcatalog/sdk into this catalog now (npm install)?', [
4319
+ { label: 'Yes, run `npm install @eventcatalog/sdk` there', value: true },
4320
+ { label: 'No, cancel the export', value: false },
4321
+ ]);
4322
+ if (!install) {
4323
+ console.error('❌ The export needs @eventcatalog/sdk in the catalog. Install it (npm install @eventcatalog/sdk) and re-run.');
4324
+ process.exit(1);
4325
+ }
4326
+ try {
4327
+ ext.installSdk(outDir);
4328
+ sdk = await ext.loadSdk(outDir);
4329
+ } catch (err) {
4330
+ console.error(`❌ Could not install @eventcatalog/sdk: ${err.message}`);
4331
+ process.exit(1);
4332
+ }
4333
+ if (!sdk) {
4334
+ console.error('❌ @eventcatalog/sdk still cannot be resolved from the catalog after installing.');
4335
+ process.exit(1);
4336
+ }
4337
+ }
4338
+ const { written, skipped, model } = await ext.run({ slices, outDir, context: opts.context, force: opts.force, owners: opts.owner, sdk });
4339
+ if (!model) {
4340
+ console.log('ℹ️ No slices found — nothing exported.');
4341
+ return;
4342
+ }
4343
+ console.log(`✅ Exported "${model.domain.name}" to ${outDir}: ${written.length} files written${skipped.length ? `, ${skipped.length} skipped (already in the catalog; use --force to overwrite)` : ''}.`);
4344
+ for (const f of skipped) console.log(` skipped ${f}`);
4345
+ });
4346
+
4213
4347
  program
4214
4348
  .command('scaffold <target> [args...]')
4215
4349
  .description('`scaffold add-user <email> <password> [dir]` adds a sign-in user to a scaffolded app (run in its folder, or pass dir). `scaffold re-init [dir]` refreshes every kit the app has (root and web/) from this CLI version — `re-init --stack` for each, the skeleton itself untouched. Otherwise: generate a runnable app skeleton from the board — sign-in, navigation with one entry per timeline, a page shell per screen, 501 for every unbuilt endpoint, hot reload — and install the build kits of the chosen --type into it, ready for `run` to add slices. <target> is an empty or new directory, or a skeleton made earlier (which is refreshed from the current model).')
@@ -0,0 +1,23 @@
1
+ # EventCatalog export
2
+
3
+ ```
4
+ eventmodelers export --type eventcatalog [--context <name>] [--local [path]] [--out <dir>] [--owner <id>]... [-f, --force]
5
+ ```
6
+
7
+ Reads the slices of an event model and writes EventCatalog resources into `--out` (default: cwd), using the
8
+ **EventCatalog SDK** (`@eventcatalog/sdk`).
9
+
10
+ - Source: the board by default (no `--context` → pick from the board's contexts). `--local` reads `./.slices/`, then `./config.json`, or the path you give.
11
+ - The SDK is resolved from the target catalog's `node_modules` — it is not a dependency of this CLI. If the catalog lacks it, the CLI asks before running `npm install @eventcatalog/sdk` there.
12
+ - `--owner <id>` (repeatable) sets `owners` on every resource; the id must be a team or user in the catalog, or the linter flags it.
13
+ - Resources already in the catalog (same id and version) are skipped. `--force` overwrites them, and — reading the board — asks for credentials again.
14
+
15
+ | Event model | EventCatalog |
16
+ |---|---|
17
+ | context | `domains/<Context>` |
18
+ | chapter | `flows/<Chapter>Flow` (steps ordered by first appearance, edges from dependencies) |
19
+ | events, commands | `events/`, `commands/` with `schema.json` (de-duplicated by title) |
20
+ | read models | `queries/` with `schema.json` |
21
+ | processors, screens | not exported: the event model has no services, so none is imported or created |
22
+
23
+ `model.js` (slices → model) and `render.js` (model → SDK resources) are pure; `write.js` is the only part that touches the catalog.
@@ -0,0 +1,32 @@
1
+ // `eventmodelers export --type eventcatalog` — writes an EventCatalog project's resources
2
+ // (domain, flow, events, commands, queries) from the slices of an event model, through the
3
+ // EventCatalog SDK. Exports run({ slices, outDir, context, force, owners, sdk }).
4
+
5
+ import { buildModel } from './model.js';
6
+ import { toResources } from './render.js';
7
+ import { writeResources } from './write.js';
8
+ import { diffAgainst, readManifest, snapshotsOf, updateManifest } from './manifest.js';
9
+
10
+ export { loadFromBoard, loadFromLocal, listContexts, ContextNotFoundError } from './load.js';
11
+ export { loadSdk, installSdk, isCatalog } from './write.js';
12
+
13
+ // `export --check`: compares the model with what the last export recorded. Writes nothing, needs no SDK.
14
+ export function check({ slices, outDir, context }) {
15
+ if (!slices.length) return null;
16
+ const model = buildModel(slices, { context });
17
+ const diff = diffAgainst(readManifest(outDir), model.domain.name, snapshotsOf(model));
18
+ return { context: model.domain.name, ...diff, inSync: !diff.new.length && !diff.changed.length && !diff.removed.length };
19
+ }
20
+
21
+ // `sdk` is the SDK's factory (see loadSdk); the catalog at `outDir` is where it writes.
22
+ export async function run({ slices, outDir, context, force = false, owners = [], sdk }) {
23
+ if (!slices.length) return { written: [], skipped: [], model: null };
24
+ const model = buildModel(slices, { context });
25
+ const resources = toResources(model, { owners });
26
+ const { written, skipped } = await writeResources(sdk(outDir), resources, { force });
27
+ // Only what was actually written is recorded: a skipped resource keeps its old snapshot (or none), so
28
+ // `--check` keeps reporting it until it is really brought up to date.
29
+ const snapshots = snapshotsOf(model);
30
+ updateManifest(outDir, model.domain.name, Object.fromEntries(written.map((label) => [label, snapshots[label]])));
31
+ return { written, skipped, model };
32
+ }
@@ -0,0 +1,78 @@
1
+ // Where the slices come from: the board (default) or local files (--local).
2
+
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
4
+ import { join, resolve } from 'path';
5
+ import { FetchAuthError } from '../../lib/fetch.js';
6
+
7
+ export class ContextNotFoundError extends Error {}
8
+
9
+ const DEFAULT_BASE_URL = 'https://api.eventmodelers.ai';
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+
12
+ const readJson = (p) => JSON.parse(readFileSync(p, 'utf-8'));
13
+
14
+ // Every context on the board, as the server resolves them — a timeline with no assigned context counts as its own
15
+ // context. Derived from the slice summaries, so contexts without slices do not show up (there is nothing to export).
16
+ export async function listContexts({ cfg }) {
17
+ const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
18
+ const res = await fetch(`${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`, { headers: { 'x-token': cfg.token, 'x-user-id': 'cli-export' } });
19
+ if (res.status === 401) throw new FetchAuthError(401, 'slicedata/slices: invalid or expired token');
20
+ if (res.status === 403) throw new FetchAuthError(403, "slicedata/slices: token's organization does not match this board");
21
+ if (res.status === 404) throw new FetchAuthError(404, `slicedata/slices: board ${cfg.boardId} not found`);
22
+ if (!res.ok) throw new Error(`slicedata/slices: HTTP ${res.status}`);
23
+ const counts = new Map();
24
+ for (const s of (await res.json()).slices ?? []) {
25
+ const name = s.contextName || 'default';
26
+ counts.set(name, (counts.get(name) ?? 0) + 1);
27
+ }
28
+ return [...counts].map(([name, slices]) => ({ name, slices }));
29
+ }
30
+
31
+ // Same endpoint `fetch` uses (json format); throws FetchAuthError on 401/403 so the CLI can re-prompt.
32
+ export async function loadFromBoard({ cfg, context }) {
33
+ if (!context) throw new Error('No context given.');
34
+ const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
35
+ const query = UUID_RE.test(context) ? `contextId=${encodeURIComponent(context)}` : `contextName=${encodeURIComponent(context)}`;
36
+ const url = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?${query}&format=json`;
37
+ const res = await fetch(url, { headers: { 'x-token': cfg.token, 'x-user-id': 'cli-export' } });
38
+ if (res.status === 401) throw new FetchAuthError(401, 'slicedata: invalid or expired token');
39
+ if (res.status === 403) throw new FetchAuthError(403, "slicedata: token's organization does not match this board");
40
+ if (res.status === 404) {
41
+ const body = await res.json().catch(() => null);
42
+ throw new ContextNotFoundError(`slicedata: ${body?.error || `no context or timeline named "${context}"`} (board ${cfg.boardId})`);
43
+ }
44
+ if (!res.ok) throw new Error(`slicedata: HTTP ${res.status}`);
45
+ return (await res.json()).slices ?? [];
46
+ }
47
+
48
+ // `local` is true (auto-detect) or a path to a config.json, a .slices/ folder, a context folder, or a slice.json.
49
+ // Auto-detect tries ./.slices/ first, then ./config.json.
50
+ export function loadFromLocal({ cwd, local, context }) {
51
+ const candidates = typeof local === 'string' ? [resolve(cwd, local)] : [join(cwd, '.slices'), join(cwd, 'config.json')];
52
+ const path = candidates.find((p) => existsSync(p));
53
+ if (!path) throw new Error(`No local slices found (looked for ${candidates.join(', ')}).`);
54
+ const slices = statSync(path).isDirectory() ? fromFolder(path) : fromFile(path);
55
+ const wanted = context?.toLowerCase();
56
+ return wanted ? slices.filter((s) => (s.context ?? '').toLowerCase() === wanted) : slices;
57
+ }
58
+
59
+ function fromFile(path) {
60
+ const data = readJson(path);
61
+ return Array.isArray(data.slices) ? data.slices : [data];
62
+ }
63
+
64
+ // A .slices/ folder holds <context>/<slice>/slice.json (and a <context>/config.json dump of the same data).
65
+ function fromFolder(dir) {
66
+ const slices = [];
67
+ const walk = (d, depth) => {
68
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
69
+ const p = join(d, entry.name);
70
+ if (entry.isDirectory() && depth < 3) walk(p, depth + 1);
71
+ else if (entry.name === 'slice.json') slices.push(readJson(p));
72
+ }
73
+ };
74
+ walk(dir, 0);
75
+ // slice.json drops `index`; order from the context's index.json when there is one.
76
+ slices.forEach((s, i) => { if (s.index === undefined) s.index = i; });
77
+ return slices;
78
+ }
@@ -0,0 +1,139 @@
1
+ // What the last export wrote, so `export --check` can tell what moved in the model since — and say what.
2
+ // Only the parts EM-Studio owns are recorded and compared (name, summary from the description, fields,
3
+ // flow steps). Everything else in a catalog resource belongs to the catalog and is never looked at.
4
+ // Kept in the catalog, per context: label ("events/OrderPlaced") → snapshot.
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+
9
+ const FILE = join('.eventmodelers', 'eventcatalog-export.json');
10
+
11
+ const field = (f) => ({
12
+ name: f.name,
13
+ type: f.type ?? 'String',
14
+ cardinality: f.cardinality ?? 'Single',
15
+ optional: Boolean(f.optional),
16
+ ...(f.example ? { example: f.example } : {}),
17
+ ...(f.subfields?.length ? { subfields: f.subfields.map(field) } : {}),
18
+ });
19
+
20
+ // The summary is only EM-Studio's when the model has a description; otherwise null = "not ours".
21
+ const message = (n) => ({ name: n.name, summary: n.description || null, keys: [...n.elementIds].sort(), fields: n.fields.map(field) });
22
+
23
+ export function snapshotsOf(model) {
24
+ const out = {};
25
+ for (const [folder, list] of [['events', model.events], ['commands', model.commands], ['queries', model.queries]]) {
26
+ for (const n of list) out[`${folder}/${n.id}`] = message(n);
27
+ }
28
+ for (const flow of model.flows) {
29
+ out[`flows/${flow.id}`] = {
30
+ name: flow.name,
31
+ steps: flow.steps.map((s) => ({
32
+ id: s.id, title: s.node.name, keys: [...s.node.elementIds].sort(),
33
+ // next carries the target's keys too, so a renamed target is still the same target
34
+ next: s.next.map((nid) => { const t = flow.steps.find((x) => x.id === nid).node; return { id: nid, keys: [...t.elementIds].sort() }; }),
35
+ })),
36
+ };
37
+ }
38
+ out[`domains/${model.domain.id}`] = { name: model.domain.name };
39
+ return out;
40
+ }
41
+
42
+ export function readManifest(catalogDir) {
43
+ const path = join(catalogDir, FILE);
44
+ if (!existsSync(path)) return {};
45
+ try {
46
+ return JSON.parse(readFileSync(path, 'utf-8'));
47
+ } catch {
48
+ return {}; // unreadable → treated as "nothing exported yet"
49
+ }
50
+ }
51
+
52
+ // Merges `snapshots` into the context's entry.
53
+ export function updateManifest(catalogDir, context, snapshots) {
54
+ const manifest = readManifest(catalogDir);
55
+ manifest[context] = { resources: { ...(manifest[context]?.resources ?? {}), ...snapshots }, exportedAt: new Date().toISOString() };
56
+ const path = join(catalogDir, FILE);
57
+ mkdirSync(dirname(path), { recursive: true });
58
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + '\n');
59
+ }
60
+
61
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
62
+ const quote = (s) => (s === null || s === undefined ? '(none)' : `"${s}"`);
63
+
64
+ function describeFields(before, after, prefix = '') {
65
+ const lines = [];
66
+ const was = new Map(before.map((f) => [f.name, f]));
67
+ const now = new Map(after.map((f) => [f.name, f]));
68
+ for (const [name, f] of now) {
69
+ const old = was.get(name);
70
+ if (!old) { lines.push(`+ field ${prefix}${name} (${f.type}${f.cardinality === 'Single' ? '' : ` ${f.cardinality}`}${f.optional ? ', optional' : ''})`); continue; }
71
+ const parts = [];
72
+ if (old.type !== f.type) parts.push(`type ${old.type} → ${f.type}`);
73
+ if (old.cardinality !== f.cardinality) parts.push(`cardinality ${old.cardinality} → ${f.cardinality}`);
74
+ if (old.optional !== f.optional) parts.push(f.optional ? 'now optional' : 'now required');
75
+ if (old.example !== f.example) parts.push(`example ${quote(old.example)} → ${quote(f.example)}`);
76
+ if (parts.length) lines.push(`~ field ${prefix}${name}: ${parts.join(', ')}`);
77
+ if (old.subfields || f.subfields) lines.push(...describeFields(old.subfields ?? [], f.subfields ?? [], `${prefix}${name}.`));
78
+ }
79
+ for (const name of was.keys()) if (!now.has(name)) lines.push(`- field ${prefix}${name}`);
80
+ return lines;
81
+ }
82
+
83
+ // Two entries are the same element when they share a board id; entries without ids (stubs) match by id.
84
+ const matches = (a, b) => (a.keys?.length && b.keys?.length ? a.keys.some((k) => b.keys.includes(k)) : a.id === b.id);
85
+ const nextIds = (list) => list.map((n) => n.id).join(', ') || '(none)';
86
+
87
+ function describeSteps(before, after) {
88
+ const lines = [];
89
+ const used = new Set();
90
+ for (const s of after) {
91
+ const old = before.find((b) => !used.has(b) && matches(b, s));
92
+ if (!old) { lines.push(`+ step ${s.id}`); continue; }
93
+ used.add(old);
94
+ if (old.id !== s.id) lines.push(`~ step ${old.id} → ${s.id}`);
95
+ const oldNext = old.next.map((n) => (typeof n === 'string' ? { id: n } : n));
96
+ const gone = oldNext.filter((o) => !s.next.some((n) => matches(o, n)));
97
+ const added = s.next.filter((n) => !oldNext.some((o) => matches(o, n)));
98
+ if (gone.length || added.length) lines.push(`~ step ${s.id}: next ${nextIds(oldNext)} → ${nextIds(s.next)}`);
99
+ }
100
+ for (const b of before) if (!used.has(b)) lines.push(`- step ${b.id}`);
101
+ return lines;
102
+ }
103
+
104
+ // The human-readable changes between two snapshots of one resource.
105
+ export function describeChange(before, after) {
106
+ const lines = [];
107
+ if (before.name !== after.name) lines.push(`name: ${quote(before.name)} → ${quote(after.name)}`);
108
+ // A summary only counts when the model has a description (see `message`).
109
+ if (after.summary !== null && after.summary !== undefined && before.summary !== after.summary) lines.push(`summary: ${quote(before.summary)} → ${quote(after.summary)}`);
110
+ if (after.fields) lines.push(...describeFields(before.fields ?? [], after.fields));
111
+ if (after.steps) lines.push(...describeSteps(before.steps ?? [], after.steps));
112
+ return lines;
113
+ }
114
+
115
+ // new: not exported yet · changed: [{ label, changes, renamedFrom? }] · removed: exported before, gone from the model.
116
+ // A "new" and a "removed" resource of the same kind that share a board element id are one resource, renamed.
117
+ export function diffAgainst(manifest, context, snapshots) {
118
+ const before = manifest[context]?.resources ?? {};
119
+ const result = { new: [], changed: [], removed: [], unchanged: [] };
120
+ const kind = (label) => label.split('/')[0];
121
+ const missing = Object.keys(before).filter((label) => !(label in snapshots));
122
+
123
+ for (const [label, snap] of Object.entries(snapshots)) {
124
+ const old = before[label];
125
+ if (old && typeof old === 'object') {
126
+ const changes = describeChange(old, snap);
127
+ if (changes.length) result.changed.push({ label, changes });
128
+ else result.unchanged.push(label);
129
+ continue;
130
+ }
131
+ const was = snap.keys?.length ? missing.find((m) => kind(m) === kind(label) && before[m]?.keys?.some((k) => snap.keys.includes(k))) : null;
132
+ if (!was) { result.new.push(label); continue; }
133
+ missing.splice(missing.indexOf(was), 1);
134
+ const id = (l) => l.split('/').slice(1).join('/');
135
+ result.changed.push({ label, renamedFrom: was, changes: [`id: ${id(was)} → ${id(label)}`, ...describeChange(before[was], snap)] });
136
+ }
137
+ result.removed = missing;
138
+ return result;
139
+ }
@@ -0,0 +1,111 @@
1
+ // Slices → a neutral EventCatalog model: one domain, one flow per chapter, and the events, commands and queries
2
+ // (read models) of the event model. Processors, screens and services are not part of it — the model has no
3
+ // such thing as a service, so none is imported or invented. Pure — no I/O — so it is testable without a catalog.
4
+
5
+ const ELEMENT_KINDS = [
6
+ ['commands', 'command'],
7
+ ['events', 'event'],
8
+ ['readmodels', 'query'],
9
+ ];
10
+ const DEP_KINDS = { COMMAND: 'command', EVENT: 'event', READMODEL: 'query' };
11
+
12
+ // "Payment confirmed" / "Payment Confirmed" / "payment_confirmed" are one element.
13
+ const normalize = (title) => String(title ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
14
+
15
+ export function pascal(title) {
16
+ return String(title ?? '')
17
+ .split(/[^A-Za-z0-9]+/)
18
+ .filter(Boolean)
19
+ .map((w) => w[0].toUpperCase() + w.slice(1))
20
+ .join('');
21
+ }
22
+
23
+ const clean = (title) => String(title ?? '').trim().replace(/\s+/g, ' ');
24
+
25
+ export function buildModel(slices, { context } = {}) {
26
+ const ordered = [...slices].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
27
+ const contextName = clean(context || ordered[0]?.context || 'Default');
28
+ const nodes = new Map(); // `${kind}:${normalized}` → node
29
+ const byElementId = new Map(); // board element id (and linkedId) → node
30
+ const edges = new Map(); // `${from}>${to}` → { from, to }
31
+
32
+ function node(kind, title, slice) {
33
+ const key = `${kind}:${normalize(title)}`;
34
+ let n = nodes.get(key);
35
+ if (!n) {
36
+ n = { key, kind, id: pascal(title), name: clean(title), description: '', fields: [], slices: [], elementIds: [] };
37
+ nodes.set(key, n);
38
+ }
39
+ if (slice && !n.slices.includes(slice)) n.slices.push(slice);
40
+ return n;
41
+ }
42
+
43
+ function mergeFields(n, fields = []) {
44
+ for (const f of fields) if (!n.fields.some((x) => x.name === f.name)) n.fields.push(f);
45
+ }
46
+
47
+ const link = (from, to) => edges.set(`${from.key}>${to.key}`, { from, to });
48
+
49
+ const pending = [];
50
+ for (const slice of ordered) {
51
+ for (const [prop, kind] of ELEMENT_KINDS) {
52
+ for (const el of slice[prop] ?? []) {
53
+ const n = node(kind, el.title, slice.title);
54
+ if (!n.description && el.description) n.description = el.description;
55
+ mergeFields(n, el.fields);
56
+ byElementId.set(el.id, n);
57
+ if (el.linkedId) byElementId.set(el.linkedId, n);
58
+ // The board's ids survive a rename of the title, so a later export can tell a rename from a new element.
59
+ for (const id of [el.id, el.linkedId]) if (id && !n.elementIds.includes(id)) n.elementIds.push(id);
60
+ pending.push([n, el]);
61
+ }
62
+ }
63
+ }
64
+
65
+ // Dependencies are resolved after every element is known, by id first and by title second.
66
+ // A target nothing in the slices defines (e.g. a read model from another context) becomes a stub.
67
+ for (const [n, el] of pending) {
68
+ for (const dep of el.dependencies ?? []) {
69
+ const kind = DEP_KINDS[dep.elementType];
70
+ if (!kind) continue;
71
+ // A message nothing defines (e.g. from another context) becomes a stub. Dependencies on processors
72
+ // and screens never get here: DEP_KINDS doesn't know them, so they are dropped above.
73
+ const target = byElementId.get(dep.id) ?? nodes.get(`${kind}:${normalize(dep.title)}`) ?? node(kind, dep.title);
74
+ if (dep.type === 'INBOUND') link(target, n);
75
+ else link(n, target);
76
+ }
77
+ }
78
+
79
+ const real = [...nodes.values()];
80
+
81
+ const ctxId = pascal(contextName);
82
+ const find = (kind) => real.filter((n) => n.kind === kind);
83
+
84
+ // The flow: one step per element in order of first appearance, edges from the dependencies.
85
+ const inFlow = real;
86
+ const stepId = (n) => `${n.kind}-${n.id}`;
87
+ const steps = inFlow.map((n) => {
88
+ const next = [...edges.values()].filter((e) => e.from === n && inFlow.includes(e.to)).map((e) => stepId(e.to));
89
+ return { id: stepId(n), node: n, next };
90
+ });
91
+
92
+ const chapters = new Set(ordered.map((s) => clean(s.chapter || contextName)));
93
+ const flows = [...chapters].map((chapter) => {
94
+ const inChapter = new Set(ordered.filter((s) => clean(s.chapter || contextName) === chapter).map((s) => s.title));
95
+ const chapterSteps = steps.filter((s) => s.node.slices.some((t) => inChapter.has(t)));
96
+ const ids = new Set(chapterSteps.map((s) => s.id));
97
+ return {
98
+ id: `${pascal(chapter)}Flow`,
99
+ name: chapter,
100
+ steps: chapterSteps.map((s) => ({ ...s, next: s.next.filter((x) => ids.has(x)) })),
101
+ };
102
+ });
103
+
104
+ return {
105
+ domain: { id: ctxId, name: contextName },
106
+ events: find('event'),
107
+ commands: find('command'),
108
+ queries: find('query'),
109
+ flows,
110
+ };
111
+ }
@@ -0,0 +1,82 @@
1
+ // Model → file contents. Everything returned is `{ path, content }`, relative to the catalog root.
2
+
3
+ const JSON_TYPES = {
4
+ string: { type: 'string' },
5
+ text: { type: 'string' },
6
+ uuid: { type: 'string', format: 'uuid' },
7
+ date: { type: 'string', format: 'date' },
8
+ datetime: { type: 'string', format: 'date-time' },
9
+ time: { type: 'string', format: 'time' },
10
+ boolean: { type: 'boolean' },
11
+ int: { type: 'integer' },
12
+ integer: { type: 'integer' },
13
+ long: { type: 'integer' },
14
+ double: { type: 'number' },
15
+ float: { type: 'number' },
16
+ decimal: { type: 'number' },
17
+ number: { type: 'number' },
18
+ };
19
+
20
+ function fieldSchema(f) {
21
+ const base = f.subfields?.length ? objectSchema(f.subfields) : { ...(JSON_TYPES[String(f.type ?? '').toLowerCase()] ?? { type: 'string' }) };
22
+ if (f.example !== undefined && f.example !== '') base.examples = [f.example];
23
+ if (f.description) base.description = f.description;
24
+ return /^(list|multiple|array)$/i.test(f.cardinality ?? '') ? { type: 'array', items: base } : base;
25
+ }
26
+
27
+ function objectSchema(fields) {
28
+ const properties = {};
29
+ for (const f of fields) properties[f.name] = fieldSchema(f);
30
+ const required = fields.filter((f) => !f.optional).map((f) => f.name);
31
+ return { type: 'object', properties, ...(required.length ? { required } : {}) };
32
+ }
33
+
34
+ export function toJsonSchema(fields) {
35
+ return { $schema: 'http://json-schema.org/draft-07/schema#', ...objectSchema(fields) };
36
+ }
37
+
38
+ const KIND_LABEL = { event: 'Event', command: 'Command', query: 'Query' };
39
+ const VERSION = '1.0.0';
40
+
41
+ // EventCatalog's linter requires a summary; fall back to a plain statement when the model has no description.
42
+ const summary = (n, context) => n.description || `${KIND_LABEL[n.kind]} ${n.name} of ${context}.`;
43
+ const usedIn = (n) => (n.slices.length ? `\nUsed in: ${n.slices.join(', ')}.\n` : '\n');
44
+
45
+ // Model → resources in the shape the EventCatalog SDK writes (`writeEvent`, `writeQuery`, …), plus the
46
+ // schema files to attach afterwards. Nothing is written here.
47
+ export function toResources(model, { owners = [] } = {}) {
48
+ const ctx = model.domain.name;
49
+ const owned = owners.length ? { owners } : {};
50
+ const message = (n) => ({
51
+ resource: {
52
+ id: n.id, name: n.name, version: VERSION, summary: summary(n, ctx), ...owned,
53
+ ...(n.fields.length ? { schemaPath: 'schema.json' } : {}),
54
+ markdown: `## Overview\n\n${n.name}.${usedIn(n)}`,
55
+ },
56
+ schema: n.fields.length ? JSON.stringify(toJsonSchema(n.fields), null, 2) + '\n' : null,
57
+ });
58
+ return {
59
+ events: model.events.map(message),
60
+ commands: model.commands.map(message),
61
+ queries: model.queries.map(message),
62
+ flows: model.flows.map((flow) => ({
63
+ resource: {
64
+ id: flow.id, name: flow.name, version: VERSION, summary: `Event model of ${flow.name}.`, ...owned,
65
+ steps: flow.steps.map((s) => ({
66
+ id: s.id,
67
+ title: s.node.name,
68
+ message: { id: s.node.id },
69
+ ...(s.next.length > 1 ? { next_steps: s.next } : s.next.length ? { next_step: s.next[0] } : {}),
70
+ })),
71
+ markdown: `## Overview\n\nGenerated from the event model chapter "${flow.name}".\n`,
72
+ },
73
+ })),
74
+ domain: {
75
+ resource: {
76
+ id: model.domain.id, name: ctx, version: VERSION, summary: `Event model context ${ctx}.`, ...owned,
77
+ ...(model.flows.length ? { flows: model.flows.map((f) => ({ id: f.id })) } : {}),
78
+ markdown: `## Overview\n\nExported from the Eventmodelers context "${ctx}".\n`,
79
+ },
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,63 @@
1
+ // Writes the resources through the EventCatalog SDK, which the *catalog* provides: it is resolved from the
2
+ // target directory's node_modules, never bundled with this CLI, so it always matches the catalog's version.
3
+
4
+ import { execFileSync } from 'child_process';
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { createRequire } from 'module';
7
+ import { join } from 'path';
8
+
9
+ const PACKAGE = '@eventcatalog/sdk';
10
+
11
+ // An EventCatalog project has an eventcatalog.config.* file and/or depends on @eventcatalog/core.
12
+ export function isCatalog(dir) {
13
+ if (['js', 'mjs', 'cjs', 'ts'].some((ext) => existsSync(join(dir, `eventcatalog.config.${ext}`)))) return true;
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
16
+ return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }['@eventcatalog/core']);
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ // The SDK's `utils` factory, or null when the catalog doesn't have the package.
23
+ export async function loadSdk(catalogDir) {
24
+ let file;
25
+ try {
26
+ file = createRequire(join(catalogDir, 'package.json')).resolve(PACKAGE);
27
+ } catch {
28
+ return null;
29
+ }
30
+ const mod = await import(file);
31
+ return typeof mod.default === 'function' ? mod.default : mod.default.default; // CJS entry nests the factory
32
+ }
33
+
34
+ // `npm install` into the catalog — adds the SDK to the *catalog's* package.json, not this CLI's.
35
+ export function installSdk(catalogDir) {
36
+ execFileSync('npm', ['install', PACKAGE], { cwd: catalogDir, stdio: 'inherit' });
37
+ }
38
+
39
+ // [resources key, SDK name part, singular for `<x>HasVersion`, label folder]
40
+ const KINDS = [
41
+ ['events', 'Event', 'event', 'events'], ['commands', 'Command', 'command', 'commands'], ['queries', 'Query', 'query', 'queries'],
42
+ ['flows', 'Flow', 'flow', 'flows'], ['domain', 'Domain', 'domain', 'domains'],
43
+ ];
44
+
45
+ // Existing resources (same id and version) are left alone — they may have been edited. `force` overwrites them.
46
+ // The schema file is attached after the resource (its `schemaPath` is already in the frontmatter).
47
+ export async function writeResources(utils, resources, { force = false } = {}) {
48
+ const written = [];
49
+ const skipped = [];
50
+ for (const [key, Name, singular, folder] of KINDS) {
51
+ const items = key === 'domain' ? [resources.domain] : resources[key];
52
+ for (const { resource, schema } of items) {
53
+ const label = `${folder}/${resource.id}`;
54
+ if (await utils[`${singular}HasVersion`](resource.id, resource.version)) {
55
+ if (!force) { skipped.push(label); continue; }
56
+ }
57
+ await utils[`write${Name}`](resource, { override: force });
58
+ if (schema) await utils[`addSchemaTo${Name}`](resource.id, { schema, fileName: 'schema.json' }, resource.version);
59
+ written.push(label);
60
+ }
61
+ }
62
+ return { written, skipped };
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "2.0.10",
3
+ "version": "2.0.11",
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": {
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "cli.js",
14
14
  "lib",
15
+ "extensions",
15
16
  "shared",
16
17
  "stacks",
17
18
  "README.md",
@@ -320,9 +320,9 @@ export async function fetchAndPersistSlices(cfg, kitDir) {
320
320
  mkdirSync(sliceDir, { recursive: true });
321
321
  const sliceJsonPath = join(sliceDir, 'slice.json');
322
322
  const existingSlice = existsSync(sliceJsonPath) ? JSON.parse(readFileSync(sliceJsonPath, 'utf-8')) : {};
323
- // The summary omits ticketNumber and assignee when the slice has none, so a cleared one must be cleared
323
+ // The summary omits ticketNumber, assignee and features when the slice has none, so a cleared one must be cleared
324
324
  // here too — otherwise the old one survives the merge and lands on the next build.
325
- writeFileSync(sliceJsonPath, JSON.stringify({ ...existingSlice, ...slice, status: statusOf(state, slice), ticketNumber: slice.ticketNumber ?? null, assignee: slice.assignee ?? null }, null, 2), 'utf-8');
325
+ writeFileSync(sliceJsonPath, JSON.stringify({ ...existingSlice, ...slice, status: statusOf(state, slice), ticketNumber: slice.ticketNumber ?? null, assignee: slice.assignee ?? null, features: slice.features ?? null }, null, 2), 'utf-8');
326
326
  }
327
327
  }
328
328
 
@@ -508,6 +508,25 @@ export function readPickupStatus(kitDir) {
508
508
  return 'Planned';
509
509
  }
510
510
 
511
+ // `run --feature <name>` (RALPH_FEATURE): only slices that belong to this feature are picked up.
512
+ // Matched like the board does — trimmed, case-insensitive. Empty means no filter.
513
+ export function readFeatureFilter() {
514
+ return (process.env.RALPH_FEATURE || '').trim().toLowerCase();
515
+ }
516
+
517
+ // Whether a slice is in the feature — its `features` as of the last fetch, from its slice.json. A slice
518
+ // whose slice.json is missing or has no features is not in any feature, so a filter skips it.
519
+ export function sliceInFeature(kitDir, ctx, folder, feature) {
520
+ if (!feature) return true;
521
+ if (!folder) return false;
522
+ try {
523
+ const { features } = JSON.parse(readFileSync(join(kitDir, '.slices', ctx, folder, 'slice.json'), 'utf-8'));
524
+ return Array.isArray(features) && features.some((f) => typeof f === 'string' && f.trim().toLowerCase() === feature);
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+
511
530
  function readCurrentContext(kitDir) {
512
531
  const ctxPath = join(kitDir, '.slices', 'current_context.json');
513
532
  if (!existsSync(ctxPath)) return null;
@@ -531,7 +550,7 @@ function readSliceMeta(kitDir, ctx, folder) {
531
550
  // before the next one starts. When the current context has no planned work, the first other
532
551
  // context that does becomes the current one (current_context.json is rewritten), so a slice
533
552
  // set to Planned in any context is eventually built. Ids in `skipIds` are passed over.
534
- function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
553
+ export function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
535
554
  const slicesDir = join(kitDir, '.slices');
536
555
  const currentCtx = readCurrentContext(kitDir);
537
556
  let others = [];
@@ -539,13 +558,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
539
558
  others = readdirSync(slicesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
540
559
  } catch {}
541
560
  const pickup = statusKey(readPickupStatus(kitDir));
561
+ const feature = readFeatureFilter();
542
562
  const candidates = [...(currentCtx ? [currentCtx] : []), ...others.filter((c) => c !== currentCtx)];
543
563
  for (const ctx of candidates) {
544
564
  const indexPath = join(slicesDir, ctx, 'index.json');
545
565
  if (!existsSync(indexPath)) continue;
546
566
  try {
547
567
  const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
548
- const planned = slices && slices.find((s) => statusKey(s.status) === pickup && !skipIds.has(s.id));
568
+ const planned = slices && slices.find((s) => statusKey(s.status) === pickup && !skipIds.has(s.id) && sliceInFeature(kitDir, ctx, s.folder, feature));
549
569
  if (!planned) continue;
550
570
  if (ctx !== currentCtx) {
551
571
  writeFileSync(join(slicesDir, 'current_context.json'), JSON.stringify({ name: ctx }, null, 2), 'utf-8');
@@ -561,13 +581,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
561
581
  function listPlannedIds(kitDir) {
562
582
  const slicesDir = join(kitDir, '.slices');
563
583
  const pickup = statusKey(readPickupStatus(kitDir));
584
+ const feature = readFeatureFilter();
564
585
  const ids = new Set();
565
586
  try {
566
587
  for (const d of readdirSync(slicesDir, { withFileTypes: true })) {
567
588
  if (!d.isDirectory()) continue;
568
589
  try {
569
590
  const { slices } = JSON.parse(readFileSync(join(slicesDir, d.name, 'index.json'), 'utf-8'));
570
- for (const s of slices ?? []) if (statusKey(s.status) === pickup) ids.add(s.id);
591
+ for (const s of slices ?? []) if (statusKey(s.status) === pickup && sliceInFeature(kitDir, d.name, s.folder, feature)) ids.add(s.id);
571
592
  } catch {}
572
593
  }
573
594
  } catch {}
@@ -770,6 +791,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false,
770
791
  const board = credentialed ? createBoard(cfg) : null;
771
792
  const meta = eventMeta(cfg);
772
793
  ensureLearningsGitRules(kitDir);
794
+ if (readFeatureFilter()) console.log(`[ralph] --feature "${process.env.RALPH_FEATURE.trim()}" — only slices of that feature are picked up.`);
773
795
  // Sharing the event log with the board (`run --sync`): needs the board, so never with --local.
774
796
  const share = createBuildLogShare({ kitDir, cfg, meta, agentType: cfg?.agentType ?? 'BUILD', kitType: kitTypeOf(readPickupStatus(kitDir)), sync: process.env.RALPH_SYNC === '1', enabled: credentialed, log: (line) => console.log(`[ralph] ${line}`) });
775
797
  if (process.env.RALPH_SYNC === '1' && !credentialed) console.log('[ralph] --sync needs board credentials — the build log stays local.');
@@ -831,6 +831,29 @@ Build structured slice data from board state.
831
831
 
832
832
  ---
833
833
 
834
+ ### Slice features (`features`)
835
+
836
+ A slice can belong to one or more **features** — free-text names such as `Seat reservation` that group slices across chapters (the people on the board see them in the Features list in the toolbar, where picking one fades every column that isn't part of it). They live on the slice's SLICE_BORDER as `meta.features`, an array of strings.
837
+
838
+ - **Read**: the slice data of a slice carries `features` (omitted when it has none), next to `ticketNumber` and `assignee`.
839
+ - **Use**: treat `features` as context — slices sharing a feature belong together, so when a build touches shared concepts, look at the sibling slices of the same feature before inventing a new name or shape. It never changes what a slice must do, and an empty list is normal.
840
+ - **Set** (only when the user asks you to group slices): a `node:changed` event on the SLICE_BORDER, same shape as the status update. Send the **whole** list — it replaces the old one — and reuse an existing feature's exact spelling instead of adding a near-duplicate (`Booking` vs `booking`; the board treats them as one, but keep them tidy):
841
+
842
+ ```bash
843
+ curl -s -X POST "<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/events" \
844
+ -H "Content-Type: application/json" \
845
+ -H "x-token: <TOKEN>" \
846
+ -d '[{
847
+ "id": "<new-random-uuid>",
848
+ "eventType": "node:changed",
849
+ "nodeId": "<SLICE_NODE_ID>",
850
+ "changedAttributes": ["features"],
851
+ "meta": { "type": "SLICE_BORDER", "features": ["Seat reservation"] }
852
+ }]'
853
+ ```
854
+
855
+ ---
856
+
834
857
  ### GET `/api/org/:orgId/boards/:boardId/slicedata/slices`
835
858
  List all slices on a board.
836
859
 
@@ -57,7 +57,7 @@ This returns `{ "slices": [ { "id": "...", "title": "...", "status": "..." } ] }
57
57
  mcp__eventmodelers__get_slice_data { "boardId": "<BOARD_ID>", "contextName": "<name>" }
58
58
  ```
59
59
 
60
- This call stays full (no `projection`): Step 3 persists the complete slice object as `slice.json`, which the build skills read fields, specs and comments from. When only one slice is needed, pass `sliceId` as well.
60
+ This call stays full (no `projection`): Step 3 persists the complete slice object as `slice.json`, which the build skills read fields, specs and comments from. It also carries the slice's `features` (names grouping slices across chapters, see `learn-eventmodelers-api` → "Slice features"), `ticketNumber` and `assignee` when it has them. When only one slice is needed, pass `sliceId` as well.
61
61
 
62
62
  (`get_slice_data` requires a `contextName` or `contextId`. If the context isn't already known, list the context names in one call — `mcp__eventmodelers__get_nodes { "boardId": "<BOARD_ID>", "type": "MODEL_CONTEXT", "projection": "line" }` (only titles needed; a timeline name works as `contextName` too, so fall back to `type: "CHAPTER"` with `projection: "line"` when there are no MODEL_CONTEXT nodes) — then map slices to contexts with `list_slices { "boardId": "<BOARD_ID>", "contextName": "<name>" }` per context. Don't `get_node` each `SLICE_BORDER`.)
63
63