@eventmodelers/cli 2.0.9 → 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.
@@ -1,125 +1,5 @@
1
- // Registers the eventmodelers MCP server with an external agent harness (OpenCode, Gemini, Codex, Hermes),
2
- // for both loops that hand turns to one: the modeling loop (lib/modeling-exec.js) and the build
3
- // loop (ralph-exec.js).
4
-
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
6
- import { spawnSync } from 'child_process';
7
- import { homedir } from 'os';
8
- import { dirname, join } from 'path';
9
-
10
- // POSIX single-quote escaping: close, insert an escaped quote, reopen. A turn is multi-line
11
- // text with backticks and $ in it, so it cannot go in unquoted.
12
- export function shellQuote(s) {
13
- return `'${String(s).replace(/'/g, `'\\''`)}'`;
14
- }
15
-
16
- // Older OpenCode releases have no `run --standalone`; passing it makes `opencode run` print its
17
- // usage and exit 1, so every turn fails. Ask the installed binary once. Overridable for tests.
18
- let standaloneSupported = null;
19
- export function opencodeSupportsStandalone() {
20
- if (standaloneSupported === null) {
21
- const r = spawnSync('opencode', ['run', '--help'], { encoding: 'utf8' });
22
- standaloneSupported = /--standalone\b/.test(`${r.stdout || ''}${r.stderr || ''}`);
23
- }
24
- return standaloneSupported;
25
- }
26
- export function setOpencodeStandaloneSupport(value) { standaloneSupported = value; }
27
-
28
- // Adds or refreshes one key in a project JSON config, leaving everything else in it alone. A file
29
- // that isn't plain JSON (a JSONC one with comments) is not rewritten — returns false instead, and
30
- // the caller says what to add by hand.
31
- function mergeJsonConfig(path, apply) {
32
- let config = {};
33
- if (existsSync(path)) {
34
- try { config = JSON.parse(readFileSync(path, 'utf8')); } catch { return false; }
35
- }
36
- apply(config);
37
- mkdirSync(dirname(path), { recursive: true });
38
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
39
- return true;
40
- }
41
-
42
- // How each known harness learns about the eventmodelers MCP server — the --exec counterpart of
43
- // ensureMcpRegistered's `.mcp.json` for Claude, and like it rewritten every run, always into the
44
- // harness's *project* config in the agent's working dir (never a user-wide one), and always with
45
- // the token as an env reference the harness resolves from the env this runner hands it, never its
46
- // value. Each entry returns the command to run, which is where a harness without a usable project
47
- // config (Codex only loads one for a trusted project) takes the server as flags instead. Adding a
48
- // harness is one entry here.
49
- const MCP_REGISTRATIONS = {
50
- opencode: (command, projectDir, url) => {
51
- const entry = {
52
- type: 'remote',
53
- url,
54
- headers: { 'x-token': '{env:EVENTMODELERS_TOKEN}', 'x-agent-id': '{env:EVENTMODELERS_AGENT_ID}' },
55
- };
56
- const ok = mergeJsonConfig(join(projectDir, 'opencode.json'), (config) => {
57
- // OpenCode 2 nests servers under mcp.servers and still reads the older flat mcp.<name>;
58
- // follow whichever form the file already uses.
59
- config.mcp = config.mcp || {};
60
- if (config.mcp.servers) config.mcp.servers.eventmodelers = entry;
61
- else config.mcp.eventmodelers = entry;
62
- });
63
- // A plain `opencode run` hands the turn to opencode's shared background service, which was
64
- // started with some other env: {env:EVENTMODELERS_TOKEN} resolves to nothing there and the
65
- // server refuses every call. --standalone runs a private server that inherits this one's env.
66
- // A binary without the flag runs the turn in-process already, so the flag is dropped, not added.
67
- const supported = opencodeSupportsStandalone();
68
- const standalone = !supported ? command.replace(/\s--standalone\b/, '')
69
- : /\s--(standalone|server)\b/.test(` ${command}`) ? command : `${command} --standalone`;
70
- return { command: standalone, file: ok ? 'opencode.json' : null, manual: ok ? null : `add mcp.eventmodelers = ${JSON.stringify(entry)} to opencode.json` };
71
- },
72
- gemini: (command, projectDir, url) => {
73
- const entry = { httpUrl: url, headers: { 'x-token': '$EVENTMODELERS_TOKEN', 'x-agent-id': '$EVENTMODELERS_AGENT_ID' } };
74
- const ok = mergeJsonConfig(join(projectDir, '.gemini', 'settings.json'), (config) => {
75
- config.mcpServers = config.mcpServers || {};
76
- config.mcpServers.eventmodelers = entry;
77
- });
78
- return { command, file: ok ? '.gemini/settings.json' : null, manual: ok ? null : `add mcpServers.eventmodelers = ${JSON.stringify(entry)} to .gemini/settings.json` };
79
- },
80
- // Hermes reads MCP servers from ~/.hermes/config.yaml only (there is no project-level file), so
81
- // this never writes it — a user-wide file is not ours to rewrite. It reports the server as
82
- // registered when the file already names it (the docker images ship it), and says what to add otherwise.
83
- hermes: (command, _projectDir, url) => {
84
- const file = join(process.env.HERMES_HOME || join(homedir(), '.hermes'), 'config.yaml');
85
- const has = existsSync(file) && /^\s+eventmodelers:/m.test(readFileSync(file, 'utf8'));
86
- return {
87
- command,
88
- file: null,
89
- manual: has ? null : `add to ${file}:\nmcp_servers:\n eventmodelers:\n url: "${url}"\n headers:\n x-token: "\${EVENTMODELERS_TOKEN}"`,
90
- };
91
- },
92
- codex: (command, _projectDir, url) => ({
93
- // -c values are TOML; env_http_headers names the env var each header is read from.
94
- command: `${command} -c ${shellQuote(`mcp_servers.eventmodelers.url="${url}"`)} -c ${shellQuote('mcp_servers.eventmodelers.env_http_headers={x-token="EVENTMODELERS_TOKEN",x-agent-id="EVENTMODELERS_AGENT_ID"}')}`,
95
- file: null,
96
- manual: null,
97
- }),
98
- };
99
-
100
- // Which known harness a command runs — the first word naming one, so `npx @openai/codex exec`
101
- // and `/opt/bin/opencode run` are recognised as well as the bare names.
102
- export function harnessOf(command) {
103
- for (const word of String(command).trim().split(/\s+/).slice(0, 3)) {
104
- const name = word.split('/').pop().replace(/@.*$/, '');
105
- if (MCP_REGISTRATIONS[name]) return name;
106
- }
107
- return null;
108
- }
109
-
110
- // Registers the MCP server for the command's harness. Returns the command to run with it, and
111
- // whether the server is now registered — false for an unknown command or a config it could not
112
- // update, where the turn still has to connect the way the kit says to.
113
- export function registerMcp(command, projectDir, baseUrl, log) {
114
- const url = `${baseUrl}/mcp`;
115
- const harness = harnessOf(command);
116
- if (!harness) {
117
- log(`MCP: unknown agent command — register the eventmodelers MCP server with it yourself: ${url}, header x-token from $EVENTMODELERS_TOKEN (and x-agent-id from $EVENTMODELERS_AGENT_ID)`);
118
- return { command, registered: false };
119
- }
120
- const reg = MCP_REGISTRATIONS[harness](command, projectDir, url);
121
- if (reg.manual) log(`MCP: could not update the ${harness} config${harness === 'hermes' ? '' : ' (not plain JSON)'} — ${reg.manual}`);
122
- else log(`MCP: eventmodelers registered for ${harness}${reg.file ? ` in ${reg.file}` : ' via -c flags'}`);
123
- return { command: reg.command, registered: !reg.manual };
124
- }
1
+ // Registers the eventmodelers MCP server with an external agent harness. The per-harness logic
2
+ // lives in ./harnesses/<name>.js; this module is the stable import for both loops that hand turns
3
+ // to one: the modeling loop (lib/modeling-exec.js) and the build loop (ralph-exec.js).
125
4
 
5
+ export { shellQuote, harnessOf, registerMcp, opencodeSupportsStandalone, setOpencodeStandaloneSupport } from './harnesses/index.js';
@@ -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