@eventmodelers/cli 1.0.85 → 1.0.86

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.
@@ -1,134 +0,0 @@
1
- # .build-kit
2
-
3
- Ralph's runtime directory. Contains the agent loop, board poller, prompts, and Claude skills.
4
-
5
- > This is the **react** (API-access) stack — it talks to the board purely through
6
- > the plain REST `slicedata` endpoint on `api.eventmodelers.ai`, polled on an interval.
7
- > There is no Supabase/PocketBase realtime subscription and no direct database table
8
- > access anywhere in this kit. For instant push notifications instead of polling, use
9
- > the `supabase-react` stack.
10
-
11
- ## Quick start
12
-
13
- ```bash
14
- # Claude (default)
15
- node .build-kit/ralph-claude.js
16
-
17
- # Local or self-hosted model — Ollama (run `ollama serve` first)
18
- LOCAL_AI_TARGET=ollama node .build-kit/ralph-local-ai.js
19
-
20
- # …or any OpenAI-compatible server (vLLM, LM Studio, llama.cpp, TGI)
21
- LOCAL_AI_TARGET=vllm LOCAL_AI_MODEL=Qwen/Qwen3-8B node .build-kit/ralph-local-ai.js
22
-
23
- # Custom project directory (defaults to the parent of .build-kit)
24
- node .build-kit/ralph-claude.js /path/to/project
25
- ```
26
-
27
- ## Files
28
-
29
- **Entry points** (top level):
30
-
31
- | File | Purpose |
32
- |------|---------|
33
- | `ralph-claude.js` | Runs the full loop using Claude Code as the executor |
34
- | `ralph-local-ai.js` | Runs the full loop using a local/self-hosted model (Ollama, vLLM, LM Studio, llama.cpp) |
35
- | `ralph-exec.js` | Runs the full loop handing each prompt to an external agent command (Codex CLI, OpenCode, …) |
36
- | `ralph.sh` | Shell-based loop — alternative to the JS entry points |
37
-
38
- **Internals** (`lib/`):
39
-
40
- | File | Purpose |
41
- |------|---------|
42
- | `lib/ralph.js` | Shared library — board poller + loop logic; imported by the entry points |
43
- | `lib/local-ai-agent.js` | Local-AI executor — called by `ralph-local-ai.js`, can also run manually |
44
- | `lib/agent.sh` | Thin shell wrapper around `claude` — called by `ralph.sh` |
45
- | `lib/prompt.md` | Phase 1 prompt: tells Claude how to load a slice from the board |
46
- | `lib/backend-prompt.md` | Phase 2 prompt: tells Claude how to build a planned slice |
47
- | `lib/AGENT.md` | Agent instructions included in Claude's context |
48
-
49
- ## How it works
50
-
51
- **Phase 1** — triggered when `tasks.json` has entries:
52
- - The poller writes a task to `tasks.json` each time it notices a slice's status changed since the last poll
53
- - The loop picks it up and runs Claude (or a local model) with `prompt.md`
54
- - Claude loads the slice data and updates `.slices/`
55
-
56
- **Phase 2** — triggered when any file in `.slices/` contains `"status": "Planned"`:
57
- - The loop runs Claude with `backend-prompt.md`
58
- - Claude implements the slice in the project
59
- - Phase 2 is Claude-only; local-AI mode skips it (local-ai-agent handles its own queue)
60
-
61
- Both phases run in a continuous loop with a 3-second idle sleep. The board poller runs concurrently in the same process, re-fetching `slicedata/slices` every `RALPH_POLL_INTERVAL_MS` (default 10s — see `lib/ralph.js`).
62
-
63
- ## Local-AI configuration
64
-
65
- `ralph-local-ai.js` drives any local or self-hosted model that can do tool calling.
66
- Claude (`ralph-claude.js`) stays the default runner — this is opt-in.
67
-
68
- ```bash
69
- LOCAL_AI_TARGET=ollama # preset: ollama | vllm | lmstudio | llamacpp
70
- LOCAL_AI_URL=http://host:8000/v1 # any OpenAI-compatible server (overrides the preset URL)
71
- LOCAL_AI_MODEL=qwen3.5:9b # model name as the server knows it
72
- LOCAL_AI_API=openai # force the wire dialect: ollama | openai (normally inferred)
73
- LOCAL_AI_API_KEY=local # sent as `Authorization: Bearer` on the openai dialect
74
- LOCAL_AI_NUM_CTX=32768 # ollama only — context window (default 32768)
75
- ```
76
-
77
- **Do not lower `LOCAL_AI_NUM_CTX`.** The MCP tool schemas are ~16k tokens on their own.
78
- Ollama's own default is 4096, which silently truncates them — the model then sees a
79
- fragment of the tool list and invents tool names instead of failing, which is why the
80
- default here is raised rather than left to the server. On the `openai` dialect the
81
- equivalent is set when you launch the server (vLLM `--max-model-len 32768`,
82
- llama.cpp `-c 32768`); an overflow there surfaces as an HTTP 400.
83
-
84
-
85
- ## External agent commands (`--exec`)
86
-
87
- Agentic harnesses that bring their own tool loop — Codex CLI, OpenCode, Gemini CLI —
88
- are not `--local-ai` targets: `--local-ai` *supplies* the agent loop, while a harness
89
- already is one and only wants a prompt. They go through `ralph-exec.js` instead:
90
-
91
- ```bash
92
- npx @eventmodelers/cli run --exec "codex exec --full-auto"
93
- npx @eventmodelers/cli run --exec "opencode run"
94
-
95
- # …or persist it and use the bare flag
96
- RALPH_EXEC_CMD="codex exec --full-auto" node .build-kit/ralph-exec.js
97
- ```
98
-
99
- The prompt is appended to the command as one shell-quoted argument, and is also written
100
- to a temp file named by `RALPH_PROMPT_FILE` for commands that prefer to read it. The
101
- child runs with the project dir as its cwd and inherits stdio — a harness owns its own
102
- output format, so there is no condensed per-step logging here the way `ralph-claude.js`
103
- has it.
104
-
105
- Persist a default alongside the local-AI settings:
106
-
107
- ```json
108
- {
109
- "localAi": {
110
- "exec": "codex exec --full-auto"
111
- }
112
- }
113
- ```
114
-
115
- One caveat worth knowing before reaching for this: the kits' prompts assume Claude
116
- Code's `Skill` tool and `CLAUDE.md`. Other harnesses read `AGENTS.md` and have no skill
117
- primitive, so `init-agents` puts the skill files where they can find them, but
118
- `lib/prompt.md` / `lib/backend-prompt.md` still need wording that says *read and follow*
119
- a skill file rather than *invoke* it.
120
-
121
- ## Config
122
-
123
- Credentials are stored in `.build-kit/.eventmodelers/config.json` (written by `eventmodelers init`):
124
-
125
- ```json
126
- {
127
- "organizationId": "...",
128
- "boardId": "...",
129
- "token": "...",
130
- "baseUrl": "https://api.eventmodelers.ai"
131
- }
132
- ```
133
-
134
- Claude skills live in `.build-kit/.claude/skills/` and are available inside any Claude Code session started from `.build-kit/`.
@@ -1,517 +0,0 @@
1
- // Common runtime for the ralph loop + board poller.
2
- // Not meant to be run directly — use ralph-claude.js or ralph-local-ai.js.
3
- //
4
- // This kit has no Supabase/PocketBase realtime integration and never touches a
5
- // database table directly — board changes are picked up purely through the plain
6
- // REST slicedata endpoint on api.eventmodelers.ai, polled on an interval (see
7
- // "Board polling" below). If you need instant push notifications instead of
8
- // polling, use the supabase-react stack.
9
- //
10
- // startRalph({ kitDir, projectDir, onTask, onPlannedSlice })
11
- // onTask(prompt) — called when tasks.json has entries
12
- // onPlannedSlice(prompt) — called when .slices/ has a "Planned" entry (omit to skip)
13
-
14
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs';
15
- import { join, dirname } from 'path';
16
- import { homedir } from 'os';
17
- import { randomUUID } from 'crypto';
18
-
19
- // ── HTTP helpers ──────────────────────────────────────────────────────────────
20
-
21
- class HttpError extends Error {
22
- constructor(status, body) {
23
- super(`HTTP ${status}: ${body}`);
24
- this.status = status;
25
- }
26
- }
27
-
28
- async function fetchJSON(url, options) {
29
- const res = await fetch(url, options);
30
- if (!res.ok) throw new HttpError(res.status, await res.text());
31
- return res.json();
32
- }
33
-
34
- async function retryOn401(label, fn, maxRetries = 3) {
35
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
36
- try {
37
- return await fn();
38
- } catch (err) {
39
- if (err instanceof HttpError && err.status === 401) {
40
- if (attempt < maxRetries) {
41
- console.warn(`[agent] ${label} — 401, retrying (${attempt}/${maxRetries})...`);
42
- continue;
43
- }
44
- console.error(`[agent] ${label} — 401 after ${maxRetries} retries, shutting down`);
45
- process.exit(1);
46
- }
47
- throw err;
48
- }
49
- }
50
- }
51
-
52
- // ── Config ────────────────────────────────────────────────────────────────────
53
-
54
- // Config is resolved by walking from the kit dir up through every ancestor
55
- // directory's .eventmodelers/config.json, merging fields as we go — a value
56
- // set by a closer (more specific) directory always wins over a farther one.
57
- // The walk stops as soon as the merged config has full connection credentials
58
- // (see hasCredentials); anthropicBaseUrl/model are picked up opportunistically
59
- // along the way but never force the walk to continue further up.
60
- function* configCandidates(kitDir) {
61
- yield join(kitDir, '.eventmodelers', 'config.json');
62
- let dir = dirname(kitDir);
63
- while (true) {
64
- yield join(dir, '.eventmodelers', 'config.json');
65
- const parent = dirname(dir);
66
- if (parent === dir) break;
67
- dir = parent;
68
- }
69
- // Last resort: the walk above only passes through $HOME if the project happens
70
- // to live under it. A project outside $HOME (e.g. /tmp/foo) never sees it, so
71
- // check it explicitly — this is where `eventmodelers init-config --global` writes
72
- // account-wide defaults (organizationId/token) shared across every project.
73
- yield join(homedir(), '.eventmodelers', 'config.json');
74
- }
75
-
76
- function loadLocalConfig(kitDir) {
77
- const merged = {};
78
- const sources = [];
79
-
80
- for (const candidate of configCandidates(kitDir)) {
81
- if (sources.includes(candidate) || !existsSync(candidate)) continue;
82
- let cfg;
83
- try {
84
- cfg = JSON.parse(readFileSync(candidate, 'utf-8'));
85
- } catch {
86
- console.warn(`[ralph] Skipping invalid config at ${candidate}`);
87
- continue;
88
- }
89
- for (const [key, value] of Object.entries(cfg)) {
90
- if (merged[key] === undefined) merged[key] = value;
91
- }
92
- sources.push(candidate);
93
- if (hasCredentials(merged)) break;
94
- }
95
-
96
- if (process.env.BASE_URL) merged.baseUrl = process.env.BASE_URL;
97
- else if (!merged.baseUrl) merged.baseUrl = 'https://api.eventmodelers.ai';
98
-
99
- if (sources.length > 1) {
100
- console.log(`[ralph] Merged config from: ${sources.join(', ')}`);
101
- } else if (sources.length === 1 && sources[0] !== join(kitDir, '.eventmodelers', 'config.json')) {
102
- console.log(`[ralph] Using credentials from ${sources[0]}`);
103
- } else if (sources.length === 0) {
104
- console.warn(`[ralph] Note: no .eventmodelers/config.json found — platform sync disabled.`);
105
- console.warn(` To enable board sync, follow: https://app.eventmodelers.ai/documentation#build`);
106
- console.warn(` Code generation from local slice definitions will still run.`);
107
- }
108
-
109
- return merged;
110
- }
111
-
112
- function hasCredentials(cfg) {
113
- return !!(cfg.token && cfg.organizationId && cfg.boardId && cfg.baseUrl);
114
- }
115
-
116
- // Distinguishes this agent process from any other agent pinging the same
117
- // token/board — e.g. a build-kit and a bridge-kit install in the same project
118
- // share one root config.json, and without a per-agent id both would upsert the
119
- // same alive row and race each other. The platform already keys the alive-ping
120
- // on the (agent_type, agent_id) pair, so one shared file works: agentIds is
121
- // namespaced by agentType (BUILD/BRIDGE/MODELING/...) inside the project ROOT
122
- // .eventmodelers/config.json — the same file credentials already live in —
123
- // instead of each kit dir keeping its own separate config.json. Falls back to
124
- // a pre-existing kit-local agentId (older installs, before this consolidation)
125
- // so an upgrade doesn't mint a new identity the platform hasn't seen before.
126
- function ensureAgentId(kitDir, agentType) {
127
- const rootConfigPath = join(dirname(kitDir), '.eventmodelers', 'config.json');
128
- let rootCfg = {};
129
- if (existsSync(rootConfigPath)) {
130
- try {
131
- rootCfg = JSON.parse(readFileSync(rootConfigPath, 'utf-8'));
132
- } catch {
133
- console.warn(`[ralph] Skipping invalid config at ${rootConfigPath}`);
134
- }
135
- }
136
- rootCfg.agentIds = rootCfg.agentIds || {};
137
- if (rootCfg.agentIds[agentType]) return rootCfg.agentIds[agentType];
138
-
139
- const legacyKitConfigPath = join(kitDir, '.eventmodelers', 'config.json');
140
- let legacyAgentId;
141
- if (existsSync(legacyKitConfigPath)) {
142
- try {
143
- legacyAgentId = JSON.parse(readFileSync(legacyKitConfigPath, 'utf-8')).agentId;
144
- } catch {
145
- console.warn(`[ralph] Skipping invalid config at ${legacyKitConfigPath}`);
146
- }
147
- }
148
-
149
- const agentId = legacyAgentId || randomUUID();
150
- rootCfg.agentIds[agentType] = agentId;
151
- mkdirSync(dirname(rootConfigPath), { recursive: true });
152
- writeFileSync(rootConfigPath, JSON.stringify(rootCfg, null, 2));
153
- return agentId;
154
- }
155
-
156
- // `x-agent-id` on every platform call this loop makes, when it knows its own agent id (see
157
- // ensureAgentId above / RALPH_AGENT_ID). The heartbeat says this agent is alive; the header says
158
- // which calls are its, so its board writes are attributed to it and a prompt the user addressed
159
- // to one preferred agent is only ever claimed by that agent.
160
- function agentHeaders(cfg) {
161
- const agentId = cfg?.agentId || process.env.RALPH_AGENT_ID || process.env.EVENTMODELERS_AGENT_ID || '';
162
- return agentId ? { 'x-agent-id': agentId } : {};
163
- }
164
-
165
- async function fetchPlatformConfig(local) {
166
- const remote = await fetchJSON(`${local.baseUrl}/api/config`, {
167
- headers: { 'x-token': local.token, ...agentHeaders(local) },
168
- });
169
- return { ...local, ...remote };
170
- }
171
-
172
- // ── Board polling ─────────────────────────────────────────────────────────────
173
-
174
- function slugify(str) {
175
- return str.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
176
- }
177
-
178
- async function fetchAndPersistSlices(cfg, kitDir) {
179
- const url = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`;
180
- const { slices } = await fetchJSON(url, {
181
- headers: { 'x-token': cfg.token, ...agentHeaders(cfg) },
182
- });
183
- const slicesDir = join(kitDir, '.slices');
184
- mkdirSync(slicesDir, { recursive: true });
185
-
186
- // Group by context slug
187
- const contexts = {};
188
- for (const slice of slices) {
189
- const contextSlug = slice.contextName ? slugify(slice.contextName) : 'default';
190
- if (!contexts[contextSlug]) contexts[contextSlug] = { name: slice.contextName || 'default', slices: [] };
191
- contexts[contextSlug].slices.push(slice);
192
- }
193
-
194
- // current_context.json is STICKY. We work within ONE context at a time and must
195
- // not auto-jump to another context just because it happens to have planned work.
196
- // Keep the existing context if it still exists; only seed it when absent or stale.
197
- const ctxPath = join(slicesDir, 'current_context.json');
198
- let activeCtx = null;
199
- if (existsSync(ctxPath)) {
200
- try { activeCtx = JSON.parse(readFileSync(ctxPath, 'utf-8')).name; } catch {}
201
- }
202
- if (!activeCtx || !contexts[activeCtx]) {
203
- // First run (or the current context disappeared): seed with a context that
204
- // has planned work, else the first one. This is the ONLY place we choose it.
205
- const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status || '').toLowerCase() === 'planned'));
206
- activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
207
- writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
208
- }
209
-
210
- // Write per-context index.json and per-slice slice.json
211
- for (const [contextSlug, { slices: ctxSlices }] of Object.entries(contexts)) {
212
- const contextDir = join(slicesDir, contextSlug);
213
- mkdirSync(contextDir, { recursive: true });
214
-
215
- const indexSlices = ctxSlices.map((s, i) => {
216
- const folder = (s.title ?? s.id).replaceAll(' ', '').toLowerCase();
217
- return {
218
- id: s.id,
219
- slice: s.title,
220
- index: i,
221
- contextName: s.contextName || contextSlug,
222
- contextSlug,
223
- folder,
224
- status: s.status,
225
- definition: { id: s.id, title: s.title, status: s.status },
226
- };
227
- });
228
- writeFileSync(join(contextDir, 'index.json'), JSON.stringify({ slices: indexSlices }, null, 2), 'utf-8');
229
-
230
- for (const slice of ctxSlices) {
231
- const folder = (slice.title ?? slice.id).replaceAll(' ', '').toLowerCase();
232
- const sliceDir = join(contextDir, folder);
233
- mkdirSync(sliceDir, { recursive: true });
234
- writeFileSync(join(sliceDir, 'slice.json'), JSON.stringify(slice, null, 2), 'utf-8');
235
- }
236
- }
237
-
238
- console.log(`[agent] Persisted ${slices.length} slice(s)`);
239
- return slices;
240
- }
241
-
242
- async function writeTask(payload, kitDir) {
243
- const tasksPath = join(kitDir, 'tasks.json');
244
- const existing = existsSync(tasksPath) ? JSON.parse(readFileSync(tasksPath, 'utf-8')) : [];
245
- const filtered = existing.filter(t => t.payload?.sliceId !== payload.sliceId);
246
- const task = { id: randomUUID(), createdAt: new Date().toISOString(), payload };
247
- filtered.push(task);
248
- writeFileSync(tasksPath, JSON.stringify(filtered, null, 2), 'utf-8');
249
- console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
250
- }
251
-
252
- // How often to re-fetch the board's slices when idle, in ms. A slice that goes
253
- // straight to "Planned" is picked up by onPlannedSlice's own index.json scan the
254
- // moment fetchAndPersistSlices writes it; this diff only exists to turn any OTHER
255
- // status change into a tasks.json entry for onTask (mirrors what a push channel's
256
- // slice:changed event used to do, one poll tick later instead of instantly).
257
- const POLL_INTERVAL_MS = Number(process.env.RALPH_POLL_INTERVAL_MS) || 10_000;
258
-
259
- async function pollForChanges(cfg, kitDir, seen, queueAllStatuses) {
260
- const slices = await fetchAndPersistSlices(cfg, kitDir);
261
- for (const slice of slices) {
262
- const status = (slice.status || '').toLowerCase();
263
- const previous = seen.get(slice.id);
264
- seen.set(slice.id, status);
265
- if (previous === undefined || previous === status) continue;
266
-
267
- console.log(`[agent] slice changed — slice="${slice.title}" status="${slice.status}"`);
268
- // Planned slices are handled by onPlannedSlice directly — no task needed.
269
- // queueAllStatuses opts out of that split entirely (e.g. bridge has no
270
- // onPlannedSlice consumer, so a lingering Planned slice would otherwise
271
- // never naturally clear its own trigger — see lib/ralph.js callers).
272
- if (queueAllStatuses || status !== 'planned') {
273
- const payload = {
274
- event: 'slice:changed',
275
- organizationId: cfg.organizationId,
276
- boardId: cfg.boardId,
277
- sliceId: slice.id,
278
- sliceTitle: slice.title,
279
- sliceStatus: slice.status,
280
- timestamp: Date.now(),
281
- };
282
- await writeTask(payload, kitDir).catch((err) => console.error('[agent] writeTask error:', err));
283
- }
284
- }
285
- }
286
-
287
- async function startPolling(cfg, kitDir, { queueAllStatuses = false } = {}) {
288
- const seen = new Map();
289
- const initial = await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) => {
290
- console.error('[agent] Initial slice fetch error:', err);
291
- return [];
292
- });
293
- for (const slice of initial) seen.set(slice.id, (slice.status || '').toLowerCase());
294
-
295
- while (true) {
296
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
297
- await retryOn401('pollForChanges', () => pollForChanges(cfg, kitDir, seen, queueAllStatuses)).catch((err) =>
298
- console.error('[agent] Poll error:', err),
299
- );
300
- }
301
- }
302
-
303
- // ── Ralph loop ────────────────────────────────────────────────────────────────
304
-
305
- function hasPendingTasks(kitDir) {
306
- const tasksPath = join(kitDir, 'tasks.json');
307
- if (!existsSync(tasksPath)) return false;
308
- try {
309
- const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
310
- return Array.isArray(tasks) && tasks.length > 0;
311
- } catch {
312
- return false;
313
- }
314
- }
315
-
316
- function readCurrentContext(kitDir) {
317
- const ctxPath = join(kitDir, '.slices', 'current_context.json');
318
- if (!existsSync(ctxPath)) return null;
319
- try { return JSON.parse(readFileSync(ctxPath, 'utf-8')).name || null; } catch { return null; }
320
- }
321
-
322
- // Returns the first Planned slice IN THE CURRENT CONTEXT ONLY. If the current
323
- // context has no planned work, returns null so the loop waits — it must NEVER
324
- // cross into another context to find something to build.
325
- function getFirstPlannedSlice(kitDir) {
326
- const currentCtx = readCurrentContext(kitDir);
327
- if (!currentCtx) return null;
328
- const indexPath = join(kitDir, '.slices', currentCtx, 'index.json');
329
- if (!existsSync(indexPath)) return null;
330
- try {
331
- const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
332
- const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === 'planned');
333
- if (planned) return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx: currentCtx };
334
- } catch {}
335
- return null;
336
- }
337
-
338
- // If the exact same Planned slice (by id) comes back up this many times in a
339
- // row without its status ever leaving "Planned", onPlannedSlice is stuck on
340
- // it — declining to build it, or building it but its own status change keeps
341
- // getting reverted (e.g. a failed check). Rather than retry it forever (or
342
- // crash the whole loop, which would take down every other slice with it),
343
- // mark it Blocked with a note explaining why and move on to other work.
344
- // Critical for unsupervised/CI runs, which have no human watching to notice
345
- // a stall. Configurable for teams that want more slack.
346
- const MAX_PLANNED_ATTEMPTS = Number(process.env.RALPH_MAX_PLANNED_ATTEMPTS) || 2;
347
-
348
- // Marks a stuck slice Blocked (locally, and on the board if credentialed) and
349
- // records why, so the loop can move on instead of looping or exiting.
350
- async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
351
- const now = new Date().toISOString();
352
- const reason = `Ralph loop picked up this slice ${attempts} times in a row without its status ever leaving ` +
353
- `"Planned" — the build agent kept declining to build it, or kept building it but its own status change kept ` +
354
- `getting reverted (e.g. a failed check). Auto-blocked to stop the loop from retrying it forever.`;
355
-
356
- const indexPath = join(kitDir, '.slices', planned.ctx, 'index.json');
357
- let folder;
358
- try {
359
- const indexData = JSON.parse(readFileSync(indexPath, 'utf-8'));
360
- const entry = (indexData.slices ?? []).find((s) => s.id === planned.id);
361
- if (entry) {
362
- entry.status = 'Blocked';
363
- entry.blockedReason = reason;
364
- entry.blockedAt = now;
365
- folder = entry.folder;
366
- writeFileSync(indexPath, JSON.stringify(indexData, null, 2), 'utf-8');
367
- }
368
- } catch (err) {
369
- console.error(`[ralph] Failed to write Blocked status to ${indexPath}:`, err.message);
370
- }
371
-
372
- if (folder) {
373
- const sliceJsonPath = join(kitDir, '.slices', planned.ctx, folder, 'slice.json');
374
- try {
375
- if (existsSync(sliceJsonPath)) {
376
- const sliceData = JSON.parse(readFileSync(sliceJsonPath, 'utf-8'));
377
- sliceData.status = 'Blocked';
378
- sliceData.blockedReason = reason;
379
- sliceData.blockedAt = now;
380
- writeFileSync(sliceJsonPath, JSON.stringify(sliceData, null, 2), 'utf-8');
381
- }
382
- } catch (err) {
383
- console.error(`[ralph] Failed to write Blocked status to ${sliceJsonPath}:`, err.message);
384
- }
385
- }
386
-
387
- try {
388
- const progressPath = join(dirname(kitDir), 'progress.txt');
389
- const existing = existsSync(progressPath) ? readFileSync(progressPath, 'utf-8') : '';
390
- const note = `\n## ${now} — Slice auto-blocked\n\nSlice: ${planned.title} (id=${planned.id}, context=${planned.ctx})\n\n- ${reason}\n---\n`;
391
- writeFileSync(progressPath, existing + note, 'utf-8');
392
- } catch (err) {
393
- console.error('[ralph] Failed to append progress.txt note:', err.message);
394
- }
395
-
396
- // Best-effort: also reflect Blocked on the board itself so a synced fetch
397
- // doesn't just pull "Planned" back down over our local fix. Never fatal —
398
- // this loop must keep going locally even if the board call fails.
399
- if (credentialed) {
400
- try {
401
- await fetchJSON(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
402
- method: 'POST',
403
- headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'ralph-loop', ...agentHeaders(cfg) },
404
- body: JSON.stringify([{
405
- id: randomUUID(),
406
- eventType: 'node:changed',
407
- nodeId: planned.id,
408
- boardId: cfg.boardId,
409
- timestamp: Date.now(),
410
- changedAttributes: ['sliceStatus'],
411
- meta: { sliceStatus: 'Blocked' },
412
- }]),
413
- });
414
- } catch (err) {
415
- console.error(`[ralph] Failed to sync Blocked status to the board:`, err.message);
416
- }
417
- }
418
-
419
- console.error(`[ralph] ${reason} Marked "${planned.title}" (id=${planned.id}) as Blocked — moving on.`);
420
- }
421
-
422
- async function runWithRetry(label, fn) {
423
- while (true) {
424
- try {
425
- console.log(`[ralph] ${label}`);
426
- await fn();
427
- return;
428
- } catch (err) {
429
- console.error(`[ralph] Error — retrying in 60s:`, err.message);
430
- await new Promise((r) => setTimeout(r, 60_000));
431
- }
432
- }
433
- }
434
-
435
- async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice) {
436
- const promptFile = join(kitDir, 'lib', 'prompt.md');
437
- const backendPromptFile = join(kitDir, 'lib', 'backend-prompt.md');
438
- const credentialed = hasCredentials(cfg);
439
- let lastIdleCtx;
440
- // Tracks consecutive sightings of the same Planned slice id — see
441
- // MAX_PLANNED_ATTEMPTS above.
442
- let stuckSlice = { id: null, count: 0 };
443
-
444
- while (true) {
445
- let didWork = false;
446
-
447
- if (credentialed && hasPendingTasks(kitDir)) {
448
- const prompt = readFileSync(promptFile, 'utf-8');
449
- await runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
450
- await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
451
- didWork = true;
452
- }
453
-
454
- const planned = onPlannedSlice && getFirstPlannedSlice(kitDir);
455
- if (planned) {
456
- stuckSlice = planned.id !== null && planned.id === stuckSlice.id
457
- ? { id: stuckSlice.id, count: stuckSlice.count + 1 }
458
- : { id: planned.id, count: 1 };
459
-
460
- if (stuckSlice.count > MAX_PLANNED_ATTEMPTS) {
461
- await blockStuckSlice(kitDir, cfg, credentialed, planned, stuckSlice.count);
462
- stuckSlice = { id: null, count: 0 };
463
- didWork = true;
464
- continue;
465
- }
466
-
467
- const prompt = readFileSync(backendPromptFile, 'utf-8');
468
- await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt));
469
- console.log(`[ralph] Slice build complete — waiting for next slice`);
470
- if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
471
- didWork = true;
472
- }
473
-
474
- if (!didWork) {
475
- // No planned work in the current context — wait, do NOT switch contexts.
476
- const ctx = readCurrentContext(kitDir);
477
- if (ctx !== lastIdleCtx) {
478
- console.log(`[ralph] No planned slices in current context "${ctx}" — waiting. Switch context on the board to continue.`);
479
- lastIdleCtx = ctx;
480
- }
481
- await new Promise((r) => setTimeout(r, 10_000));
482
- } else {
483
- lastIdleCtx = undefined;
484
- }
485
- }
486
- }
487
-
488
- // ── Public API ────────────────────────────────────────────────────────────────
489
-
490
- export { loadLocalConfig, fetchPlatformConfig, retryOn401, startPolling };
491
-
492
- export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
493
- const local = loadLocalConfig(kitDir);
494
- local.agentId = ensureAgentId(kitDir, agentType);
495
-
496
- console.log(`Ralph — kit: ${kitDir}`);
497
- console.log(` project: ${projectDir}`);
498
-
499
- // localOnly (set via `eventmodelers run --local`) forces this branch even when
500
- // credentials are present — it skips fetchPlatformConfig's network call to
501
- // ${baseUrl}/api/config and startPolling entirely, so the loop never reaches
502
- // out to the platform at all.
503
- if (localOnly || !hasCredentials(local)) {
504
- console.log(` mode: local-only (no platform sync)${localOnly ? ' — forced by --local' : ''}\n`);
505
- await ralphLoop(kitDir, local, onTask, onPlannedSlice);
506
- return;
507
- }
508
-
509
- const cfg = await retryOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
510
- console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
511
- console.log(` board sync: polling every ${POLL_INTERVAL_MS}ms (REST only — no realtime/table subscription)\n`);
512
-
513
- await Promise.all([
514
- startPolling(cfg, kitDir, { queueAllStatuses }),
515
- ralphLoop(kitDir, cfg, onTask, onPlannedSlice),
516
- ]);
517
- }
@@ -1,9 +0,0 @@
1
- {
2
- "name": "build-kit",
3
- "version": "1.0.0",
4
- "type": "module",
5
- "scripts": {
6
- "start": "node ralph.js"
7
- },
8
- "dependencies": {}
9
- }