@eventmodelers/cli 2.0.44 → 2.0.47

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.47",
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` |
@@ -35,7 +35,7 @@ Write tools take their items as an array (`nodeIds[]`, `elements[]`, `connection
35
35
  | `place_element` | `boardId`, `timelineId`, `elements[]` (each `action?` = `create` (default) with `elementType`, `title`, `fields?`, `lane?`, `columnIndex?`, `autoConnect?` · `move` with `nodeId` + target `cellName` · `clear` with `nodeId` (preferred for an existing node) or `cellName`), `compact?` | Find/create an empty cell in the right lane and place a COMMAND/READMODEL/EVENT/AUTOMATION/MARKDOWN/TABLE. `action: "move"` puts an existing node of any type into that (empty) cell, moving it if it already sits elsewhere on the timeline; `action: "clear"` unplaces a node without deleting it (`delete_node` deletes) — pass its `nodeId` to empty the cell it sits in, or a `cellName`. These replace `drop_node_to_cell`, `move_node_in_timeline` and `clear_cell`, which still exist but are deprecated — don't use them. `fields` writes the element's attributes in the same call — don't follow a placement with a `submit_node_events` just to set them. A whole slice's or column run's worth goes in one call, applied in order so each entry sees the columns the previous one added; answers `{results}` (`compact: true` → `{nodeId, cellName, columnIndex}` per entry). `autoConnect: false` places without wiring to timeline neighbors — wire the edges yourself | — (MCP-only convenience; composes §2+§3) |
36
36
  | `list_slices` | `boardId`, `contextName?` | List slices (id, title, status). `contextName` restricts to one MODEL_CONTEXT (or timeline) — an unknown name is a `CONTEXT_NOT_FOUND` error, not an empty list | §8 `GET .../slicedata/slices` |
37
37
  | `get_slice_rework` | `boardId`, `contextId` | How much each slice of one context has been reworked: changes, steps backwards, and reopens after Done, most reworked first, plus planning metrics over them. `contextId` is a MODEL_CONTEXT or a timeline (a timeline resolves to the context it belongs to). Always per context — there is no board-wide form | §8 `GET .../reporting/rework/contexts/:contextId` |
38
- | `update_slice_status` | `boardId`, `newStatus`, plus exactly one of `sliceId` / `sliceTitle` | Change a SLICE_BORDER's `sliceStatus`. With a title there is no need to call `list_slices` first; an ambiguous title comes back with its candidates. A slice being created takes its status from `create_slice`/`create_slice_definition` instead | — (via `node:changed` event, §3) |
38
+ | `update_slice_status` | `boardId`, `newStatus`, `statusReason?` (optional, but you should set it: one sentence — why; shown as an info icon in the planning view; stored as `meta.statusReason` next to `sliceStatus`), plus exactly one of `sliceId` / `sliceTitle` | Change a SLICE_BORDER's `sliceStatus`. With a title there is no need to call `list_slices` first; an ambiguous title comes back with its candidates. A slice being created takes its status from `create_slice`/`create_slice_definition` instead | — (via `node:changed` event, §3) |
39
39
  | `get_slice_data` | `boardId`, `contextName?`, `contextId?`, `sliceId?`, `projection?` (`"outline"` \| `"fields"` \| `"specs"`), `format?` | Full element graph for slices in a context. `projection` trims it first: `outline` = slice header + each element as `{id, title, type}`; `fields` = elements with fields and dependencies, no specs/comments/prompts; `specs` = specifications and storylines, no elements — prefer one over the full graph when only part is needed. `outline`/`specs` encode only as `json`, `yaml` or `toon` (other formats are a `PROJECTION_FORMAT_UNSUPPORTED` error); an unknown `sliceId` is `SLICE_NOT_FOUND` | §8 `GET /slicedata` |
40
40
  | `get_spec_info` | `boardId`, `timelineId`, `elementTypes?` | EVENT/COMMAND/READMODEL nodes valid in GWT steps. Pass `elementTypes` (subset of `EVENT`/`COMMAND`/`READMODEL`) to avoid pulling the full element list when only one or two types are needed — filtered server-side, not just after a full fetch | §6 `GET .../spec-info` |
