@eventmodelers/cli 1.0.163 → 2.0.2

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.
Files changed (128) hide show
  1. package/README.md +48 -36
  2. package/RELEASE_NOTES.md +20 -2
  3. package/cli.js +774 -247
  4. package/lib/adapters/spec-kitty-adapter.js +3 -7
  5. package/lib/build-kits.js +23 -0
  6. package/lib/global-agent-id.js +45 -0
  7. package/lib/modeling-local-ai.js +4 -2
  8. package/lib/scaffold-model.js +71 -0
  9. package/lib/scaffold.js +224 -0
  10. package/lib/supervise.js +60 -0
  11. package/lib/ui-baseline.js +123 -0
  12. package/package.json +1 -1
  13. package/shared/build-kit/README.md +4 -19
  14. package/shared/build-kit/lib/agent-lock.js +75 -0
  15. package/shared/build-kit/lib/agent-name.js +27 -0
  16. package/shared/build-kit/lib/event-prune.js +58 -0
  17. package/shared/build-kit/lib/events-view.js +134 -0
  18. package/shared/build-kit/lib/local-ai-agent.js +14 -26
  19. package/shared/build-kit/lib/mcp-register.js +17 -1
  20. package/shared/build-kit/lib/prompt-journal.js +191 -0
  21. package/shared/build-kit/lib/prompt-replay.js +35 -0
  22. package/shared/build-kit/lib/ralph.js +218 -169
  23. package/shared/build-kit/lib/slice-curation.js +40 -0
  24. package/shared/build-kit/lib/slice-events.js +93 -0
  25. package/shared/build-kit/lib/slice-git.js +86 -0
  26. package/shared/build-kit/lib/slice-learnings.js +197 -0
  27. package/shared/build-kit/lib/slice-projection.js +101 -0
  28. package/shared/build-kit/lib/slice-run.js +181 -0
  29. package/shared/build-kit/lib/slice-state.js +184 -0
  30. package/shared/build-kit/lib/slice-tasks.js +35 -0
  31. package/shared/build-kit/ralph-claude.js +4 -2
  32. package/shared/build-kit/ralph-exec.js +4 -2
  33. package/shared/build-kit/ralph-local-ai.js +5 -3
  34. package/shared/scaffold/LICENSE-em-scaffold +21 -0
  35. package/shared/scaffold/supabase/generate.mjs +290 -0
  36. package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
  37. package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
  38. package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
  39. package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
  40. package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
  41. package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
  42. package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
  43. package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
  44. package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
  45. package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
  46. package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
  47. package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
  48. package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
  49. package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
  50. package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
  51. package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
  52. package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
  53. package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
  54. package/shared/skills/learn-eventmodelers-api/SKILL.md +17 -4
  55. package/shared/skills/request-feedback/SKILL.md +1 -2
  56. package/shared/skills/ui-contract/SKILL.md +7 -5
  57. package/shared/skills/update-slice-status/SKILL.md +1 -1
  58. package/stacks/axon/templates/build-kit/CLAUDE.md +7 -6
  59. package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
  60. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
  61. package/stacks/axon/templates/build-kit/lib/prompt.md +32 -41
  62. package/stacks/blank/templates/build-kit/CLAUDE.md +7 -6
  63. package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
  64. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
  65. package/stacks/blank/templates/build-kit/lib/prompt.md +33 -42
  66. package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
  67. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  68. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  69. package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
  70. package/stacks/classic/templates/build-kit/CLAUDE.md +9 -8
  71. package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
  72. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
  73. package/stacks/classic/templates/build-kit/lib/prompt.md +33 -42
  74. package/stacks/kurrent/templates/build-kit/CLAUDE.md +7 -7
  75. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
  76. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
  77. package/stacks/kurrent/templates/build-kit/lib/prompt.md +32 -41
  78. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  79. package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
  80. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
  81. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
  82. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
  83. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
  84. package/stacks/modeling-kit/templates/kit/CLAUDE.md +18 -28
  85. package/stacks/modeling-kit/templates/kit/README.md +1 -1
  86. package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
  87. package/stacks/node/templates/build-kit/CLAUDE.md +7 -6
  88. package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
  89. package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
  90. package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
  91. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +7 -7
  92. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
  93. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
  94. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +32 -41
  95. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  96. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
  97. package/stacks/react/templates/build-kit/CLAUDE.md +7 -6
  98. package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
  99. package/stacks/react/templates/build-kit/lib/backend-prompt.md +25 -42
  100. package/stacks/react/templates/build-kit/lib/kit.json +1 -1
  101. package/stacks/react/templates/build-kit/lib/prompt.md +47 -43
  102. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
  103. package/stacks/supabase/templates/build-kit/CLAUDE.md +7 -6
  104. package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
  105. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
  106. package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
  107. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +80 -8
  108. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +66 -13
  109. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +8 -7
  110. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
  111. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +25 -42
  112. package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
  113. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +48 -44
  114. package/stacks/umadb/templates/build-kit/CLAUDE.md +7 -6
  115. package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
  116. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
  117. package/stacks/umadb/templates/build-kit/lib/prompt.md +32 -41
  118. package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
  119. package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
  120. package/shared/build-kit/lib/agent.sh +0 -20
  121. package/shared/build-kit/ralph.sh +0 -111
  122. package/shared/build-kit/realtime-agent.js +0 -20
  123. package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
  124. package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
  125. package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
  126. package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
  127. package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
  128. package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
@@ -27,8 +27,6 @@ node .build-kit/ralph-claude.js /path/to/project
27
27
  | `ralph-claude.js` | Runs the full loop using Claude Code as the executor |
