@eventmodelers/cli 1.0.57 → 1.0.59

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 CHANGED
@@ -179,13 +179,22 @@ when you upgrade the CLI. Pass `--global` to prefer it even when a local kit doe
179
179
  npx @eventmodelers/cli run --standalone --board-id <uuid> # from any directory, nothing written there
180
180
  ```
181
181
 
182
+ **Which board?** When `--board-id` isn't given, the agent asks for it on start, pre-filled
183
+ with whatever the config resolved to — press Enter to accept it, or paste a different board
184
+ id. A board inherited from a config file can be arbitrarily stale, and which board a run
185
+ drives is the one thing worth confirming. Skipped when there's no one to ask (`--print`, or a
186
+ non-interactive stdin), where the resolved value stands on its own.
187
+
182
188
  **Credentials are per board, not per directory.** The first time this machine runs a board it
183
- asks one question — does this board get credentials of its own, or does it use your
189
+ asks one more question — does this board get credentials of its own, or does it use your
184
190
  account-wide ones? Answer once and it's remembered: either the board's credentials or a
185
191
  `useGlobal` marker lands in `~/.eventmodelers/boards/<board>.json`, and you're not asked
186
- again. The question is skipped entirely when the answer is already implied (credentials given
187
- on the command line) or when there's no one to ask (`--print`, or a non-interactive stdin such
188
- as CI or a process supervisor).
192
+ again. If the credentials you paste name a *different* board than the one asked about an easy
193
+ way to end up there is a stale `boardId` in `~/.eventmodelers/config.json` the run switches
194
+ to the pasted board and leaves a pointer behind for the one it asked about, so the question is
195
+ asked once rather than on every start. The question is skipped entirely when the answer is
196
+ already implied (credentials given on the command line) or when there's no one to ask
197
+ (`--print`, or a non-interactive stdin such as CI or a process supervisor).
189
198
 
190
199
  To configure a board up front instead, paste the blob from
191
200
  [app.eventmodelers.ai/account](https://app.eventmodelers.ai/account):
@@ -211,27 +220,56 @@ reading its own `.eventmodelers/config.json`.
211
220
 
212
221
  Without `--standalone` the agent only ever answers direct messages. With it, the loop also
213
222
  subscribes to the board's own change channel — the same one the canvas and the build agents
214
- use — and when the board falls quiet after someone edits it, the agent gets a turn nobody
215
- asked for and decides for itself whether there's something a human collaborator would
216
- obviously have done: example data on a freshly placed element, a missing attribute on the
217
- rest of the chain, a screen for an empty SCREEN node, a question comment on a gap. It does at
218
- most one focused thing per change, adds rather than deletes, and answers `NOOP` when there's
219
- nothing worth doing (see the "Standalone board-change turns" section in
223
+ use — and the agent becomes a background collaborator on the board: when it falls quiet after
224
+ someone edits it, and again whenever the board has simply been sitting still for a while
225
+ (`EVENTMODELERS_STANDALONE_IDLE_MS`), the agent gets a turn nobody asked for.
226
+
227
+ What it does with that turn is *not* "react to the last event". The changed nodes are a
228
+ notification telling it where to look. The agent itself analyses all of them against the model
229
+ as a whole — each changed area in its slice and chain, plus whatever else is still obviously
230
+ unfinished — and decides what needs doing. Then it fans the work out: **one subagent per piece
231
+ of work that needs doing, all dispatched in parallel** (pieces sharing a slice or chain are
232
+ merged into one agent, so no two agents write to the same area). The decision stays with the
233
+ main agent; each subagent is an executor that carries out the one piece it was given, invoking
234
+ the matching skill for its own target — example data on a freshly placed element, a missing
235
+ attribute on the rest of the chain, a screen for an empty SCREEN node, a question comment on a
236
+ gap. Nothing needing doing means no agents are spawned at all: the turn adds nothing and
237
+ answers `NOOP` (see the "Standalone board-change turns" section in
220
238
  `.agent-modeling-kit/CLAUDE.md`).
221
239
 
222
- Its own writes come back on that same channel and the platform can't tell them apart from a
223
- human's, so the lane is deliberately damped: it waits for a quiet period, ignores everything
224
- that arrives while a turn runs or shortly after one ends, and never fires twice in quick
225
- succession. Override the three windows if the defaults don't suit your board:
240
+ `--max-agents <n>` caps that fan-out, so an unattended turn's cost stays bounded default 5:
241
+
242
+ ```bash
243
+ npx @eventmodelers/cli run --standalone --board-id <uuid> --max-agents 3
244
+ ```
245
+
246
+ Work sharing a slice or chain is merged into one agent first (that part is about not clobbering
247
+ the board, not about the cap); if more pieces are still left than the cap allows, the agent
248
+ dispatches the most valuable ones and leaves the rest for a later turn. `--max-agents 1` means
249
+ no subagents at all: the turn does the single most valuable piece itself. The cap rides along in
250
+ the turn's own instructions rather than being enforced from outside — the `claude` process is
251
+ what spawns the agents — so it's a budget the agent is told to keep, not a hard ceiling.
252
+
253
+ Every event that arrives is remembered until a turn carries it — including events that land
254
+ while a turn is running. Its own writes come back on that same channel and the platform can't
255
+ tell them apart from a human's, so those are *labelled* for the agent rather than discarded,
256
+ and the lane is damped on timing instead: it waits for a quiet period (but not forever), waits
257
+ out the echo window of its last turn, never fires twice in quick succession, and widens that
258
+ floor each time it answers `NOOP`, so a finished board goes quiet by itself. Override the
259
+ windows if the defaults don't suit your board:
226
260
 
227
261
  | Env var | Default | What it controls |
228
262
  |---|---|---|
229
- | `EVENTMODELERS_STANDALONE_DEBOUNCE_MS` | `8000` | quiet period before a board change turns into a turn |
230
- | `EVENTMODELERS_STANDALONE_ECHO_WINDOW_MS` | `20000` | after a turn, how long incoming changes are treated as the agent's own echo |
263
+ | `EVENTMODELERS_STANDALONE_DEBOUNCE_MS` | `8000` | quiet period before buffered board changes turn into a turn |
264
+ | `EVENTMODELERS_STANDALONE_MAX_WAIT_MS` | `90000` | cap on that quiet period, so a board being edited continuously still gets a turn |
265
+ | `EVENTMODELERS_STANDALONE_ECHO_WINDOW_MS` | `20000` | after a turn, how long incoming changes are labelled as probably the agent's own echo |
231
266
  | `EVENTMODELERS_STANDALONE_MIN_INTERVAL_MS` | `60000` | floor between two self-directed turns |
267
+ | `EVENTMODELERS_STANDALONE_BACKOFF_CAP_MS` | `900000` | ceiling that floor doubles up to while turns keep answering `NOOP` |
268
+ | `EVENTMODELERS_STANDALONE_IDLE_MS` | `900000` | with nothing happening at all, how long before the agent reviews the model anyway (`0` disables it) |
232
269
 
233
- Direct prompts always outrank the agent's own initiative — a standalone turn waits while
234
- anything from the prompt queue is running.
270
+ Direct prompts always outrank the agent's own initiative — a self-directed turn waits while
271
+ anything from the prompt queue is running, and the changes it was about keep accumulating
272
+ meanwhile.
235
273
 
236
274
  ### Installing skills globally
237
275
 
package/cli.js CHANGED
@@ -349,7 +349,16 @@ function getSharedRl() {
349
349
  // called, hanging forever. Pulling from the iterator instead queues each line until
350
350
  // something asks for it, so nothing emitted ahead of time is ever lost between prompts.
351
351
  async function prompt(question = '') {
352
- getSharedRl();
352
+ const rl = getSharedRl();
353
+ // selectPrompt pauses stdin when it tears down its raw-mode keypress handler. That was
354
+ // invisible for as long as every menu came BEFORE the first prompt() — getSharedRl's
355
+ // createInterface resumes stdin on the way in, so a freshly built readline never noticed.
356
+ // Once a prompt runs first (the Board ID question), the interface already exists and is
357
+ // reused, so the next prompt after a menu waits forever on a stream nobody resumed: the
358
+ // event loop drains and node reports an unsettled top-level await instead of reading the
359
+ // line. Both calls are no-ops when nothing paused anything.
360
+ rl.resume();
361
+ process.stdin.resume();
353
362
  if (question) process.stdout.write(question);
354
363
  const { value, done } = await sharedRlLines.next();
355
364
  return (done ? '' : value).trim();
@@ -384,6 +393,26 @@ async function promptPasteBlock() {
384
393
  // rather than silently disabling platform sync.
385
394
  const DEFAULT_BASE_URL = 'https://api.eventmodelers.ai';
386
395
 
396
+ // How many subagents a self-directed --standalone turn may dispatch at once. It's a
397
+ // budget, not a mechanism: the turn runs inside one `claude` process, which is what
398
+ // actually spawns the agents, so the cap reaches it as part of the turn's instructions
399
+ // (buildStandaloneTurn) rather than as something the CLI can enforce from outside. Five
400
+ // keeps an unattended turn's cost in the same ballpark as a single prompt turn while
401
+ // still covering the usual burst — a handful of nodes across a couple of slices.
402
+ const DEFAULT_MAX_AGENTS = 5;
403
+
404
+ // `--max-agents` is a cost guard, so a typo must not silently turn into "no limit" or
405
+ // into the default: anything that isn't a positive integer is rejected outright.
406
+ function parseMaxAgents(raw) {
407
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_MAX_AGENTS;
408
+ const n = Number(raw);
409
+ if (!Number.isInteger(n) || n < 1) {
410
+ console.error(`❌ --max-agents must be a positive integer (got "${raw}").`);
411
+ process.exit(1);
412
+ }
413
+ return n;
414
+ }
415
+
387
416
  // This CLI's own version, stamped into every install manifest so the global modeling
388
417
  // install can tell whether it was written by the version now running (see ensureGlobalKit).
389
418
  const CLI_VERSION = readJsonSafe(join(__dirname, 'package.json')).version || '0.0.0';
@@ -1345,13 +1374,17 @@ function boardCredentialsPath(boardId) {
1345
1374
  return join(GLOBAL_DIR, 'boards', `${boardId}.json`);
1346
1375
  }
1347
1376
 
1348
- // Two shapes, never mixed: the board's own credentials, or a note that this board just
1349
- // uses the account-wide ones. The second is what makes the first-run question a
1350
- // once-per-board event instead of something to dismiss on every start.
1377
+ // Three shapes, never mixed: the board's own credentials, a note that this board just uses
1378
+ // the account-wide ones, or a pointer to another board's file. All three exist for the same
1379
+ // reason — the first-run question has to be a once-per-board event rather than something to
1380
+ // dismiss on every start, so every possible answer has to be recordable against the board
1381
+ // that was ASKED about, including "actually, those credentials were for a different board".
1351
1382
  function writeBoardCredentials(config) {
1352
1383
  const path = boardCredentialsPath(config.boardId);
1353
1384
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1354
- const body = config.useGlobal
1385
+ const body = config.useBoard
1386
+ ? { boardId: config.boardId, useBoard: config.useBoard }
1387
+ : config.useGlobal
1355
1388
  ? { boardId: config.boardId, useGlobal: true, agentId: config.agentId }
1356
1389
  : {
1357
1390
  token: config.token,
@@ -1407,8 +1440,36 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print)
1407
1440
  // Which board comes first — everything else is stored per board, so there is nothing to
1408
1441
  // look up until we know which board this run is for.
1409
1442
  let boardId = explicit.boardId || process.env.EVENTMODELERS_BOARD_ID || walked.boardId || null;
1443
+
1444
+ // Which board a run drives is the single most consequential thing about it, and a boardId
1445
+ // inherited from a config file can be arbitrarily stale — so when it wasn't named on the
1446
+ // command line, confirm it. Enter accepts whatever the config resolved to, keeping the
1447
+ // common case to one keystroke. Skipped when there is no one to ask (--print, or a
1448
+ // non-interactive stdin such as CI or a process supervisor), where the resolved value
1449
+ // stands on its own exactly as before.
1450
+ let boardChosen = !!(explicit.boardId || process.env.EVENTMODELERS_BOARD_ID);
1451
+ if (!boardChosen && !print && process.stdin.isTTY) {
1452
+ const answer = await prompt(boardId ? `\n Board ID [${boardId}]: ` : '\n Board ID: ');
1453
+ if (answer) {
1454
+ boardId = answer;
1455
+ boardChosen = true;
1456
+ }
1457
+ }
1458
+
1410
1459
  let stored = boardId ? readJsonSafe(boardCredentialsPath(boardId)) : {};
1411
1460
 
1461
+ // Follow a pointer left by an earlier answer: this directory (or the account config) keeps
1462
+ // resolving to one board, but the credentials pasted for it named another. Without this the
1463
+ // question below would be re-asked on every single start, since the board that gets a file
1464
+ // is never the board the next run resolves. Not followed when the board was named
1465
+ // explicitly — that is a direct instruction, not an inherited default. One hop only: a
1466
+ // pointer always targets a board that then holds real credentials, so a chain would mean a
1467
+ // corrupted store rather than something to chase.
1468
+ if (stored.useBoard && !boardChosen) {
1469
+ boardId = stored.useBoard;
1470
+ stored = readJsonSafe(boardCredentialsPath(boardId));
1471
+ }
1472
+
1412
1473
  // First time this machine has seen this board, ask the one question that can't be
1413
1474
  // guessed: does it get credentials of its own, or does it ride on the account-wide ones?
1414
1475
  // The answer is recorded either way (as credentials, or as a useGlobal marker), so this
@@ -1439,8 +1500,15 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print)
1439
1500
  console.error("\n❌ Couldn't make sense of that paste — nothing was saved.");
1440
1501
  process.exit(1);
1441
1502
  }
1442
- // The paste is the more specific answer about which board this is: someone who
1443
- // copied board B's credentials means board B, whatever the command line defaulted to.
1503
+ // The paste is the more specific answer about which board this is: someone who copied
1504
+ // board B's credentials means board B, whatever the command line defaulted to. But the
1505
+ // question was asked ABOUT board A, so board A needs an answer on file too — otherwise
1506
+ // the next run resolves A again, finds nothing, and asks all over again.
1507
+ if (parsed.boardId && boardId && parsed.boardId !== boardId) {
1508
+ writeBoardCredentials({ boardId, useBoard: parsed.boardId });
1509
+ console.log(`\n ℹ️ Those credentials are for board ${parsed.boardId}, not ${boardId} — noted, so this is asked once and not again.`);
1510
+ console.log(` Pass --board-id to pick a different board, or drop the stale boardId from ~/.eventmodelers/config.json.`);
1511
+ }
1444
1512
  if (parsed.boardId) boardId = parsed.boardId;
1445
1513
  stored = parsed;
1446
1514
  } else {
@@ -1547,7 +1615,7 @@ async function ensureGlobalKit(baseUrl) {
1547
1615
  // question, sketch a screen. Without the flag that channel is still subscribed on
1548
1616
  // the same connection and every event on it is dropped, so the two modes differ by
1549
1617
  // one filter rather than by a whole second realtime stack.
1550
- async function runModeling(kitDir, projectDir, verbose = false, standalone = false, overrides = null) {
1618
+ async function runModeling(kitDir, projectDir, verbose = false, standalone = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS) {
1551
1619
  const configLibPath = join(kitDir, 'lib', 'config.js');
1552
1620
  if (!existsSync(configLibPath)) {
1553
1621
  console.error(`❌ ${relative(process.cwd(), configLibPath)} not found — --modeling needs a kit installed via \`init --modeling\`.`);
