@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.
- package/README.md +3 -1
- package/cli.js +153 -35
- package/extensions/eventcatalog/README.md +23 -0
- package/extensions/eventcatalog/index.js +32 -0
- package/extensions/eventcatalog/load.js +78 -0
- package/extensions/eventcatalog/manifest.js +139 -0
- package/extensions/eventcatalog/model.js +111 -0
- package/extensions/eventcatalog/render.js +82 -0
- package/extensions/eventcatalog/write.js +63 -0
- package/lib/modeling-exec.js +27 -4
- package/package.json +2 -1
- package/shared/build-kit/lib/harnesses/codex.js +25 -0
- package/shared/build-kit/lib/harnesses/cursor.js +62 -0
- package/shared/build-kit/lib/harnesses/gemini.js +19 -0
- package/shared/build-kit/lib/harnesses/hermes.js +28 -0
- package/shared/build-kit/lib/harnesses/index.js +66 -0
- package/shared/build-kit/lib/harnesses/opencode.js +51 -0
- package/shared/build-kit/lib/harnesses/util.js +24 -0
- package/shared/build-kit/lib/mcp-register.js +4 -124
- package/shared/build-kit/lib/ralph.js +27 -5
- package/shared/skills/learn-eventmodelers-api/SKILL.md +23 -0
- package/shared/skills/load-slice/SKILL.md +1 -1
|
@@ -1,125 +1,5 @@
|
|
|
1
|
-
// Registers the eventmodelers MCP server with an external agent harness
|
|
2
|
-
//
|
|
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
|
|
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
|
|