28
28
  | `ralph-local-ai.js` | Runs the full loop using a local/self-hosted model (Ollama, vLLM, LM Studio, llama.cpp) |
29
29
  | `ralph-exec.js` | Runs the full loop handing each prompt to an external agent command (Codex CLI, OpenCode, …) |
30
- | `ralph.sh` | Shell-based loop — alternative to the JS entry points |
31
- | `realtime-agent.js` | Standalone realtime agent — only needed to run it in a separate terminal |
32
30
 
33
31
  **Internals** (`lib/`):
34
32
 
@@ -36,16 +34,15 @@ node .build-kit/ralph-claude.js /path/to/project
36
34
  |------|---------|
37
35
  | `lib/ralph.js` | Shared library — realtime agent + loop logic; imported by the entry points |
38
36
  | `lib/local-ai-agent.js` | Local-AI executor — called by `ralph-local-ai.js`, can also run manually |
39
- | `lib/agent.sh` | Thin shell wrapper around `claude` — called by `ralph.sh` |
40
37
  | `lib/prompt.md` | Phase 1 prompt: tells Claude how to load a slice from the board |
41
38
  | `lib/backend-prompt.md` | Phase 2 prompt: tells Claude how to build a planned slice |
42
39
  | `lib/AGENT.md` | Agent instructions included in Claude's context |
43
40
 
44
41
  ## How it works
45
42
 
46
- **Phase 1** — triggered when `tasks.json` has entries:
47
- - The realtime agent writes a task to `tasks.json` each time a `slice:changed` event arrives from the board
48
- - The loop picks it up and runs Claude (or a local model) with `prompt.md`
43
+ **Phase 1** — triggered when the task queue has entries (the queue is the event log in `.build-kit/events/`):
44
+ - The realtime agent queues a task (`task-queued`) each time a `slice:changed` event arrives from the board; a newer change to the same slice replaces its queued task
45
+ - The loop hands the oldest one to Claude (or a local model) with `prompt.md` and completes it (`task-done`) when the turn ends
49
46
  - Claude loads the slice data and updates `.slices/`
50
47
 
51
48
  **Phase 2** — triggered when any file in `.slices/` contains `"status": "Planned"`:
@@ -55,18 +52,6 @@ node .build-kit/ralph-claude.js /path/to/project
55
52
 
56
53
  Both phases run in a continuous loop with a 3-second idle sleep. The realtime agent runs concurrently in the same process.
57
54
 
58
- ## Running the realtime agent separately
59
-
60
- If you want the board subscription in one terminal and the Claude loop in another:
61
-
62
- ```bash
63
- # Terminal 1 — realtime agent only
64
- node .build-kit/realtime-agent.js
65
-
66
- # Terminal 2 — loop only (poll tasks.json without the realtime subscription)
67
- .build-kit/ralph.sh
68
- ```
69
-
70
55
  ## Local-AI configuration
71
56
 
72
57
  `ralph-local-ai.js` drives any local or self-hosted model that can do tool calling.
@@ -78,7 +63,7 @@ Claude (`ralph-claude.js`) stays the default runner — this is opt-in.
78
63
  Planned. That is by design, not a bug:
79
64
 
80
65
  - **Planned slices are never handed to it.** `ralph-local-ai.js` gives `startRalph` no
81
- `onPlannedSlice`, so the loop only feeds it `tasks.json` prompts.
66
+ `onPlannedSlice`, so the loop only feeds it queued tasks.
82
67
  - **It has no coding tools.** `lib/local-ai-agent.js` exposes only the board MCP tools:
83
68
  no file read/write, no shell, no git, no test run.
84
69
  - **Its system prompt forbids file and shell access.** Such a request is answered with