@@ -1600,7 +1668,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1600
1668
  function withSessionHeader(body) {
1601
1669
  if (!firstTurn) return body;
1602
1670
  firstTurn = false;
1603
- return `MODE=modeling token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
1671
+ return `MODE=modeling token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
1604
1672
  }
1605
1673
 
1606
1674
  function buildTurn(p) {
@@ -1683,7 +1751,9 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1683
1751
  lastTurnEndedAt = Date.now();
1684
1752
  const turn = pending;
1685
1753
  pending = null;
1686
- if (turn) (msg.is_error ? turn.reject(new Error(msg.result || 'Claude turn errored')) : turn.resolve());
1754
+ // The result text is what a standalone turn's NOOP/DONE answer rides in — the
1755
+ // self-directed lane reads it to decide whether to back off (dispatchStandaloneTurn).
1756
+ if (turn) (msg.is_error ? turn.reject(new Error(msg.result || 'Claude turn errored')) : turn.resolve(msg.result ?? ''));
1687
1757
  }
1688
1758
  }
1689
1759
 
@@ -1721,7 +1791,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1721
1791
  spawnProcess();
1722
1792
  log(
1723
1793
  standalone
1724
- ? 'standalone: ON — reacting to direct prompts AND to board changes on its own initiative'
1794
+ ? `standalone: ON — reacting to direct prompts AND to board changes on its own initiative (max ${maxAgents} subagent(s) per self-directed turn)`
1725
1795
  : 'standalone: off — reacting to direct prompts only (board changes are dropped)',
1726
1796
  );