41
41
  | `get_board_outline` | `boardId`, `chapterId` | One chapter's structure, compact: per-column node lists (`{id, type, title, lane, cellName}`) + a flat edge list, no HTML pages / field bodies / meta. `cellName` is each node's real cell address (column letter + row number) — the only read that returns cell names at all; use it when a placement needs a cell address. For an empty target cell, take the column's `letter` plus the lane's 1-based position in the outline's `lanes` list (e.g. `letter: "C"`, second lane → `C2`) — never convert array indexes yourself. The cheap "what is where and how is it wired" read — prefer over `get_nodes` (no projection) for orientation checks | — (MCP-only convenience) |
@@ -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.
@@ -129,10 +129,12 @@ or contradictory — not just "please clarify this slice."
129
129
 
130
130
  ## Step 4 — Mark the slice Blocked
131
131
 
132
+ Blocking is two things: the comment from Step 3 holds the full question, and the `statusReason` is its one-sentence summary (what is missing or contradictory) that people see as an exclamation-mark icon in the planning view and on the slice header. Set both — the reason is optional, but you should always give it here.
133
+
132
134
  Prefer MCP:
133
135
 
134
136
  ```
135
- mcp__eventmodelers__update_slice_status { "boardId": "<BOARD_ID>", "sliceId": "<SLICE_NODE_ID>", "newStatus": "Blocked" }
137
+ mcp__eventmodelers__update_slice_status { "boardId": "<BOARD_ID>", "sliceId": "<SLICE_NODE_ID>", "newStatus": "Blocked", "statusReason": "<one sentence: what is missing>" }
136
138
  ```
137
139
 
138
140
  Also mark the slice 'blocked' locally in the index.json if possible.
@@ -149,10 +151,11 @@ curl -s -X POST "<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/events" \
149
151
  "id": "<new-random-uuid>",
150
152
  "eventType": "node:changed",
151
153
  "nodeId": "<SLICE_NODE_ID>",
152
- "changedAttributes": ["sliceStatus"],
154
+ "changedAttributes": ["sliceStatus", "statusReason"],
153
155
  "meta": {
154
156
  "type": "SLICE_BORDER",
155
- "sliceStatus": "Blocked"
157
+ "sliceStatus": "Blocked",
158
+ "statusReason": "<one sentence: what is missing>"
156
159
  }
157
160
  }]'
158
161
  ```
@@ -17,6 +17,7 @@ From `$ARGUMENTS`, extract:
17
17
  |-------|---------------|---------|
18
18
  | `sliceName` | the slice title to update (case-insensitive match) | **required** |
19
19
  | `newStatus` | the target status value | **required** |
20
+ | `statusReason` | ONE sentence saying why the status changes | optional, but you **should** set it |
20
21
 
21
22
  Valid status values (case-sensitive):
22
23
 
@@ -35,6 +36,13 @@ Valid status values (case-sensitive):
35
36
 
36
37
  If `newStatus` is not one of these exact values, stop and tell the user the valid options.
37
38
 
39
+ ### Give a reason (optional, but you should)
40
+
41
+ `statusReason` is optional — a status change works without it — but whenever you change a status you **should** send one: **one sentence**, in plain words, saying *why* the status changes
42
+ ("Backend built and tested, UI contract published.", "Blocked: the order total rule is unclear.", "Reviewed and approved by Anna.").
43
+ It is stored next to `sliceStatus` in the SLICE_BORDER's meta and shown to people as an exclamation-mark icon in the planning view and on the slice header,
44
+ so write it for a person who was not watching. Say what happened, not the status name again. For `Blocked`, also post a comment with the details (see `request-feedback`) and let `statusReason` be its short summary. Don't leave an old reason in place when the status changes — send a new one each time.
45
+
38
46
  ---
39
47
 
40
48
  ## Step 2 — List all slices
@@ -69,13 +77,13 @@ Save the matched slice as:
69
77
  Prefer the MCP tool — it does the same `node:changed`/`sliceStatus` update in one call, no event envelope to hand-assemble:
70
78
 
71
79
  ```