@@ -0,0 +1,75 @@
1
+ // One agent id, one live process. An agent keeps its stable id (see ensureAgentId); a second process that would
2
+ // share it — two `run`s in one project, two standalone runs from one repo — gets a fresh id instead. Sharing one
3
+ // would make them fight over a single heartbeat row and a single chat, and the second one's start-up recovery
4
+ // would hand back the first one's prompts.
5
+ //
6
+ // The id is claimed with an exclusive lock file (`<id>.lock`, holding the pid) that the process removes on exit. It
7
+ // is written complete under a private name and then hard-linked into place, so a competitor never finds it empty
8
+ // and mistakes a lock being taken for one left over.
9
+ // A lock left by a process that is gone — a crash, kill -9 — is taken over. A lock problem never stops an agent:
10
+ // if locking is impossible the stable id is simply kept.
11
+
12
+ import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
13
+ import { randomUUID } from 'crypto';
14
+ import { join } from 'path';
15
+ import { isProcessAlive } from './slice-run.js';
16
+
17
+ const answers = new Map(); // lock file path → the answer given in this process, so asking twice is one decision
18
+ const owned = new Set(); // lock files this process holds
19
+ let exitHooked = false;
20
+
21
+ function release() {
22
+ for (const path of owned) {
23
+ try {
24
+ if (JSON.parse(readFileSync(path, 'utf-8')).pid === process.pid) rmSync(path, { force: true });
25
+ } catch { /* already gone */ }
26
+ }
27
+ owned.clear();
28
+ }
29
+
30
+ // true: the lock is ours; false: a live process of another pid holds it.
31
+ function lock(path, pid, isAlive) {
32
+ for (let attempt = 0; attempt < 3; attempt++) {
33
+ try {
34
+ const mine = `${path}.${pid}.${randomUUID()}.tmp`;
35
+ writeFileSync(mine, JSON.stringify({ pid, startedAt: new Date().toISOString() }));
36
+ try {
37
+ linkSync(mine, path); // EEXIST when someone holds it — atomically, and never half written
38
+ } finally {
39
+ rmSync(mine, { force: true });
40
+ }
41
+ owned.add(path);
42
+ if (!exitHooked) { exitHooked = true; process.on('exit', release); }
43
+ return true;
44
+ } catch (err) {
45
+ if (err.code !== 'EEXIST') return true; // cannot lock here (permissions, odd filesystem): do not get in the way
46
+ let holder = null;
47
+ try { holder = JSON.parse(readFileSync(path, 'utf-8')).pid; } catch { /* unreadable: left over */ }
48
+ if (holder === pid) return true;
49
+ if (holder !== null && isAlive(holder)) return false;
50
+ rmSync(path, { force: true }); // left over by a process that is gone — take it over
51
+ }
52
+ }
53
+ return true;
54
+ }
55
+
56
+ // `stableId` is the id this agent keeps. Returns { agentId, fellBack }: the stable id when this process got the
57
+ // lock, else a fresh one. Decided once per process and lock directory.
58
+ export function acquireAgentId({ lockDir, stableId, mint = randomUUID, isAlive = isProcessAlive, pid = process.pid }) {
59
+ const path = join(lockDir, `${stableId}.lock`);
60
+ if (answers.has(path)) return answers.get(path);
61
+ let taken = true;
62
+ try {
63
+ mkdirSync(lockDir, { recursive: true });
64
+ taken = lock(path, pid, isAlive);
65
+ } catch { /* cannot lock here: keep the stable id */ }
66
+ const answer = taken ? { agentId: stableId, fellBack: false } : { agentId: mint(), fellBack: true };
67
+ answers.set(path, answer);
68
+ return answer;
69
+ }
70
+
71
+ // Releases this process's locks and forgets what it decided — for tests, and a clean shutdown.
72
+ export function forgetAgentLocks() {
73
+ release();
74
+ answers.clear();
75
+ }
@@ -0,0 +1,27 @@
1
+ // The name an agent shows on the board when nobody gave it one: the repository it runs in and what it is
2
+ // ("cart-service (build)"). Derived on every start, so it is stable for as long as the repo is, and never
3
+ // written into the project's config — `agentName` there (init --name) and `run --name` still win.
4
+
5
+ import { execFileSync } from 'child_process';
6
+ import { basename } from 'path';
7
+
8
+ // `git@github.com:acme/cart-service.git`, `https://host/acme/cart-service/`, `ssh://git@host:2222/a/b.git` →
9
+ // the last path segment without `.git`.
10
+ function repoNameFromRemote(url) {
11
+ const last = url.trim().replace(/\/+$/, '').split(/[/:]/).pop() ?? '';
12
+ return last.replace(/\.git$/, '');
13
+ }
14
+
15
+ export function defaultAgentName(projectDir, agentType = 'BUILD') {
16
+ let repo = '';
17
+ try {
18
+ repo = repoNameFromRemote(execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }));
19
+ } catch { /* no remote, or not a git repo */ }
20
+ return `${repo || basename(projectDir)} (${agentType.toLowerCase()})`;
21
+ }
22
+
23
+ // A second agent in the same repo gets a fresh id (see agent-lock.js); its default name says which instance it is,
24
+ // so the board does not show two rows of the same name.
25
+ export function withInstanceSuffix(name, agentId) {
26
+ return `${name} · ${String(agentId).slice(0, 4)}`;
27
+ }
@@ -0,0 +1,58 @@
1
+ // Pruning the event log. The log only grows, and most of it is the prompt journal — a received/finished pair per
2
+ // board prompt, plus every tool call of a `--verbose` run — which nothing reads once the prompt is finished. What
3
+ // the state is derived from (claims, settles, learnings, the task queue, prompts still open) is never touched.
4
+ // Before anything is deleted the state is folded from what would be left and compared with the state folded from
5
+ // the whole log; a difference aborts the prune.
6
+
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
8
+ import { isDeepStrictEqual } from 'util';
9
+ import { join } from 'path';
10
+ import { EVENTS_DIR, INDEX_FILE, readEvents } from './slice-events.js';
11
+ import { fold } from './slice-state.js';
12
+
13
+ export const PRUNE_EVERY = 1000;
14
+ const ASKED_FILE = '.prune-asked'; // inside events/, which readEvents never lists and the kit's git rules already cover
15
+
16
+ const JOURNAL_TYPES = new Set(['prompt-received', 'prompt-finished', 'prompt-action', 'tool-call']);
17
+
18
+ // The events of prompts that have finished (and of nothing else).
19
+ export function prunableEvents(events) {
20
+ const finished = new Set(events.filter((e) => e?.type === 'prompt-finished').map((e) => e.prompt));
21
+ return events.filter((e) => e && JOURNAL_TYPES.has(e.type) && finished.has(e.type === 'prompt-received' ? e.prompt?.id : e.prompt));
22
+ }
23
+
24
+ // Deletes them: their files, then their lines from the index (rewritten whole and renamed into place). Returns
25
+ // { removed, kept }; removes nothing when there is nothing to remove, and throws without touching the log when
26
+ // the state folded from the rest would differ.
27
+ export function pruneLog(kitDir) {
28
+ const events = readEvents(kitDir);
29
+ const doomed = new Set(prunableEvents(events).map((e) => e.id));
30
+ if (!doomed.size) return { removed: 0, kept: events.length };
31
+ const rest = events.filter((e) => !doomed.has(e.id));
32
+ if (!isDeepStrictEqual(fold(rest), fold(events))) throw new Error('pruning would change the state the log folds to — nothing was deleted');
33
+
34
+ for (const id of doomed) rmSync(join(kitDir, EVENTS_DIR, `${id}.json`), { force: true });
35
+ const index = join(kitDir, INDEX_FILE);
36
+ const lines = readFileSync(index, 'utf-8').split('\n').filter((l) => l.trim() && !doomed.has(l.trim()));
37
+ const tmp = `${index}.tmp`;
38
+ writeFileSync(tmp, lines.length ? `${lines.join('\n')}\n` : '', 'utf-8');
39
+ renameSync(tmp, index);
40
+ return { removed: doomed.size, kept: rest.length };
41
+ }
42
+
43
+ // Asked when the log has reached a multiple of PRUNE_EVERY it has not been asked at yet — 1000, then 2000, … — so a
44
+ // "no" is not repeated on every start. `remember` records the size the answer was given at.
45
+ const askedPath = (kitDir) => join(kitDir, EVENTS_DIR, ASKED_FILE);
46
+ const bucket = (n) => Math.floor(n / PRUNE_EVERY);
47
+
48
+ export function shouldOfferPrune(kitDir, count) {
49
+ if (bucket(count) < 1) return false;
50
+ let asked = 0;
51
+ try { asked = Number(readFileSync(askedPath(kitDir), 'utf-8')) || 0; } catch { /* never asked */ }
52
+ return bucket(count) > bucket(asked);
53
+ }
54
+
55
+ export function rememberPruneAnswer(kitDir, count) {
56
+ if (!existsSync(join(kitDir, EVENTS_DIR))) mkdirSync(join(kitDir, EVENTS_DIR), { recursive: true });
57
+ writeFileSync(askedPath(kitDir), String(count), 'utf-8');
58
+ }
@@ -0,0 +1,134 @@
1
+ // The slice-state event log, decoded for a terminal (`eventmodelers events`): one plain-text line per event, the
2
+ // filters that go with it, and a summary of the state the log folds to. Colour is the command's business.
3
+
4
+ const short = (id) => (id ? String(id).slice(0, 8) : '');
5
+ const oneLine = (text, max) => {
6
+ const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
7
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
8
+ };
9
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
10
+
11
+ // slice id → title, from what the log itself knows (a claim records the slice's title).
12
+ export function sliceTitles(events) {
13
+ const titles = {};
14
+ for (const e of events) if (e.type === 'claimed' && e.slice && e.title) titles[e.slice] = e.title;
15
+ return titles;
16
+ }
17
+
18
+ // How long a call took, and that it failed — nothing for an event without a duration.
19
+ const took = (e) => (typeof e.durationMs === 'number' ? ` (${e.durationMs < 1000 ? `${e.durationMs}ms` : `${(e.durationMs / 1000).toFixed(1)}s`}${e.error ? ', failed' : ''})` : '');
20
+
21
+ // [subject, detail] — what the event is about, and what it says.
22
+ function describe(e) {
23
+ const tags = e.tags?.length ? ` ${e.tags.map((t) => `#${t}`).join(' ')}` : '';
24
+ switch (e.type) {
25
+ case 'board-observed': case 'agent-recorded': case 'settled':
26
+ return [e.slice, e.status ?? ''];
27
+ case 'claimed': {
28
+ const tree = e.cleanTree === true ? 'clean tree' : e.cleanTree === false ? 'dirty tree' : 'not a git tree';
29
+ return [e.slice, `pid ${e.pid}, ${tree}${e.base ? `, ${e.base.branch}@${String(e.base.head).slice(0, 8)}` : ''}${e.ticketNumber ? `, ticket ${e.ticketNumber}` : ''}${e.assignee ? `, assignee ${e.assignee}` : ''}`];
30
+ }
31
+ case 'claim-confirmed':
32
+ return [e.slice, ''];
33
+ case 'released':
34
+ return [e.slice, e.reason ?? ''];
35
+ case 'attempt-failed': {
36
+ const files = String(e.changed ?? '').split('\n').filter((l) => l.trim()).length;
37
+ const parts = [e.reason, e.status ? `back to ${e.status}` : '', files ? plural(files, 'file') : '', e.branch ?? ''].filter(Boolean);
38
+ return [e.slice, `${parts.join(', ')}${e.lastReply ? `: ${oneLine(e.lastReply, 80)}` : ''}`];
39
+ }
40
+ case 'blocked':
41
+ return [e.slice, oneLine(e.reason, 120)];
42
+ case 'task-queued':
43
+ return [e.payload?.sliceId, e.payload?.sliceStatus ?? ''];
44
+ case 'task-done':
45
+ return [e.task, ''];
46
+ case 'learning-recorded':
47
+ return ['', `[${e.topic ?? 'General'}] ${oneLine(e.text, 160)} (${e.scope ?? 'repo'}${tags})`];
48
+ case 'learning-retracted':
49
+ return [e.learning, e.reason ? oneLine(e.reason, 120) : ''];
50
+ case 'learning-merged':
51
+ return ['', `${plural(e.sources?.length ?? e.learnings?.length ?? 0, 'learning')} → ${oneLine(e.text, 120)}${e.reason ? ` — ${oneLine(e.reason, 80)}` : ''}`];
52
+ case 'learnings-curated':
53
+ return ['', ''];
54
+ case 'prompt-received':
55
+ return [e.prompt?.id, `"${oneLine(e.prompt?.text, 80)}"${e.prompt?.priority ? ', priority' : ''}, pid ${e.pid}`];
56
+ case 'prompt-action':
57
+ return [e.prompt, `${e.action ?? ''}${e.detail ? ` ${oneLine(e.detail, 140)}` : ''}${took(e)}`];
58
+ case 'tool-call':
59
+ return [e.prompt, `${oneLine(e.detail ?? e.tool, 140)}${took(e)}`];
60
+ case 'prompt-finished':
61
+ return [e.prompt, `${e.outcome ?? ''}${e.reason ? ` — ${oneLine(e.reason, 80)}` : ''}`];
62
+ default: {
63
+ const { type, id, at, meta, slice, ...rest } = e;
64
+ return [slice, oneLine(JSON.stringify(rest), 160)];
65
+ }
66
+ }
67
+ }
68
+
69
+ // One line: date and time (UTC, as logged), type, subject (a slice with its title when known), detail, and the first four characters of the
70
+ // agent that wrote it with its type (build or modeling) — a log shared by several agents says who did what.
71
+ export function formatEvent(e, { titles = {} } = {}) {
72
+ const time = typeof e.at === 'string' && e.at.length >= 19 ? `${e.at.slice(0, 10)} ${e.at.slice(11, 19)}` : '-'.repeat(19);
73
+ const [rawSubject, detail] = describe(e);
74
+ const title = titles[rawSubject];
75
+ const subject = rawSubject ? `${short(rawSubject)}${title ? ` "${title}"` : ''}` : '';
76
+ const agent = [e.meta?.agent_id ? String(e.meta.agent_id).slice(0, 4) : '', String(e.meta?.agent_type ?? '').toLowerCase()].filter(Boolean).join(' ');
77
+ return [time, String(e.type).padEnd(18), subject, detail, agent].filter((p, i) => i < 2 || p).join(' ');
78
+ }
79
+
80
+ // What an event carries beyond its line, indented under it: every field but the ones the line already shows
81
+ // (type, id, time, who). Empty when there is nothing more to say.
82
+ export function formatPayload(e) {
83
+ const { type, id, at, meta, ...rest } = e;
84
+ if (!Object.keys(rest).length) return '';
85
+ return JSON.stringify(rest, null, 2).split('\n').map((l) => ` ${l}`).join('\n');
86
+ }
87
+
88
+ // The kit's first look at each slice: its first `board-observed` event. A fresh kit records every slice already on
89
+ // the board this way, which says nothing happened — the ids to leave out of the default view of the log.
90
+ export function baselineIds(events) {
91
+ const seen = new Set();
92
+ const ids = new Set();
93
+ for (const e of events) {
94
+ if (e.type !== 'board-observed' || !e.slice || seen.has(e.slice)) continue;
95
+ seen.add(e.slice);
96
+ ids.add(e.id);
97
+ }
98
+ return ids;
99
+ }
100
+
101
+ // types / slice (an id prefix or part of a title) / tag / board (an id prefix — a global kit's log mixes boards) narrow
102
+ // the log; omit is a set of event ids to leave out (see baselineIds); last keeps the final N that remain.
103
+ export function filterEvents(events, { types, slice, tag, board, last, omit } = {}, titles = {}) {
104
+ let out = omit?.size ? events.filter((e) => !omit.has(e.id)) : events;
105
+ if (board) out = out.filter((e) => String(e.meta?.board_id ?? '').toLowerCase().startsWith(board.toLowerCase()));
106
+ if (types?.length) out = out.filter((e) => types.includes(e.type));
107
+ if (slice) {
108
+ const wanted = slice.toLowerCase();
109
+ out = out.filter((e) => {
110
+ const id = e.slice ?? e.payload?.sliceId;
111
+ return !!id && (String(id).toLowerCase().startsWith(wanted) || String(titles[id] ?? '').toLowerCase().includes(wanted));
112
+ });
113
+ }
114
+ if (tag) out = out.filter((e) => e.tags?.includes(tag));
115
+ return last ? out.slice(-last) : out;
116
+ }
117
+
118
+ // What the log folds to: slices (status, holder, attempts), the queue, prompts in hand, learnings.
119
+ export function summarize(state, titles = {}) {
120
+ const lines = [];
121
+ const slices = Object.entries(state.slices);
122
+ if (!slices.length) lines.push('no slices in the log');
123
+ for (const [id, s] of slices) {
124
+ const claim = s.claim ? ` claimed by pid ${s.claim.pid}${s.claim.confirmed ? '' : ' (unconfirmed)'}` : '';
125
+ const name = titles[id] ?? s.claim?.title;
126
+ const title = name ? ` "${name}"` : '';
127
+ const attempts = s.attempts ? ` ${plural(s.attempts, 'failed attempt')}` : '';
128
+ const blocked = s.blocked ? ` blocked: ${oneLine(s.blocked.reason, 80)}` : '';
129
+ lines.push(`${short(id)}${title} ${s.status ?? 'unknown'}${claim}${attempts}${blocked}`);
130
+ }
131
+ lines.push('');
132
+ lines.push(`${plural(state.tasks.length, 'task')} waiting · ${plural(state.prompts.length, 'prompt')} open · ${plural(state.learnings.length, 'learning')}`);
133
+ return lines.join('\n');
134
+ }
@@ -6,16 +6,17 @@
6
6
  // ollama — Ollama's native POST /api/chat