1727
1797
 
@@ -1760,6 +1830,9 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1760
1830
  }
1761
1831
  } finally {
1762
1832
  draining = false;
1833
+ // A prompt turn counts as activity: the board isn't idle just because nobody edited
1834
+ // it while the agent was busy answering someone.
1835
+ armIdleReview();
1763
1836
  }
1764
1837
  }
1765
1838
 
@@ -1775,55 +1848,91 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1775
1848
  // later adds to these payloads lands here without a client change.
1776
1849
  const BOARD_CHANGE_EVENTS = ['node:created', 'node:changed', 'node:deleted', 'edge:added', 'edge:removed', 'board:cleared'];
1777
1850
 
1778
- // Three guards, because a board event can't tell you who caused it: the platform
1851
+ // Four knobs, because a board event can't tell you who caused it: the platform
1779
1852
  // attributes an API token's writes to the org owner's user_id, so on this channel the
1780
- // agent's own edits are indistinguishable from the human's.
1853
+ // agent's own edits are indistinguishable from the human's. They all govern *when* a
1854
+ // self-directed turn fires — never whether an event is remembered. Everything that
1855
+ // arrives is buffered (see onBoardEvent): an event seen while a turn runs, or inside the
1856
+ // echo window, is only *marked* as possibly the agent's own write, so a burst that
1857
+ // straddles a turn boundary still reaches the next turn instead of being thrown away and
1858
+ // leaving the agent looking at whichever single event happened to land last.
1781
1859
  // DEBOUNCE — one gesture (place a node, drag a column) fans out into several
1782
1860
  // events; wait for the board to fall quiet, then send a single turn.
1783
- // ECHO_WINDOW anything arriving while a turn runs, or within this long after one
1784
- // ends, is assumed to be that turn's own writes coming back, and dropped.
1861
+ // MAX_WAIT cap on that quiet period: a board someone keeps editing never falls
1862
+ // quiet, and the debounce alone would slide forever.
1863
+ // ECHO_WINDOW — how long after a turn its own writes are expected back; changes in
1864
+ // that window are labelled, and the next turn waits it out.
1785
1865
  // MIN_INTERVAL — a floor between self-directed turns, so a mistake upstream can't
