@eventmodelers/cli 2.0.43 → 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.43",
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.
@@ -57,7 +57,7 @@ Guidelines:
57
57
  - **Each page may be at most 6000 characters** (the full markup string, including inline styles). The server rejects a longer page with `400 HTML_SCREEN_PAGE_TOO_LONG` (`context: {pageIndex, length, maxLength}`) and saves nothing — the render call fails as a whole. Check each page's length before rendering; if one is over, trim it (fewer inline styles, framework classes instead of custom CSS, fewer repeated sample rows) or split the content into another page. Adding a mark also counts toward the limit, since it adds attributes to the page's markup.
58
58
  - Inline styles (`style="..."`) are the simplest way to keep each page self-contained.
59
59
  - No `<script>` tags, no inline event handlers (`onclick`, `onload`, ...), no `javascript:` URIs — these are stripped server-side from every page before persisting regardless of what's sent. This is a static visual mockup, not an interactive prototype.
60
- - A real page background (e.g. a light gray full-bleed background behind a centered white card) reads more realistically than a bare form floating on white.
60
+ - **Full width by default — no fixed or maximum widths.** Unless the user explicitly asks for a specific width (e.g. "mobile layout", "420px card"), the page fills the whole screen. The preview, the movie strip and the board all render a page at a simulated device width and scale it to the frame, so the top-level container must stretch to the full width: no `max-width`, no fixed `width: 420px` / `w-[420px]` / `.container` / `mw-*` on the outermost element, and no auto-centered narrow card. Use `width:100%` (or nothing, a block element is already full width) with ordinary padding. A narrow card in a wide viewport shows as a small box in the corner. Inner pieces may be sized relative to the page (grid columns, percentages, `flex`), and a form may sit in a full-width section, but nothing at the top level caps the width. A real page background (e.g. a light gray full-bleed background) reads more realistically than a bare form floating on white.
61
61
  - Don't add `<html>`/`<head>`/`<body>` tags to a page — every page is a body-only fragment. The canvas wraps each page in its own `<html><head>` (stylesheet + resize script) `<body>...</body></html>` at render time, so anything sent is placed inside that generated `<body>`.
62
62
  - **CSS framework — one per screen.** Each screen loads exactly one CSS framework (or none) into every page's `<head>`, chosen by the user in the HTML editor's header and stored on the node as `cssFrameworks` (an array holding one id: `"bulma"`, `"bootstrap"` or `"tailwind"`; `[]` = none). A screen where nothing was chosen gets **Bulma** — that is the default for new screens. Write markup for the framework the screen uses, and never mix classes from several frameworks in one screen:
63
63
  - **Bulma 0.9.4** (default) — classes like `title`, `button`, `is-primary`, `field`/`control`/`input` work out of the box. Note headings need a size modifier too, e.g. `class="title is-1"` — a bare `title` class alone is always 2rem regardless of the tag (`h1` vs `h2` etc.).