7
7
  // openai — the OpenAI-compatible POST /v1/chat/completions that vLLM, LM Studio,
8
8
  // llama.cpp-server, TGI, SGLang (and hosted gateways) all speak
9
- // Everything above the transport — the MCP tool loop, the tasks.json queue, the
9
+ // Everything above the transport — the MCP tool loop, the
10
10
  // security prompt — is identical for both, which is why this is one file and not
11
11
  // one kit per vendor.
12
12
  //
13
13
  // Usage: node local-ai-agent.js [model]
14
14
  // LOCAL_AI_TARGET=vllm node local-ai-agent.js
15
15
  // LOCAL_AI_URL=http://gpu-box:8000/v1 LOCAL_AI_MODEL=Qwen/Qwen3-8B node local-ai-agent.js
16
- // Reads tasks.json, picks the next task, and passes its prompts to the model.
16
+ // The run loop (lib/ralph.js) picks the oldest queued task and hands it over as the prompt on stdin — one task
17
+ // per run, completed by the loop when this process exits cleanly. There is no queue file to read.
17
18
 
18
- import { readFileSync, writeFileSync } from 'fs';
19
+ import { readFileSync } from 'fs';
19
20
  import { resolve, dirname } from 'path';
20
21
  import { fileURLToPath } from 'url';