1786
- // become a self-feeding loop burning tokens unattended.
1866
+ // become a self-feeding loop burning tokens unattended. Doubles per
1867
+ // consecutive NOOP up to BACKOFF_CAP, and resets as soon as a turn
1868
+ // actually does something.
1869
+ // IDLE — with nothing at all happening on the board, how long before the agent
1870
+ // looks the model over anyway (a BOARD_REVIEW turn). 0 disables it, and
1871
+ // it answers to the same MIN_INTERVAL backoff, so a board with nothing
1872
+ // left to do goes quiet by itself rather than being swept every IDLE ms.
1787
1873
  const envMs = (name, fallback) => {
1788
1874
  const raw = Number(process.env[name]);
1789
1875
  return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
1790
1876
  };
1791
1877
  const STANDALONE_DEBOUNCE_MS = envMs('EVENTMODELERS_STANDALONE_DEBOUNCE_MS', 8_000);
1878
+ const STANDALONE_MAX_WAIT_MS = envMs('EVENTMODELERS_STANDALONE_MAX_WAIT_MS', 90_000);
1792
1879
  const STANDALONE_ECHO_WINDOW_MS = envMs('EVENTMODELERS_STANDALONE_ECHO_WINDOW_MS', 20_000);
1793
1880
  const STANDALONE_MIN_INTERVAL_MS = envMs('EVENTMODELERS_STANDALONE_MIN_INTERVAL_MS', 60_000);
1794
-
1795
- const observed = new Map(); // node_id (or '(board)') -> event types seen since the last standalone turn
1881
+ const STANDALONE_BACKOFF_CAP_MS = envMs('EVENTMODELERS_STANDALONE_BACKOFF_CAP_MS', 15 * 60_000);
1882
+ const STANDALONE_IDLE_MS = envMs('EVENTMODELERS_STANDALONE_IDLE_MS', 15 * 60_000);
1883
+
1884
+ // node_id (or '(board)') -> { types: Set<string>, count: number, maybeOwn: boolean }
1885
+ // for everything seen since the last self-directed turn. `maybeOwn` stays true only
1886
+ // while every event for that node arrived while a turn was running or inside the echo
1887
+ // window — one event from outside that window and the node is a real change again.
1888
+ const observed = new Map();
1796
1889
  let observedCount = 0;
1797
1890
  let seqLo = null;
1798
1891
  let seqHi = null;
1799
1892
  let standaloneTimer = null;
1893
+ let firstObservedAt = 0; // start of the current burst — MAX_WAIT is measured from here
1800
1894
  let lastStandaloneAt = 0;
1895
+ let noopStreak = 0;
1896
+
1897
+ // The floor between self-directed turns, widened while the agent keeps finding nothing
1898
+ // to do. A quiet board therefore costs a turn every MIN_INTERVAL, then 2×, 4×, … up to
1899
+ // BACKOFF_CAP, instead of one per interval forever.
1900
+ function minIntervalMs() {
1901
+ return Math.min(STANDALONE_MIN_INTERVAL_MS * 2 ** noopStreak, STANDALONE_BACKOFF_CAP_MS);
1902
+ }
1801
1903
 
1802
1904
  function onBoardEvent(type, payload) {
1803
1905
  if (!standalone) {
1804
1906
  if (verbose) log(`board event ${type} dropped — not running with --standalone`);
1805
1907
  return;
1806
1908
  }
1807
- if (pending || draining) {
1808
- if (verbose) log(`board event ${type} dropped — a turn is in flight (assumed own write)`);
1809
- return;
1810
- }
1811
1909
  const sinceTurn = Date.now() - lastTurnEndedAt;
1812
- if (lastTurnEndedAt && sinceTurn < STANDALONE_ECHO_WINDOW_MS) {
1813
- if (verbose) log(`board event ${type} dropped ${Math.round(sinceTurn / 1000)}s after a turn (assumed own write)`);
1814
- return;
1815
- }
1910
+ const inEchoWindow = !!lastTurnEndedAt && sinceTurn < STANDALONE_ECHO_WINDOW_MS;
1911
+ const maybeOwn = !!pending || draining || inEchoWindow;
1816
1912
  const nodeId = payload?.node_id ?? '(board)';
1817
- if (!observed.has(nodeId)) observed.set(nodeId, new Set());
1818
- observed.get(nodeId).add(type);
1913
+ const entry = observed.get(nodeId) ?? { types: new Set(), count: 0, maybeOwn };
1914
+ entry.types.add(type);
1915
+ entry.count += 1;
1916
+ if (!maybeOwn) entry.maybeOwn = false;
1917
+ observed.set(nodeId, entry);
1819
1918
  observedCount += 1;
1919
+ if (!firstObservedAt) firstObservedAt = Date.now();
1820
1920
  const seq = Number(payload?.seq);
1821
1921
  if (Number.isFinite(seq)) {
1822
1922
  if (seqLo === null || seq < seqLo) seqLo = seq;
1823
1923
  if (seqHi === null || seq > seqHi) seqHi = seq;
1824
1924
  }
1825
- log(`board change: ${type} node=${nodeId}${Number.isFinite(seq) ? ` seq=${seq}` : ''}`);
1826
- armStandaloneTurn(STANDALONE_DEBOUNCE_MS);
1925
+ log(`board change: ${type} node=${nodeId}${Number.isFinite(seq) ? ` seq=${seq}` : ''}${maybeOwn ? ' (maybe own write)' : ''}`);
1926
+ armStandaloneTurn(nextDelayMs());
1927
+ }
1928
+
1929
+ // Debounce, but never past MAX_WAIT from the first event of the burst, and never before
1930
+ // the echo window of the last turn has run out.
1931
+ function nextDelayMs() {
1932
+ const waitedSoFar = firstObservedAt ? Date.now() - firstObservedAt : 0;
1933
+ const debounce = Math.max(0, Math.min(STANDALONE_DEBOUNCE_MS, STANDALONE_MAX_WAIT_MS - waitedSoFar));
1934
+ const echoLeft = lastTurnEndedAt ? STANDALONE_ECHO_WINDOW_MS - (Date.now() - lastTurnEndedAt) : 0;
1935
+ return Math.max(debounce, echoLeft, 0);
1827
1936
  }
1828
1937
 
1829
1938
  function armStandaloneTurn(delayMs) {
@@ -1834,51 +1943,140 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
1834
1943
  }, delayMs);
1835
1944
  }
1836
1945
 
