@eventmodelers/cli 2.0.44 → 2.0.46
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/package.json +1 -1
- package/shared/build-kit/lib/ralph.js +26 -5
- package/shared/build-kit/lib/slice-projection.js +36 -0
- package/shared/build-kit/lib/slice-refresh.js +35 -0
- package/shared/skills/learn-eventmodelers-api/references/mcp-tool-catalog.md +1 -1
- package/shared/skills/learn-eventmodelers-api/references/s03-nodes.md +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.46",
|
|
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": {
|
|
@@ -14,7 +14,8 @@ import { execFileSync } from 'child_process';
|
|
|
14
14
|
import { randomUUID } from 'crypto';
|
|
15
15
|
import { createRealtimeAdapter, keepSubscribed } from './adapters/realtime-adapter.js';
|
|
16
16
|
import { backoffMs, sleep } from './backoff.js';
|
|
17
|
-
import { adoptAgentEdits, observeBoard, projectStatus, statusOf } from './slice-projection.js';
|
|
17
|
+
import { adoptAgentEdits, applyBoardStatus, observeBoard, projectStatus, statusOf } from './slice-projection.js';
|
|
18
|
+
import { coalesce } from './slice-refresh.js';
|
|
18
19
|
import { fold, statusKey } from './slice-state.js';
|
|
19
20
|
import { readEvents } from './slice-events.js';
|
|
20
21
|
import { claimSlice, recordEvent, settleSlice, settleLeftoverClaims } from './slice-run.js';
|
|
@@ -364,7 +365,18 @@ async function fetchFullSlice(cfg, kitDir, sliceId) {
|
|
|
364
365
|
}
|
|
365
366
|
}
|
|
366
367
|
|
|
367
|
-
|
|
368
|
+
// Re-reads of the slice list caused by other people's changes: a burst of events (an import, a bulk status
|
|
369
|
+
// change) becomes one read per agent instead of one per event.
|
|
370
|
+
const SLICE_REFRESH_DELAY_MS = 1500;
|
|
371
|
+
const refreshSlices = coalesce(fetchAndPersistSlices, SLICE_REFRESH_DELAY_MS);
|
|
372
|
+
|
|
373
|
+
// A slice:changed that only moved a slice's status carries everything the local files need — the other
|
|
374
|
+
// fields of the payload are the ones the write set, so all of them null means none was touched. Anything
|
|
375
|
+
// else (a title, assignee, ticket or feature, or a slice not listed yet) is read from the board.
|
|
376
|
+
const isStatusOnly = (payload) =>
|
|
377
|
+
!!payload?.sliceStatus && payload.sliceTitle == null && payload.assignee == null && payload.ticketNumber == null && payload.feature == null;
|
|
378
|
+
|
|
379
|
+
export async function handleSliceChanged(payload, cfg, kitDir, refresh = refreshSlices) {
|
|
368
380
|
console.log(`[agent] slice:changed — slice="${payload.sliceTitle ?? payload.sliceId}" status="${payload.sliceStatus}"`);
|
|
369
381
|
// This agent's own status writes (claim → InProgress, → Done, → Blocked) come straight back
|
|
370
382
|
// as slice:changed. Queuing them only buys a turn in which the agent reads its own echo and
|
|
@@ -376,9 +388,12 @@ async function handleSliceChanged(payload, cfg, kitDir) {
|
|
|
376
388
|
console.log(`[agent] Own write — not queued (slice="${payload.sliceTitle ?? payload.sliceId}" status="${payload.sliceStatus}")`);
|
|
377
389
|
return;
|
|
378
390
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
391
|
+
const appliedLocally = isStatusOnly(payload) && applyBoardStatus(kitDir, payload.sliceId, payload.sliceStatus, eventMeta(cfg));
|
|
392
|
+
if (!appliedLocally) {
|
|
393
|
+
await refresh(cfg, kitDir).catch((err) =>
|
|
394
|
+
console.error('[agent] Slice persist error:', err),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
382
397
|
// Slices in the kit's pickup status (Planned, or Waiting for UI kits) are handled by
|
|
383
398
|
// onPlannedSlice directly — no task needed.
|
|
384
399
|
if (statusKey(payload.sliceStatus) !== statusKey(readPickupStatus(kitDir))) {
|
|
@@ -464,6 +479,12 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', createAdap
|
|
|
464
479
|
},
|
|
465
480
|
});
|
|
466
481
|
|
|
482
|
+
// A slice:changed that never arrived (a dropped message the reconnect logic cannot see) would leave the
|
|
483
|
+
// local copy stale until the next change — read the whole list now and then to catch it up.
|
|
484
|
+
setInterval(() => {
|
|
485
|
+
refreshSlices(cfg, kitDir).catch((err) => console.error(`[agent] ${ts()} Scheduled slice reconcile failed:`, err));
|
|
486
|
+
}, 10 * 60 * 1000);
|
|
487
|
+
|
|
467
488
|
setInterval(() => {
|
|
468
489
|
refreshToken('scheduled 10min refresh').catch((err) => console.error(`[agent] ${ts()} Scheduled token refresh failed:`, err));
|
|
469
490
|
}, 10 * 60 * 1000);
|
|
@@ -24,6 +24,42 @@ export function observeBoard(kitDir, boardSlices, meta, current = fold(readEvent
|
|
|
24
24
|
return changed ? fold(readEvents(kitDir)) : state;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// The board's slice statuses as the slice list reports them (the backend's STATUS_MAP). 'Handover' is the
|
|
28
|
+
// legacy name of Waiting. Undefined for anything else — the caller then reads the board instead of guessing.
|
|
29
|
+
const BOARD_STATUSES = new Set(['Created', 'Ready', 'Done', 'InProgress', 'Blocked', 'Assigned', 'Review', 'Waiting', 'Planned', 'Informational']);
|
|
30
|
+
export function normalizeBoardStatus(raw) {
|
|
31
|
+
if (raw === 'Handover') return 'Waiting';
|
|
32
|
+
return BOARD_STATUSES.has(raw) ? raw : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Whether the slice is already in a context's index.json — a slice the agent has never listed has to be
|
|
36
|
+
// fetched, because its title, type and context are not in a status event.
|
|
37
|
+
function knowsSlice(kitDir, sliceId) {
|
|
38
|
+
const slicesDir = join(kitDir, '.slices');
|
|
39
|
+
if (!existsSync(slicesDir)) return false;
|
|
40
|
+
for (const ctx of readdirSync(slicesDir, { withFileTypes: true })) {
|
|
41
|
+
if (!ctx.isDirectory()) continue;
|
|
42
|
+
try {
|
|
43
|
+
const index = JSON.parse(readFileSync(join(slicesDir, ctx.name, 'index.json'), 'utf-8'));
|
|
44
|
+
if ((index.slices ?? []).some((e) => e.id === sliceId)) return true;
|
|
45
|
+
} catch { /* an unreadable index is left for the next full fetch to repair */ }
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A status change the board announced for a slice already on disk, applied without reading the board again:
|
|
51
|
+
// the same two steps a full refresh takes for status — record what the board shows (observeBoard, after
|
|
52
|
+
// adopting the agent's own edits), then project the log's status into the files — so a claim held here still
|
|
53
|
+
// wins over the board's InProgress. Returns false when the slice is unknown or the status unrecognised; the
|
|
54
|
+
// caller then falls back to a full refresh.
|
|
55
|
+
export function applyBoardStatus(kitDir, sliceId, boardStatus, meta) {
|
|
56
|
+
const status = normalizeBoardStatus(boardStatus);
|
|
57
|
+
if (!sliceId || !status || !knowsSlice(kitDir, sliceId)) return false;
|
|
58
|
+
const state = observeBoard(kitDir, [{ id: sliceId, status }], meta, adoptAgentEdits(kitDir, fold(readEvents(kitDir)), meta));
|
|
59
|
+
projectStatus(kitDir, state);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
27
63
|
// The build agent still records its outcome by editing a slice's status in index.json (until it has a
|
|
28
64
|
// command of its own). While a slice is claimed the projection keeps that file equal to the log, so any
|
|
29
65
|
// difference is the agent's edit — adopted as `agent-recorded`. This runs before every refresh and before
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// One refresh for a burst of requests. Every agent on a board re-reads the slice list when someone else
|
|
2
|
+
// changes a slice, so an import or a bulk status change would otherwise read it once per event, per agent.
|
|
3
|
+
//
|
|
4
|
+
// request() resolves once a run that STARTED after the call has finished — a request made while a run is
|
|
5
|
+
// already in flight gets a fresh run of its own, because the data that run read may predate it. Requests
|
|
6
|
+
// within `delayMs` of each other share one run, which receives the arguments of the last request.
|
|
7
|
+
|
|
8
|
+
export function coalesce(fn, delayMs) {
|
|
9
|
+
let waiters = [];
|
|
10
|
+
let timer = null;
|
|
11
|
+
let running = false;
|
|
12
|
+
let lastArgs = [];
|
|
13
|
+
|
|
14
|
+
const run = async () => {
|
|
15
|
+
timer = null;
|
|
16
|
+
running = true;
|
|
17
|
+
const batch = waiters;
|
|
18
|
+
waiters = [];
|
|
19
|
+
try {
|
|
20
|
+
await fn(...lastArgs);
|
|
21
|
+
for (const w of batch) w.resolve();
|
|
22
|
+
} catch (err) {
|
|
23
|
+
for (const w of batch) w.reject(err);
|
|
24
|
+
} finally {
|
|
25
|
+
running = false;
|
|
26
|
+
if (waiters.length && !timer) timer = setTimeout(run, delayMs);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (...args) => new Promise((resolve, reject) => {
|
|
31
|
+
lastArgs = args;
|
|
32
|
+
waiters.push({ resolve, reject });
|
|
33
|
+
if (!running && !timer) timer = setTimeout(run, delayMs);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -11,7 +11,7 @@ Write tools take their items as an array (`nodeIds[]`, `elements[]`, `connection
|
|
|
11
11
|
| Tool | Args | Purpose | REST equivalent |
|
|
12
12
|
|---|---|---|---|
|
|
13
13
|
| `list_boards` | — | List boards for the org | §1 `GET /api/boards` (org-scoped) |
|
|
14
|
-
| `get_nodes` | `boardId`, `type?`, `name?`, `chapterId?`, `nodeIds?`, `projection?` (`"line"` \| `"fields"`) | List nodes, optionally by type and/or a partial case-insensitive title match. **
|
|
14
|
+
| `get_nodes` | `boardId`, `type?`, `name?`, `chapterId?`, `nodeIds?`, `projection?` (`"line"` \| `"fields"`) | List nodes, optionally by type and/or a partial case-insensitive title match. **A filter is required** — `type`, `name`, `chapterId` or `nodeIds`; a call without one is rejected (`NODES_FILTER_REQUIRED`) because it would load the whole board, and a `projection` alone does not count. Full `meta` is about 2 KB per node, so add a projection (`line` / `fields`) on top of the filter when you only need an overview. `chapterId` scopes to one timeline — prefer this over an unscoped board-wide call whenever the step is working within one chapter (the common case); `nodeIds` fetches a known, scattered subset in one call (e.g. re-verifying exactly the nodes just touched by a batch write) instead of a full `type` refetch; `projection: "line"` maps each match to `{id, type, title, fields}` — `fields` being just the attribute names; `projection: "fields"` to `{id, type, title, parentId, fields}` with the full field definitions (type, example, idAttribute, …) but no html, description, sketch or grid data. No `cellName` — an existing node is addressed by its `id`; when a cell address is needed (placing into a cell), read it from `get_board_outline`. No `sliceStatus` — take it per column from `get_board_outline` | §3 `GET .../nodes` (REST records carry no `cellName`, `projection=line`/`fields` included) |
|
|
15
15
|
| `get_node` | `boardId`, `nodeId`, `projection?` (`"cells"` \| `"edges"`) | Get one node (no `cellName` — address it by `nodeId`; cell addresses come only from `get_board_outline`). `projection: "cells"` (CHAPTER nodes only) returns just `{rows, columns, cells}` instead of the full `timelineData` — use whenever only the grid/occupancy is needed, not the whole chapter; `projection: "edges"` returns just that node's inbound/outbound connections instead of `findNodeById`'s full record. Both are opt-in — omitting `projection` is the unchanged full response. An unknown id is a `NODE_NOT_FOUND` error | §3 `GET .../nodes/:nodeId` (REST returns the node as stored, without `cellName`; `404` carries `code: 'NODE_NOT_FOUND'`) |
|
|
16
16
|
| `get_node_comments` | `boardId`, `nodeId` | List comments on a node | §1 `GET .../nodes/:nodeId/comments` |
|
|
17
17
|
| `get_chat_attachment` | `boardId`, `sessionId`, `attachmentId` | The file a chat message came with — the message's `attachments` list `{id, type, ending}`. Images come back as images, a PDF as an embedded resource, csv / xml / txt / json as text. Only the agent the message was addressed to can read it (`CHAT_ATTACHMENT_NOT_FOUND`); needs the connection's `x-agent-id` (`CHAT_AGENT_REQUIRED`). Max 5 MB per file. | §15 `GET /api/org/:orgId/chat/sessions/:sessionId/attachments/:attachmentId` |
|
|
@@ -66,12 +66,13 @@ interface NodeChangeEvent {
|
|
|
66
66
|
---
|
|
67
67
|
|
|
68
68
|
### GET `/api/org/:orgId/boards/:boardId/nodes`
|
|
69
|
-
List
|
|
69
|
+
List a board's nodes. **A filter is required**: a call with none of `type`, `name`, `chapterId`, `toplevel=true` or `cellId`/`colId` (with `timelineId`) is `400 NODES_FILTER_REQUIRED`, because it would load the whole board — a `projection` alone does not count. Read a board in parts: `toplevel=true` (the nodes in no chapter), then `chapterId` per chapter; or fetch known ids with `POST …/nodes/query`.
|
|
70
70
|
|
|
71
71
|
**Query params** (each at most once — a repeated or nested param is `400 QUERY_PARAM_INVALID`):
|
|
72
72
|
- `type?: ElementType` — exact match.
|
|
73
73
|
- `name?: string` — partial, case-insensitive title match.
|
|
74
74
|
- `chapterId?: string` — only nodes placed in this chapter (timeline).
|
|
75
|
+
- `toplevel?: "true"` — only the nodes placed in no chapter (the chapters themselves, contexts, drawings); not combinable with `chapterId`, `cellId` or `colId` (`400 TOP_LEVEL_FILTER_CONFLICT`).
|
|
75
76
|
- `projection?: "line" | "fields"` — `line`: compact `{id, type, title, fields?}` per node (`fields` = attribute names only, absent on nodes without fields); `fields`: `{id, type, title, parentId, fields?}` with the full field definitions as stored, but no html, description, sketch or grid data. Either instead of the full record. Neither form carries `cellName` — address existing nodes by `id`. Any other value is `400 PROJECTION_INVALID`.
|
|
76
77
|
- `cellId?: string` — return only the node occupying this timeline cell (format `<rowId>-<colId>`). Requires `timelineId`. Empty array if the cell is unoccupied.
|
|
77
78
|
- `colId?: string` — return every node occupying this column, across *all* rows of the timeline (e.g. to check for an existing COMMAND/READMODEL/SCREEN/AUTOMATION before placing one, since the server caps those at one per column even across separate lane rows). Requires `timelineId`. Combine with `type` to narrow to one element type.
|