21
22
 
@@ -254,28 +255,15 @@ async function runAgent(userPrompt, boardId) {
254
255
  return 'Max tool iterations reached.';
255
256
  }
256
257
 
257
- async function runNextTask() {
258
- const tasksPath = resolve(__dirname, '..', 'tasks.json');
259
- let tasks = [];
260
- try { tasks = JSON.parse(readFileSync(tasksPath, 'utf8')); } catch {}
261
-
262
- const blocked = tasks.filter(t => t.blocked === true || t.blockedBy?.length > 0);
263
- if (blocked.length > 0) {
264
- console.error(`[local-ai] removing ${blocked.length} blocked task(s): ${blocked.map(t => t.id).join(', ')}`);
265
- tasks = tasks.filter(t => !blocked.includes(t));
266
- writeFileSync(tasksPath, JSON.stringify(tasks, null, 2));
267
- }
268
-
269
- const task = tasks[0];
270
- if (!task) return;
271
-
272
- console.error(`[local-ai] task=${task.id} prompts=${task.prompts.length}`);
273
-
274
- for (const p of task.prompts) {
275
- console.log(await runAgent(p.prompt, p.board_id || defaultBoardId));
276
- }
277
-
278
- writeFileSync(tasksPath, JSON.stringify(tasks.slice(1), null, 2));
258
+ async function readStdin() {
259
+ let text = '';
260
+ for await (const chunk of process.stdin) text += chunk;
261
+ return text;
279
262
  }
280
263
 
281
- await runNextTask();
264
+ const prompt = await readStdin();
265
+ if (!prompt.trim()) {
266
+ console.error('[local-ai] no prompt on stdin — this agent is started by the run loop with the task as its prompt');
267
+ process.exit(1);
268
+ }
269
+ console.log(await runAgent(prompt, defaultBoardId));
@@ -3,6 +3,7 @@
3
3
  // loop (ralph-exec.js).
4
4
 
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
6
+ import { spawnSync } from 'child_process';
6
7
  import { homedir } from 'os';
7
8
  import { dirname, join } from 'path';
8
9
 
@@ -12,6 +13,18 @@ export function shellQuote(s) {
12
13
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
13
14
  }
14
15
 
16
+ // Older OpenCode releases have no `run --standalone`; passing it makes `opencode run` print its
17
+ // usage and exit 1, so every turn fails. Ask the installed binary once. Overridable for tests.
18
+ let standaloneSupported = null;
19
+ export function opencodeSupportsStandalone() {
20
+ if (standaloneSupported === null) {
21
+ const r = spawnSync('opencode', ['run', '--help'], { encoding: 'utf8' });
22
+ standaloneSupported = /--standalone\b/.test(`${r.stdout || ''}${r.stderr || ''}`);
23
+ }
24
+ return standaloneSupported;
25
+ }
26
+ export function setOpencodeStandaloneSupport(value) { standaloneSupported = value; }
27
+
15
28
  // Adds or refreshes one key in a project JSON config, leaving everything else in it alone. A file
16
29
  // that isn't plain JSON (a JSONC one with comments) is not rewritten — returns false instead, and
17
30
  // the caller says what to add by hand.
@@ -50,7 +63,10 @@ const MCP_REGISTRATIONS = {
50
63
  // A plain `opencode run` hands the turn to opencode's shared background service, which was
51
64
  // started with some other env: {env:EVENTMODELERS_TOKEN} resolves to nothing there and the
52
65
  // server refuses every call. --standalone runs a private server that inherits this one's env.
53
- const standalone = /\s--(standalone|server)\b/.test(` ${command}`) ? command : `${command} --standalone`;
66
+ // A binary without the flag runs the turn in-process already, so the flag is dropped, not added.
67
+ const supported = opencodeSupportsStandalone();
68
+ const standalone = !supported ? command.replace(/\s--standalone\b/, '')
69
+ : /\s--(standalone|server)\b/.test(` ${command}`) ? command : `${command} --standalone`;
54
70
  return { command: standalone, file: ok ? 'opencode.json' : null, manual: ok ? null : `add mcp.eventmodelers = ${JSON.stringify(entry)} to opencode.json` };
55
71
  },
56
72
  gemini: (command, projectDir, url) => {
@@ -0,0 +1,191 @@
1
+ // The modeling agent's journal of the board prompts it takes: `prompt-received` when it claims one, then
2
+ // `prompt-finished` with the outcome. The platform decides who gets a prompt (an atomic claim); this is the
3
+ // agent's own record of what it had in hand.
4
+
5
+ import { appendEvent, readEvents } from './slice-events.js';
6
+ import { fold } from './slice-state.js';
7
+ import { redactString } from './redact.js';
8
+
9
+ // What identifies the prompt and what `replay` needs to submit it again (text, timeline, canvas context) — not the
10
+ // platform's own columns (status, claim, timestamps).
11
+ export function journalPromptReceived({ kitDir, prompt, meta }) {
12
+ if (!prompt?.id) return;
13
+ appendEvent(kitDir, {
14
+ type: 'prompt-received',
15
+ pid: process.pid,
16
+ prompt: {
17
+ id: prompt.id,
18
+ text: String(prompt.prompt ?? ''),
19
+ board_id: prompt.board_id,
20
+ timeline_id: prompt.timeline_id,
21
+ ...(prompt.context ? { context: prompt.context } : {}),
22
+ priority: prompt.priority,
23
+ agent_id: prompt.agent_id ?? null,
24
+ node_id: prompt.node_id,
25
+ comment_id: prompt.comment_id,
26
+ },
27
+ }, meta);
28
+ }
29
+
30
+ // outcome: worked | released | already-settled | gone. A turn that failed is not finished: its entry stays
31
+ // open, and the next start recovers it.
32
+ export function journalPromptFinished({ kitDir, id, outcome, reason, meta }) {
33
+ appendEvent(kitDir, { type: 'prompt-finished', prompt: id, outcome, ...(reason ? { reason } : {}) }, meta);
34
+ }
35
+
36
+ // ── what a turn did on the board ────────────────────────────────────────────────
37
+ // `prompt-received` and `prompt-finished` say a prompt was taken and a turn ended; they cannot say what happened in
38
+ // between. Each board write the turn makes is journaled as it streams past — a comment, a slice moved to Blocked,
39
+ // nodes placed — so the log reads as what the agent did, and a turn that dies half way still shows how far it got.
40
+ // Reads are not journaled. Only what the runner can see is: a write made through curl in Bash is not.
41
+
42
+ const oneLine = (text, max) => {
43
+ const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
44
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
45
+ };
46
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
47
+ const count = (list) => (Array.isArray(list) ? list.length : 1);
48
+
49
+ // tool → what to journal; null for a tool that writes nothing to the board.
50
+ const BOARD_WRITES = {
51
+ add_comment: ({ comments = [] }) => {
52
+ const nodes = new Set(comments.map((c) => c?.nodeId).filter(Boolean)).size;
53
+ const first = oneLine(comments[0]?.text, 100);
54
+ return { action: 'commented', detail: `${plural(comments.length, 'comment')} on ${plural(nodes, 'node')}${first ? `: "${first}"` : ''}` };
55
+ },
56
+ update_comment: ({ action, commentId }) => ({ action: `comment-${action === 'delete' ? 'deleted' : 'resolved'}`, detail: String(commentId ?? '').slice(0, 8) }),
57
+ update_slice_status: ({ newStatus, sliceId, sliceTitle }) => ({ action: 'slice-status', detail: `${sliceTitle ? `"${oneLine(sliceTitle, 60)}"` : String(sliceId ?? '').slice(0, 8)} → ${newStatus}` }),
58
+ submit_node_events: ({ events = [] }) => {
59
+ const by = {};
60
+ for (const e of events) by[e?.type ?? 'event'] = (by[e?.type ?? 'event'] ?? 0) + 1;
61
+ return { action: 'nodes-changed', detail: Object.entries(by).map(([t, n]) => `${n} ${t}`).join(', ') };
62
+ },
63
+ place_element: ({ elements }) => ({ action: 'placed', detail: plural(count(elements), 'element') }),
64
+ delete_node: ({ nodeIds }) => ({ action: 'nodes-deleted', detail: plural(count(nodeIds), 'node') }),
65
+ create_slice: ({ type, status }) => ({ action: 'slice-created', detail: [type, status].filter(Boolean).join(', ') }),
66
+ create_chapter: ({ title }) => ({ action: 'chapter-created', detail: title ? `"${oneLine(title, 60)}"` : '' }),
67
+ add_column: ({ count: n = 1 }) => ({ action: 'columns-added', detail: String(n) }),
68
+ add_lane: ({ lanes }) => ({ action: 'lanes-added', detail: String(count(lanes)) }),
69
+ add_scenario: ({ scenarios }) => ({ action: 'scenarios-added', detail: String(count(scenarios)) }),
70
+ add_storyline: ({ storylines }) => ({ action: 'storylines-added', detail: String(count(storylines)) }),
71
+ set_connection: ({ connections }) => ({ action: 'connections-changed', detail: String(count(connections)) }),
72
+ link_element: () => ({ action: 'linked', detail: '' }),
73
+ create_screen: ({ screens }) => ({ action: 'screens-created', detail: String(count(screens)) }),
74
+ post_chat_message: ({ text }) => ({ action: 'chat-posted', detail: `"${oneLine(text, 100)}"` }),
75
+ };
76
+
77
+ // The journal entry for a tool call, or null when it is not a board write.
78
+ export function describeBoardAction(toolName, input) {
79
+ const describe = BOARD_WRITES[String(toolName).split('__').pop()];
80
+ return describe ? describe(input ?? {}) : null;
81
+ }
82
+
83
+ // Under `verbose` every call leaves exactly one entry: a board write as its action, any other call (a read, Bash, a
84
+ // skill) as a `tool-call` with `detail` — the caller's one-line account of its input — and both carry the call's full
85
+ // `input`, so `events --verbose` can show what was actually sent. The input is stored with the API token and anything
86
+ // JWT-shaped masked: a skill's arguments can hold credentials, and this log is a plain file.
87
+ //
88
+ // A call is journaled when it ends, as one entry: stamped with when it started, and carrying `durationMs` and, when the
89
+ // tool reported one, `error`. Timing costs nothing beyond a clock read at each end (createCallTracker).
90
+ export function journalPromptAction({ kitDir, id, toolName, input, verbose = false, detail, secrets = [], startedAt, durationMs, error = false, meta }) {
91
+ if (!id) return;
92
+ const done = describeBoardAction(toolName, input);
93
+ if (!done && !verbose) return;
94
+ const payload = verbose && input !== undefined ? { input: JSON.parse(redactString(JSON.stringify(input), secrets)) } : {};
95
+ const timing = { ...(startedAt ? { at: startedAt } : {}), ...(durationMs !== undefined ? { durationMs } : {}), ...(error ? { error: true } : {}) };
96
+ if (done) appendEvent(kitDir, { type: 'prompt-action', prompt: id, ...done, ...timing, ...payload }, meta);
97
+ else appendEvent(kitDir, { type: 'tool-call', prompt: id, tool: toolName, ...(detail ? { detail } : {}), ...timing, ...payload }, meta);
98
+ }
99
+
100
+ // Which calls are in flight, and for how long. `begin` and `end` are a Map operation and a clock read; calls that are
101
+ // never going to be journaled should not be begun (shouldJournalCall). `drain` returns what never ended — a turn that
102
+ // died mid-call — without a duration.
103
+ export const shouldJournalCall = (toolName, input, verbose) => verbose || describeBoardAction(toolName, input) !== null;
104
+
105
+ export function createCallTracker({ now = () => performance.now(), clock = () => new Date().toISOString() } = {}) {
106
+ const open = new Map();
107
+ return {
108
+ begin(id, call) { open.set(id, { ...call, startedAt: clock(), t0: now() }); },
109
+ end(id, { isError = false } = {}) {
110
+ const call = open.get(id);
111
+ if (!call) return null;
112
+ open.delete(id);
113
+ const { t0, ...rest } = call;
114
+ return { ...rest, durationMs: Math.round(now() - t0), error: !!isError };
115
+ },
116
+ drain() {
117
+ const rest = [...open.values()].map(({ t0, ...call }) => call);
118
+ open.clear();
119
+ return rest;
120
+ },
121
+ };
122
+ }
123
+
124
+ export function openPrompts(kitDir) {
125
+ return fold(readEvents(kitDir)).prompts;
126
+ }
127
+
128
+ // ── restart recovery ────────────────────────────────────────────────────────────
129
+ // The platform records who holds a prompt (`claimed_by`, the agent id) and never re-queues one by itself. A
130
+ // freshly started agent holds nothing, so whatever the platform still shows as CLAIMED / IN_PROGRESS under its
131
+ // own id is what an earlier run left behind — handed back at start. (Prompts of an agent that never comes back
132
+ // are the platform's to release, once its heartbeat has lapsed.) Two live processes must not share one agent
133
+ // id: the second would hand back the first one's prompts.
134
+
135
+ const REASON = 'recovered after a restart';
136
+
137
+ // `fetchHeld(agentId)` → [{ id, status }] the platform shows as held by that agent; `release(id)` puts a prompt
138
+ // back on the queue and throws an error carrying `status` when the platform refuses. The journal's open entries
139
+ // are closed along the way: released ones as 'released', the rest as 'already-settled'. Whatever cannot be
140
+ // settled now (platform unreachable, a 5xx on a hand-back) stays open and is tried again at the next start.
141
+ export async function recoverOwnPrompts({ kitDir, meta, agentId, fetchHeld, release }) {
142
+ const open = openPrompts(kitDir);
143
+ const result = { released: 0, settled: 0, left: 0 };
144
+ if (!agentId) return { ...result, left: open.length };
145
+
146
+ let held;
147
+ try {
148
+ held = await fetchHeld(agentId);
149
+ } catch (err) {
150
+ console.error(`[modeling] could not ask the platform which prompts this agent was holding${open.length ? ` — leaving ${open.length} unfinished for the next start` : ''}:`, err.message);
151
+ return { ...result, left: open.length };
152
+ }
153
+
154
+ const finish = (id, outcome) => journalPromptFinished({ kitDir, id, outcome, reason: REASON, meta });
155
+ const isOpen = (id) => open.some((p) => p.id === id);
156
+ for (const { id } of held) {
157
+ try {
158
+ await release(id);
159
+ if (isOpen(id)) finish(id, 'released');
160
+ result.released++;
161
+ } catch (err) {
162
+ // 409: it is already back on the queue; 404: it was deleted — nothing left to hand back.
163
+ if (err?.status === 409 || err?.status === 404) {
164
+ if (isOpen(id)) finish(id, err.status === 404 ? 'gone' : 'already-settled');
165
+ result.settled++;
166
+ } else {
167
+ console.error(`[modeling] could not hand prompt ${id} back — it stays held for the next start:`, err.message);
168
+ result.left++;
169
+ }
170
+ }
171
+ }
172
+ // Entries the platform does not show as held any more finished (or went) after all.
173
+ const heldIds = new Set(held.map((h) => h.id));
174
+ for (const p of open) {
175
+ if (heldIds.has(p.id)) continue;
176
+ finish(p.id, 'already-settled');
177
+ result.settled++;
178
+ }
179
+ return result;
180
+ }
181
+
182
+ // What asks the platform which prompts an agent holds: GET /prompts?claimed_by=, which reads and claims nothing.
183
+ // Like the other prompt routes it needs the API token *and* an authenticated user — the realtime JWT the agent
184
+ // already holds for /prompts/next.
185
+ export function heldPromptsRequest({ baseUrl, organizationId, boardId, token, jwt, agentId }) {
186
+ const query = new URLSearchParams({ board_id: boardId, claimed_by: agentId, status: 'CLAIMED,IN_PROGRESS', limit: '500' });
187
+ return {
188
+ url: `${baseUrl}/api/org/${organizationId}/prompts?${query}`,
189
+ headers: { 'x-token': token, Authorization: `Bearer ${jwt}`, 'x-agent-id': agentId },
190
+ };
191
+ }