1946
+ // The changed-node list is a *pointer*, not the job: it says which corners of the board
1947
+ // someone just touched. The turn's actual task is for the agent to analyse all of them
1948
+ // against the model as a whole and then fan out — a subagent per piece of work that really
1949
+ // needs doing, running in parallel. Without that framing the agent treats the last event as
1950
+ // its work item and does one narrow thing (or nothing) even when the model needs something
1951
+ // else entirely, which is exactly what a burst of events on several nodes used to degrade
1952
+ // into.
1953
+ // The fan-out budget (`--max-agents`). A turn nobody asked for still costs money, so the
1954
+ // cap is stated in the turn itself — the `claude` process is what spawns the agents, and
1955
+ // the CLI has no way to count them from out here.
1956
+ const AGENT_BUDGET =
1957
+ maxAgents > 1
1958
+ ? `Dispatch at most ${maxAgents} Agents in this turn (--max-agents=${maxAgents}). Merge pieces that share a slice or ` +
1959
+ 'chain first — that is a correctness rule, not a way to fit the cap — and if more than that is still left, ' +
1960
+ 'take the most valuable pieces up to the cap and leave the rest; a later turn will see them again.'
1961
+ : 'Do not dispatch any Agents in this turn (--max-agents=1) — that budget overrides the fan-out above: do ' +
1962
+ 'the single most valuable piece of work yourself, inline, and leave the rest for a later turn.';
1963
+
1964
+ const STANDALONE_TASK =
1965
+ 'Nobody asked you for this — you are working on this board in the background, on your own initiative. ' +
1966
+ 'The change list above is a notification, not the task: it tells you where something just happened and ' +
1967
+ 'which parts of the model to look at first. The task is to judge the model as a whole — each changed area ' +
1968
+ 'in its context (its slice, its chain, the timeline around it), plus anything still obviously unfinished ' +
1969
+ 'elsewhere — and then get the useful work done. Do not stop at the last event, and do not treat the ' +
1970
+ 'nodeId list as the boundary of the work. You do the analysis: look at every entry above, decide what ' +
1971
+ 'actually needs doing, and then work in parallel rather than serially — dispatch one Agent per piece of ' +
1972
+ 'work that needs doing, all in a single message, merging pieces that share a slice or chain so no two ' +
1973
+ `agents write to the same area. ${AGENT_BUDGET} Follow the "Standalone board-change turns" section of ` +
1974
+ '.agent-modeling-kit/CLAUDE.md, and if the model genuinely needs nothing right now, spawn nothing, change ' +
1975
+ 'nothing and reply <promise>NOOP</promise>.';
1976
+
1837
1977
  function buildStandaloneTurn() {
1838
- const lines = [...observed.entries()].map(([nodeId, types]) => `- ${nodeId}: ${[...types].join(', ')}`);
1978
+ const lines = [...observed.entries()].map(
1979
+ ([nodeId, entry]) =>
1980
+ `- ${nodeId}: ${[...entry.types].join(', ')} (${entry.count}×)${entry.maybeOwn ? ' — possibly your own earlier write' : ''}`,
1981
+ );
1839
1982
  const header = [
1840
1983
  'BOARD_CHANGE',
1841
1984
  `board_id=${cfg.boardId}`,
1842
1985
  `organization_id=${cfg.organizationId}`,
1843
1986
  seqLo !== null ? `seq=${seqLo}${seqHi !== seqLo ? `..${seqHi}` : ''}` : null,
1844
1987
  `events=${observedCount}`,
1988
+ `nodes=${observed.size}`,
1845
1989
  ].filter(Boolean).join(' ');
1990
+ return withSessionHeader(`${header}\nchanged:\n${lines.join('\n')}\n\n${STANDALONE_TASK}`);
1991
+ }
1992
+
1993
+ // No events at all — the board has been sitting still. Same self-directed turn, with the
1994
+ // whole model as its subject instead of a changed corner of it.
1995
+ function buildIdleReviewTurn() {
1996
+ const header = [
1997
+ 'BOARD_REVIEW',
1998
+ `board_id=${cfg.boardId}`,
1999
+ `organization_id=${cfg.organizationId}`,
2000
+ `idle_for=${Math.round(STANDALONE_IDLE_MS / 1000)}s`,
2001
+ ].join(' ');
1846
2002
  return withSessionHeader(
1847
- `${header}\nchanged:\n${lines.join('\n')}\n\n` +
1848
- 'Nobody asked you for this the board itself changed and you are acting on your own initiative. ' +
1849
- 'Follow the "Standalone board-change turns" section of .agent-modeling-kit/CLAUDE.md: look at what changed, ' +
1850
- 'decide whether there is genuinely useful modeling work to do, do at most one focused piece of it, and if ' +
1851
- 'there is nothing worth doing, change nothing and reply <promise>NOOP</promise>.',
2003
+ `${header}\nchanged: nothing — the board has been quiet.\n\n` +
2004
+ 'Nobody asked you for this and nothing changed: you are working on this board in the background, on ' +
2005
+ 'your own initiative. Look over the model as a whole and decide what it still needs; for each piece of ' +
2006
+ 'work that needs doing, dispatch one Agent, all in a single message so they run in parallel, exactly ' +
2007
+ `as the "Standalone board-change turns" section of .agent-modeling-kit/CLAUDE.md describes. ${AGENT_BUDGET} ` +
2008
+ 'If the model needs nothing, spawn nothing, change nothing and reply <promise>NOOP</promise>.',
1852
2009
  );
1853
2010
  }
1854
2011
 
