@eventmodelers/cli 1.0.162 → 2.0.1

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 (104) hide show
  1. package/README.md +22 -27
  2. package/RELEASE_NOTES.md +20 -2
  3. package/cli.js +461 -225
  4. package/lib/adapters/spec-kitty-adapter.js +3 -7
  5. package/lib/global-agent-id.js +45 -0
  6. package/lib/modeling-local-ai.js +4 -2
  7. package/package.json +1 -1
  8. package/shared/build-kit/README.md +4 -19
  9. package/shared/build-kit/lib/agent-lock.js +75 -0
  10. package/shared/build-kit/lib/agent-name.js +27 -0
  11. package/shared/build-kit/lib/event-prune.js +58 -0
  12. package/shared/build-kit/lib/events-view.js +121 -0
  13. package/shared/build-kit/lib/local-ai-agent.js +14 -26
  14. package/shared/build-kit/lib/mcp-register.js +17 -1
  15. package/shared/build-kit/lib/prompt-journal.js +191 -0
  16. package/shared/build-kit/lib/prompt-replay.js +35 -0
  17. package/shared/build-kit/lib/ralph.js +250 -163
  18. package/shared/build-kit/lib/slice-curation.js +40 -0
  19. package/shared/build-kit/lib/slice-events.js +93 -0
  20. package/shared/build-kit/lib/slice-git.js +86 -0
  21. package/shared/build-kit/lib/slice-learnings.js +197 -0
  22. package/shared/build-kit/lib/slice-projection.js +101 -0
  23. package/shared/build-kit/lib/slice-run.js +181 -0
  24. package/shared/build-kit/lib/slice-state.js +182 -0
  25. package/shared/build-kit/lib/slice-tasks.js +35 -0
  26. package/shared/build-kit/ralph-claude.js +4 -2
  27. package/shared/build-kit/ralph-exec.js +4 -2
  28. package/shared/build-kit/ralph-local-ai.js +5 -3
  29. package/shared/skills/connect/SKILL.md +1 -5
  30. package/shared/skills/learn-eventmodelers-api/SKILL.md +13 -0
  31. package/shared/skills/request-feedback/SKILL.md +1 -2
  32. package/shared/skills/ui-contract/SKILL.md +5 -3
  33. package/stacks/axon/templates/build-kit/CLAUDE.md +3 -3
  34. package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
  35. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
  36. package/stacks/axon/templates/build-kit/lib/prompt.md +18 -41
  37. package/stacks/blank/templates/build-kit/CLAUDE.md +3 -3
  38. package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
  39. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
  40. package/stacks/blank/templates/build-kit/lib/prompt.md +19 -42
  41. package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
  42. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  43. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  44. package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
  45. package/stacks/classic/templates/build-kit/CLAUDE.md +3 -3
  46. package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
  47. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
  48. package/stacks/classic/templates/build-kit/lib/prompt.md +19 -42
  49. package/stacks/kurrent/templates/build-kit/CLAUDE.md +3 -4
  50. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
  51. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
  52. package/stacks/kurrent/templates/build-kit/lib/prompt.md +18 -41
  53. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +1 -17
  54. package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
  55. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
  56. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
  57. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +0 -8
  58. package/stacks/modeling-kit/templates/.claude/skills/stressor-analysis/SKILL.md +0 -2
  59. package/stacks/modeling-kit/templates/.claude/skills/walkthrough/SKILL.md +1 -1
  60. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
  61. package/stacks/modeling-kit/templates/kit/AGENTS.md +0 -1
  62. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
  63. package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -72
  64. package/stacks/modeling-kit/templates/kit/README.md +1 -1
  65. package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
  66. package/stacks/node/templates/build-kit/CLAUDE.md +3 -3
  67. package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
  68. package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
  69. package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
  70. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +3 -4
  71. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
  72. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
  73. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +18 -41
  74. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  75. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
  76. package/stacks/react/templates/build-kit/CLAUDE.md +3 -3
  77. package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
  78. package/stacks/react/templates/build-kit/lib/backend-prompt.md +20 -37
  79. package/stacks/react/templates/build-kit/lib/prompt.md +19 -42
  80. package/stacks/supabase/templates/build-kit/CLAUDE.md +3 -3
  81. package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
  82. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
  83. package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
  84. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +2 -2
  85. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  86. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +3 -3
  87. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
  88. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +20 -37
  89. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +19 -42
  90. package/stacks/umadb/templates/build-kit/CLAUDE.md +3 -3
  91. package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
  92. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
  93. package/stacks/umadb/templates/build-kit/lib/prompt.md +18 -41
  94. package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
  95. package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
  96. package/shared/build-kit/lib/agent.sh +0 -20
  97. package/shared/build-kit/ralph.sh +0 -111
  98. package/shared/build-kit/realtime-agent.js +0 -20
  99. package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
  100. package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
  101. package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
  102. package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
  103. package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
  104. package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
