@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "2.0.44",
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
- async function handleSliceChanged(payload, cfg, kitDir) {
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
- await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
380
- console.error('[agent] Slice persist error:', err),
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. **Unscoped with full `meta` it is expensive** — about 2 KB per node, ~1 MB on a 500-node board — so always pass a filter or a projection. `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) |
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 all nodes on a board.
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.