1855
- async function dispatchStandaloneTurn() {
1856
- if (!observed.size) return;
2012
+ let idleTimer = null;
2013
+ function armIdleReview() {
2014
+ if (!standalone || !STANDALONE_IDLE_MS) return;
2015
+ if (idleTimer) clearTimeout(idleTimer);
2016
+ idleTimer = setTimeout(() => {
2017
+ idleTimer = null;
2018
+ // A buffer that filled meanwhile has its own timer — let that turn carry the work.
2019
+ if (observed.size || standaloneTimer || pending || draining) {
2020
+ armIdleReview();
2021
+ return;
2022
+ }
2023
+ dispatchStandaloneTurn({ idle: true }).catch((err) => log(`idle review dispatch error: ${err.message}`));
2024
+ }, STANDALONE_IDLE_MS);
2025
+ }
2026
+
2027
+ async function dispatchStandaloneTurn({ idle = false } = {}) {
2028
+ if (!idle && !observed.size) return;
1857
2029
  // A direct message always outranks the agent's own initiative — re-arm instead of
1858
2030
  // queueing behind the prompt lane, so the buffer just keeps collecting meanwhile.
1859
2031
  if (pending || draining) {
1860
- armStandaloneTurn(STANDALONE_DEBOUNCE_MS);
2032
+ if (idle) armIdleReview();
2033
+ else armStandaloneTurn(nextDelayMs());
1861
2034
  return;
1862
2035
  }
1863
- const waitLeft = STANDALONE_MIN_INTERVAL_MS - (Date.now() - lastStandaloneAt);
2036
+ const waitLeft = minIntervalMs() - (Date.now() - lastStandaloneAt);
1864
2037
  if (lastStandaloneAt && waitLeft > 0) {
1865
- armStandaloneTurn(waitLeft);
2038
+ if (idle) armIdleReview();
2039
+ else armStandaloneTurn(waitLeft);
1866
2040
  return;
1867
2041
  }
1868
- const text = buildStandaloneTurn();
1869
- log(`standalone turn: ${observedCount} board event(s) on ${observed.size} node(s)`);
2042
+ const text = idle ? buildIdleReviewTurn() : buildStandaloneTurn();
2043
+ if (idle) {
2044
+ log(`standalone review turn: board quiet for ${Math.round(STANDALONE_IDLE_MS / 1000)}s`);
2045
+ } else {
2046
+ const ownOnly = [...observed.values()].every((entry) => entry.maybeOwn);
2047
+ log(
2048
+ `standalone turn: ${observedCount} board event(s) on ${observed.size} node(s)` +
2049
+ `${ownOnly ? ' (all possibly own writes)' : ''}`,
2050
+ );
2051
+ }
1870
2052
  observed.clear();
1871
2053
  observedCount = 0;
1872
2054
  seqLo = null;
1873
2055
  seqHi = null;
2056
+ firstObservedAt = 0;
1874
2057
  lastStandaloneAt = Date.now();
1875
2058
  try {
1876
- await runClaudeWarm(text);
2059
+ const result = await runClaudeWarm(text);
2060
+ // NOOP is the agent saying the board needs nothing — widen the floor so a finished
2061
+ // board isn't revisited at full rate. Any real contribution resets it.
2062
+ if (/NOOP/.test(String(result ?? ''))) {
2063
+ noopStreak += 1;
2064
+ log(`standalone turn: NOOP (${noopStreak} in a row — next no sooner than ${Math.round(minIntervalMs() / 1000)}s)`);
2065
+ } else {
2066
+ noopStreak = 0;
2067
+ }
1877
2068
  } catch (err) {
1878
2069
  log(`standalone turn failed: ${err.message}`);
2070
+ } finally {
2071
+ // Events that arrived while this turn ran are still in the buffer — give them a turn
2072
+ // of their own once the echo window has passed, instead of waiting for the next edit.
2073
+ if (observed.size) armStandaloneTurn(nextDelayMs());
2074
+ armIdleReview();
1879
2075
  }
1880
2076
  }
1881
2077
 
2078
+ armIdleReview();
2079
+
1882
2080
  const channelName = `org:${cfg.organizationId}`;
1883
2081
  const realtime = await createRealtimeAdapter(cfg, realtimeToken);
1884
2082
 
@@ -2410,7 +2608,8 @@ credentialFlags(program
2410
2608
  .option('--ollama', 'Use ralph-ollama.js instead of the default Claude runner (build-kit stacks only)')
2411
2609
  .option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
2412
2610
  .option('--modeling', 'Keep one Claude process warm across prompts instead of spawning a fresh one per task, for low-latency voice/live use. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
2413
- .option('--standalone', 'Let the modeling agent act on its own initiative: on top of direct prompts it subscribes to the board\'s change channel (like the build agents do) and, whenever the board goes quiet after an edit, decides for itself what a human collaborator would do next fill in examples on a new node, post a comment, sketch a screen. Implies --modeling.')
2611
+ .option('--standalone', 'Let the modeling agent work the board in the background, on its own initiative: on top of direct prompts it subscribes to the board\'s change channel (like the build agents do) and, whenever the board goes quiet after an edit or has simply been idle for a while — it takes a turn nobody asked for. Changed nodes are a notification, not the task: it judges the model as a whole and fans the work out over parallel subagents, one per changed area (examples on a new node, a missing attribute along a chain, a screen, a question comment). Implies --modeling.')
2612
+ .option('--max-agents <n>', 'Cap how many subagents a self-directed --standalone turn may dispatch at once, to bound what an unattended agent can spend per turn. The agent merges work that shares a slice or chain first, then takes the most valuable pieces up to this many and leaves the rest for a later turn. 1 makes it do the single most valuable piece itself, without spawning anything. Default 5. Ignored without --standalone — prompt turns are one piece of work by definition.', '5')
2414
2613
  .option('--global', 'Run the modeling agent from the global install (~/.eventmodelers/kit), initializing it on first use, and ignore any kit in this directory. This is also what --modeling/--standalone fall back to on their own when nothing is installed here — pass it explicitly to prefer the global install over a local one. Credentials come from the flags below, EVENTMODELERS_* env vars, or ~/.eventmodelers/boards/<board>.json, so nothing is written into the current directory.')
2415
2614
  .option('--local', 'Skip platform config/credential lookup entirely and run the local-only loop (no board sync, no realtime agent) — even if .eventmodelers/config.json has credentials (build-kit stacks only)')
2416
2615
  .option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
@@ -2418,6 +2617,13 @@ credentialFlags(program
2418
2617
  .action(async (opts, command) => {
2419
2618
  const globalOpts = command.optsWithGlobals();
2420
2619
  const cwd = process.cwd();
2620
+ // Validated up front, before any runner is selected: a cost guard given as garbage
2621
+ // should fail on the spot, not once the loop is already up — and a cap passed where
2622
+ // nothing will read it is worth saying out loud rather than ignoring silently.
2623
+ const maxAgents = parseMaxAgents(opts.maxAgents);
2624
+ if (command.getOptionValueSource('maxAgents') === 'cli' && !opts.standalone) {
2625
+ console.log('ℹ️ --max-agents only applies to --standalone turns; ignoring it here.');
2626
+ }
2421
2627
  // Both kit dirs can be installed side by side (e.g. running a build-kit and a
2422
2628
  // modeling-kit agent from the same project). findInstalledKitDir only ever
2423
2629
  // returns its first fixed-order match, which would silently prefer one stack
@@ -2481,7 +2687,7 @@ credentialFlags(program
2481
2687
  const shown = relative(cwd, kitDir);
2482
2688
  await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (warm Claude process) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
2483
2689
  try {
2484
- await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides);
2690
+ await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides, maxAgents);
2485
2691
  } catch (err) {
2486
2692
  console.error('[modeling] Fatal:', err);
2487
2693
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.57",
3
+ "version": "1.0.59",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,10 +18,12 @@ every later turn just because a new prompt came in. The same applies to other on
18
18
  setup; see step 2 below for `/connect`.
19
19
 
20
20
  When the loop runs with `--standalone`, the CLI also subscribes to the board's own change
21
- channel, so you get a second kind of turn on top of prompts: a **board-change turn**, whose
22
- first line starts with `BOARD_CHANGE` instead of `prompt_id=`. Nobody asked you for anything
23
- in those turns you decide whether there's useful modeling work to do and do it, the way a
24
- human collaborator glancing at the board would. They follow their own steps; see
21
+ channel, so you get a second kind of turn on top of prompts: a **self-directed turn**, whose
22
+ first line starts with `BOARD_CHANGE` (the board changed) or `BOARD_REVIEW` (nothing has
23
+ changed for a while) instead of `prompt_id=`. Nobody asked you for anything in those turns
24
+ you are a background collaborator on this board: you judge the model as a whole, decide
25
+ what it needs, and fan the work out over parallel subagents. The listed changes are a
26
+ notification pointing at an area, never the task itself. They follow their own steps; see
25
27
  "Standalone board-change turns" below. The session header's `standalone=on|off` tells you
26
28
  whether this session gets them at all.
27
29
 
@@ -65,17 +67,30 @@ skip to "Standalone board-change turns" instead.
65
67
 
66
68
  ## Standalone board-change turns
67
69
 
68
- Only in a `standalone=on` session. Such a turn looks like this:
70
+ Only in a `standalone=on` session. These turns come in two shapes, and both are
71
+ self-directed — nobody asked you for anything:
69
72
 
70
73
  ```
71
- BOARD_CHANGE board_id=<uuid> organization_id=<uuid> seq=118..124 events=4
74
+ BOARD_CHANGE board_id=<uuid> organization_id=<uuid> seq=118..124 events=9 nodes=3
72
75
  changed:
73
- - 9f3c…: node:created, node:changed
74
- - a12b…: node:changed
76
+ - 9f3c…: node:created, node:changed (4×)
77
+ - a12b…: node:changed (2×) — possibly your own earlier write
78
+ - c771…: edge:added (3×)
75
79
  ```
76
80
 
77
- It means: those nodes changed on the board, the board has since gone quiet, and nobody
78
- asked you for anything. You are acting on your own initiative.
81
+ ```
82
+ BOARD_REVIEW board_id=<uuid> organization_id=<uuid> idle_for=900s
83
+ changed: nothing — the board has been quiet.
84
+ ```
85
+
86
+ **The change list is a notification, not the work item.** It tells you that something
87
+ happened and which corner of the board to look at first — nothing more. It is not a task
88
+ list, not a boundary, and the last line of it is not "the" change to react to. A burst of
89
+ 40 events on 6 nodes and a single `node:created` get the same treatment: you look at the
90
+ model, not at the event. A `BOARD_REVIEW` turn is the same job with no starting hint at all.
91
+ Nodes marked *possibly your own earlier write* are changes that landed while you were
92
+ working or just after — usually your own echo, so weigh them accordingly, but don't assume:
93
+ a human may well have been editing at the same time.
79
94
 
80
95
  **There is no `prompt_id` in these turns — never call `/update-prompt-status` in one** (not
81
96
  `IN_PROGRESS`, not `DONE`; the "exactly two calls per turn" rule is about prompt turns only).
@@ -83,46 +98,95 @@ There is nothing to sanitize either — a board change is not user text.
83
98
 
84
99
  Steps:
85
100
 
86
- 1. **Look at what actually changed.** Fetch each listed node (`mcp__eventmodelers__get_node`,
87
- or the REST equivalent) and enough of its surroundings its cell, its slice, its
88
- connectionsto judge it. The payload only carries ids; the node itself tells you its
89
- type, name, fields and whether it's still half-finished. `mcp__eventmodelers__get_board_events`
90
- with the `seq` range from the header fills in what the change actually was, when the
91
- node's current state doesn't make that obvious.
92
- 2. **Decide whether there is genuinely useful work here the default answer is no.** Do
93
- something only when a human collaborator would obviously have done it too:
94
- - a new EVENT/COMMAND/READMODEL with fields but no example data `/examples`
101
+ 1. **Get the whole picture, not just the changed nodes.** Start at the listed nodes
102
+ (`mcp__eventmodelers__get_node`, or the REST equivalent) and widen out to what they sit
103
+ intheir cell, their slice, the chain they belong to, the timeline around them.
104
+ `mcp__eventmodelers__get_board_events` with the header's `seq` range tells you what the
105
+ change actually was when the node's current state doesn't make it obvious. Then judge the
106
+ board as a whole: run `/analyze-existing-model` once per session to get that picture and
107
+ keep it in mind across turns, refreshing it when a turn's changes invalidate it. On a
108
+ `BOARD_REVIEW` turn that model-wide picture *is* the starting point.
109
+ 2. **Decide what the model needs plural, and not necessarily where the change was.** List
110
+ the candidate contributions you can actually see evidence for, each with its own target
111
+ (node/cell/slice) and the skill that does it. A changed node is a reason to look; it is
112
+ not automatically the thing to work on, and work you spot two slices away counts just as
113
+ much. The usual candidates:
114
+ - an EVENT/COMMAND/READMODEL with fields but no example data → `/examples`
95
115
  - a field added to one element that its chain neighbours are missing → `/attributes`
96
116
  - an empty SCREEN node → `/html-screen`
97
- - a timeline element that clearly should be sliced and isn't → `/eventmodeling-slicing-event-models`
98
- - a change that raises a real business question (a gap, an unhandled case) → one
99
- `/wdyt`-style QUESTION comment on that node, via `/handle-comment` with `action=place`
100
- Do **nothing** for: a node that merely moved or was resized, a rename, a change inside
101
- something you yourself just wrote, a node that already has the thing you'd add, or a node
102
- someone is visibly still working on.
103
- 3. **Do at most one focused piece of work**, through the matching skill from the Skill
104
- Selection table (same rule as step 5 of a prompt turn: invoke the skill, don't substitute
105
- raw MCP calls). One change one contribution. Never take a single board change as licence
106
- to sweep the whole board if you spot five other things worth doing, that's a `/wdyt`
107
- comment, not five edits.
108
- 4. **Never undo or overwrite human work.** You add to the board; you don't delete, rename,
109
- restructure timelines, or move slice statuses on your own initiative. If the right move
110
- would be destructive, post a comment saying so instead.
111
- 5. **If you already said it, don't say it again.** Before posting a comment, read the node's
112
- existing commentsan unresolved question you (or anyone) already posted there means your
113
- contribution for this change is already on the board.
114
- 6. **Write no progress entry.** A board-change turn is modeling, not tracked progress —
117
+ - a timeline element that clearly should be sliced and isn't →
118
+ `/eventmodeling-slicing-event-models`
119
+ - a gap or unhandled case that raises a real business question → one QUESTION comment via
120
+ `/handle-comment` with `action=place`
121
+ Nothing is a candidate when it's cosmetic (a node moved, resized or renamed), when the
122
+ target already has the thing you'd add, when it's inside something you yourself just
123
+ wrote, or when someone is visibly still working on it. An empty candidate list is a
124
+ perfectly good outcome see step 8.
125
+ 3. **Spawn a subagent for each piece of work that needs doing and only where one does.** The
126
+ analysis in steps 1–2 is yours: you look at every entry in `changed:` yourself, in the
127
+ context of the model, and decide what (if anything) needs to happen. Then, for each
128
+ candidate that survived that judgment, dispatch one subagent via the `Agent` tool, **with
129
+ all of them in a single message** so they run in parallel. Entries that need nothing spawn
130
+ nothing; a turn where nothing needs doing spawns nothing at all and ends in a `NOOP`. What
131
+ you must never do is work the candidates one after another in your own turn, or pick one
132
+ out of five and drop the rest nine events on three nodes that each need something are
133
+ three agents working at once. You analyse and coordinate; the agents do the work.
134
+ Each subagent prompt must be self-contained, because a subagent is a fresh session that
135
+ inherits none of this one's state:
136
+ - `token=`, `org=`, `baseUrl=` from this session's first message, and the instruction to
137
+ run `/connect` first;
138
+ - `board_id`, plus the exact target ids (`node_id`/`cellName`/`timelineId`/slice) it owns
139
+ — never "the node that changed";
140
+ - what you concluded in step 2: the specific piece of work, and enough of the surrounding
141
+ model for the agent to do it well;
142
+ - the one skill from the Skill Selection table to invoke, and the same rule that applies to
143
+ you: invoke the skill, don't substitute raw MCP calls;
144
+ - the standing constraints of step 4 and step 5 below.
145
+ **Stay inside the agent budget.** The session header carries `max_agents=<n>` (default 5)
146
+ and every self-directed turn restates it: that is the most Agents you may dispatch in one
147
+ turn, because a turn nobody asked for still costs money. Merge by area first (step 4) —
148
+ that's a correctness rule, not a way to fit the budget — and if more pieces are still left
149
+ than the cap allows, dispatch the most valuable ones and leave the rest; the board doesn't
150
+ forget, and a later turn will see them again. With `max_agents=1`, spawn nothing at all and
151
+ do the single most valuable piece yourself, inline.
152
+ **The decision stays with you.** A subagent is an executor, not a second judge: it carries
153
+ out the piece of work you decided on, on the target you named, and nothing else. It does
154
+ not re-open the question of whether the work is worth doing, does not widen its scope, and
155
+ does not go looking for other things on the board. If it finds the work doesn't apply after
156
+ all — the node already has what you'd add, someone is mid-edit — it reports that back to
157
+ you instead of substituting work of its own, and you decide what happens next.
158
+ Do the work inline yourself only when exactly one candidate survived and it is small (one
159
+ comment, one `/examples` call) — spawning a single agent for a single small thing is pure
160
+ overhead.
161
+ 4. **Give every agent its own territory — merge before you dispatch, never split a slice.**
162
+ Two agents writing into the same node, chain or slice will clobber each other and the board
163
+ has no merge. So the mapping from step 3 is subject to one rule: candidates that live in
164
+ the same slice or the same chain are handled by **one** agent that owns that whole area,
165
+ with all of their work in its brief, not one agent each. That also keeps a big burst sane —
166
+ work on 30 changed nodes across 4 slices is 4 agents, well inside the default budget. Merge
167
+ first, then prioritize: a candidate is only ever deferred to a later turn because the budget
168
+ ran out, never because it was inconvenient to merge.
169
+ 5. **Never undo or overwrite human work** — you and every agent you dispatch. You add to the
170
+ board; you don't delete, rename, restructure timelines, or move slice statuses on your own
171
+ initiative. If the right move would be destructive, post a comment saying so instead.
172
+ 6. **If you already said it, don't say it again.** Before posting a comment — or having a
173
+ subagent post one — read the node's existing comments. An unresolved question already
174
+ there means that contribution is on the board.
175
+ 7. **Write no progress entry.** A self-directed turn is modeling, not tracked progress —
115
176
  nothing goes into `progress.txt` here (that file belongs to prompt turns, which answer to
116
177
  someone who asked). Still promote anything reusable to `.agent-modeling-kit/AGENTS.md`
117
- (same as step 9 of a prompt turn).
118
- 7. Reply `<promise>DONE</promise>` if you changed something, or when the answer at step 2
119
- was "nothing worth doing" — change nothing at all and reply `<promise>NOOP</promise>`. A
120
- NOOP is a perfectly good outcome.
121
-
122
- Keep these turns small and finished within the turn. Everything you write to the board comes
123
- back to this same channel as another change; the CLI suppresses your own echo for a short
124
- window after each turn, so work that trails off and lands later can wake you up again for no
125
- reason.
178
+ (same as step 9 of a prompt turn), including anything a subagent reported back.
179
+ 8. Reply `<promise>DONE</promise>`, naming what you dispatched and what each agent did, or
180
+ when step 2 turned up nothing worth doing — change nothing at all and reply
181
+ `<promise>NOOP</promise>`. A NOOP is a perfectly good outcome, and the CLI widens the gap
182
+ before the next self-directed turn each time you answer one, so a finished board goes
183
+ quiet by itself. Don't manufacture work to avoid a NOOP.
184
+
185
+ Keep these turns finished within the turn: wait for the subagents you dispatched, don't leave
186
+ work trailing. Everything you and they write to the board comes back on this same channel as
187
+ another change; the CLI labels changes that arrive in that echo window rather than dropping
188
+ them, so you'll see your own writes listed on a later turn — recognize them and don't rework
189
+ them.
126
190
 
127
191
  ## Skill Selection
128
192