72
- mcp__eventmodelers__update_slice_status { "boardId": "<BOARD_ID>", "sliceId": "<SLICE_NODE_ID>", "newStatus": "<newStatus>" }
80
+ mcp__eventmodelers__update_slice_status { "boardId": "<BOARD_ID>", "sliceId": "<SLICE_NODE_ID>", "newStatus": "<newStatus>", "statusReason": "<one sentence: why>" }
73
81
  ```
74
82
 
75
83
  The tool also resolves the slice itself — pass `sliceTitle` (or `columnId`) instead of `sliceId` and the listing step above can be skipped entirely; an ambiguous title comes back with its candidates instead of a guess:
76
84
 
77
85
  ```
78
- mcp__eventmodelers__update_slice_status { "boardId": "<BOARD_ID>", "sliceTitle": "Place Order", "newStatus": "<newStatus>" }
86
+ mcp__eventmodelers__update_slice_status { "boardId": "<BOARD_ID>", "sliceTitle": "Place Order", "newStatus": "<newStatus>", "statusReason": "<one sentence: why>" }
79
87
  ```
80
88
 
81
89
  A slice that is being *created* takes its status straight from `create_slice`/`create_slice_definition` (`status`) — don't create it and then call this.
@@ -91,10 +99,11 @@ curl -s -X POST "<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/events" \
91
99
  "id": "<new-random-uuid>",
92
100
  "eventType": "node:changed",
93
101
  "nodeId": "<SLICE_NODE_ID>",
94
- "changedAttributes": ["sliceStatus"],
102
+ "changedAttributes": ["sliceStatus", "statusReason"],
95
103
  "meta": {
96
104
  "type": "SLICE_BORDER",
97
- "sliceStatus": "<newStatus>"
105
+ "sliceStatus": "<newStatus>",
106
+ "statusReason": "<one sentence: why>"
98
107
  }
99
108
  }]'
100
109
  ```
@@ -122,6 +131,7 @@ Tell the user:
122
131
  - **Slice**: the title that was updated
123
132
  - **Previous status**: `CURRENT_STATUS`
124
133
  - **New status**: `newStatus`
134
+ - **Reason**: `statusReason` (if set)
125
135
  - **Node ID**: `SLICE_NODE_ID`
126
136
  - **Outcome**: `SUCCESS`, `ALREADY_IN_STATUS` (another agent got there first — see above), or `ERROR`
127
137
  - **Any errors**: raw API message if something failed for a reason other than `ALREADY_IN_STATUS`
@@ -116,6 +116,7 @@ Rules of thumb: every API call is either a screen-driven command/read or an auto
116
116
  - `eventmodeling-designing-automation-chains` / `eventmodeling-translating-external-events` — jobs, listeners, webhooks, outbound calls;
117
117
  - `eventmodeling-elaborating-scenarios` — rules from the tests (layer 1+ only), built exactly as that skill says (see *Scenarios* below).
118
118
  Feed each skill what the lenses and the mapping table give you; ask only where the code stops (see *Step 1* when the order is unclear).
119
+ **Screens and scenarios are part of the pass — never skip them.** A chapter with only events, commands and read models is not detailed yet. Every command and read model a person uses gets its `HTML_SCREEN` (storyboarding step), and every command gets at least its happy-path scenario from `eventmodeling-elaborating-scenarios`. Never report "not done: screens, scenarios, tests, UI" and ask for the next chapter; run those steps first. If a step truly can't be done (e.g. no tests to mine for a rule), say why in the chapter's decision rows, but still place the screens and the happy-path scenario.
119
120
  2. Stay within the layer's detail budget (below). Every placement batch carries its decision rows (see *Recording decisions*).
120
121
  3. Re-space the chapters (see *Keeping chapters apart*) after every batch.
121
122
  4. Append this pass's row to the chapter's *Analysis history*.