@@ -14,11 +14,8 @@
14
14
  // adapter's whole job is producing a good brief and calling `intake`, nothing
15
15
  // more.
16
16
  //
17
- // Lives here (not only inside a bridge-kit install) so it has exactly one
18
- // entry point regardless of caller: `eventmodelers fetch --spec-kitty` calls
19
- // it directly for a one-shot sync; `eventmodelers bridge` (via
20
- // ralph-static.js) calls the same run() from a copy the bridge-kit installer
21
- // places at .bridge-kit/adapters/ — see installStack in cli.js.
17
+ // Its one entry point is `eventmodelers fetch --spec-kitty`, a one-shot sync. (A standing bridge loop that
18
+ // ran the same run() from a copy in .bridge-kit/ is retired.)
22
19
  //
23
20
  // Exports run({ cfg, projectDir, contextName }).
24
21
 
@@ -43,8 +40,7 @@ function slugify(text) {
43
40
 
44
41
  // Mirrors lib/fetch.js's per-context call rather than importing runFetch
45
42
  // directly — this fetches raw JSON only, it never touches .slices/ (fetch.js
46
- // owns writing that), and a copy of this file also has to run standalone
47
- // inside an installed .bridge-kit/ with no access to lib/fetch.js at all.
43
+ // owns writing that).
48
44
  async function fetchFullSliceData(cfg, contextName) {
49
45
  const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
50
46
  const url = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?contextName=${encodeURIComponent(contextName)}`;
@@ -0,0 +1,45 @@
1
+ // A run on the global install keeps one agent id per board and repo — "repo" being the directory the run is
2
+ // started in (its git root, or the directory itself outside git). It is the same agent on every start: its chat
3
+ // sessions, its star on the board and the prompts it holds all stay addressed to it. The ids live in one small
4
+ // file next to the per-board credentials; nothing is written into the repo.
5
+
6
+ import { execFileSync } from 'child_process';
7
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'fs';
8
+ import { randomUUID } from 'crypto';
9
+ import { dirname } from 'path';
10
+
11
+ function real(path) {
12
+ try { return realpathSync(path); } catch { return path; }
13
+ }
14
+
15
+ // The git root of `dir`, else `dir` — both with symlinks resolved, so two paths to one repo are one repo.
16
+ export function repoKey(dir) {
17
+ const start = real(dir);
18
+ try {
19
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: start, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
20
+ return top ? real(top) : start;
21
+ } catch {
22
+ return start;
23
+ }
24
+ }
25
+
26
+ function readIds(file) {
27
+ try {
28
+ const ids = JSON.parse(readFileSync(file, 'utf-8'));
29
+ return ids && typeof ids === 'object' && !Array.isArray(ids) ? ids : {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ export function stableGlobalAgentId({ file, boardId, dir, mint = randomUUID }) {
36
+ const key = `${boardId}|${repoKey(dir)}`;
37
+ const ids = readIds(file);
38
+ if (typeof ids[key] === 'string' && ids[key]) return ids[key];
39
+ const id = mint();
40
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
41
+ const tmp = `${file}.${process.pid}.tmp`;
42
+ writeFileSync(tmp, JSON.stringify({ ...ids, [key]: id }, null, 2), 'utf-8');
43
+ renameSync(tmp, file);
44
+ return id;
45
+ }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // The build kit's own local runner (shared/build-kit/lib/local-ai-agent.js) is a
4
4
  // template copied into user projects, so it owns its whole lifecycle: it reads
5
- // config.json off disk, pulls one task out of tasks.json and exits. This one is the
5
+ // config.json off disk, pulls one task off the queue and exits. This one is the
6
6
  // same idea mounted the other way round — the modeling loop already owns the prompt
7
7
  // queue, the standalone board-change lane and the idle review, and only ever needed
8
8
  // something to hand a turn's text to. So this exports a runner with one method,
@@ -175,7 +175,7 @@ function systemPrompt({ boardId, organizationId, standalone }) {
175
175
  ].join('\n');
176
176
  }
177
177
 
178
- export function createModelingLocalAiRunner({ cfg, target, log, verbose = false, standalone = false }) {
178
+ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false, standalone = false, onToolUse = () => () => {} }) {
179
179
  const t = resolveLocalAiTarget({ target, localAi: cfg.localAi || {} });
180
180
  let tools = null; // the MCP tool set, fetched once per session
181
181
 
@@ -269,6 +269,7 @@ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false,
269
269
  : rawArgs;
270
270
 
271
271
  log(verbose ? `→ ${name}(${JSON.stringify(args).slice(0, 120)})` : `→ ${name}`);
272
+ const finish = onToolUse(name, args);
272
273
 
273
274
  let toolResult;
274
275
  try {
@@ -276,6 +277,7 @@ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false,
276
277
  } catch (err) {
277
278
  toolResult = { isError: true, content: [{ type: 'text', text: err.message }] };
278
279
  }
280
+ finish({ isError: !!toolResult.isError });
279
281
  if (verbose) log(` ${JSON.stringify(toolResult).slice(0, 160)}`);
280
282
 
281
283
  messages.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.162",
3
+ "version": "2.0.1",
4
4
  "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,121 @@
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
+ // types / slice (an id prefix or part of a title) / tag / board (an id prefix — a global kit's log mixes boards) narrow
89
+ // the log; last keeps the final N that remain.
90
+ export function filterEvents(events, { types, slice, tag, board, last } = {}, titles = {}) {
91
+ let out = events;
92
+ if (board) out = out.filter((e) => String(e.meta?.board_id ?? '').toLowerCase().startsWith(board.toLowerCase()));
93
+ if (types?.length) out = out.filter((e) => types.includes(e.type));
94
+ if (slice) {
95
+ const wanted = slice.toLowerCase();
96
+ out = out.filter((e) => {
97
+ const id = e.slice ?? e.payload?.sliceId;
98
+ return !!id && (String(id).toLowerCase().startsWith(wanted) || String(titles[id] ?? '').toLowerCase().includes(wanted));
99
+ });
100
+ }
101
+ if (tag) out = out.filter((e) => e.tags?.includes(tag));
102
+ return last ? out.slice(-last) : out;
103
+ }
104
+
105
+ // What the log folds to: slices (status, holder, attempts), the queue, prompts in hand, learnings.
106
+ export function summarize(state, titles = {}) {
107
+ const lines = [];
108
+ const slices = Object.entries(state.slices);
109
+ if (!slices.length) lines.push('no slices in the log');
110
+ for (const [id, s] of slices) {
111
+ const claim = s.claim ? ` claimed by pid ${s.claim.pid}${s.claim.confirmed ? '' : ' (unconfirmed)'}` : '';
112
+ const name = titles[id] ?? s.claim?.title;
113
+ const title = name ? ` "${name}"` : '';
114
+ const attempts = s.attempts ? ` ${plural(s.attempts, 'failed attempt')}` : '';
115
+ const blocked = s.blocked ? ` blocked: ${oneLine(s.blocked.reason, 80)}` : '';
116
+ lines.push(`${short(id)}${title} ${s.status ?? 'unknown'}${claim}${attempts}${blocked}`);
117
+ }
118
+ lines.push('');
119
+ lines.push(`${plural(state.tasks.length, 'task')} waiting · ${plural(state.prompts.length, 'prompt')} open · ${plural(state.learnings.length, 'learning')}`);
120
+ return lines.join('\n');
121
+ }
@@ -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) => {