@eventmodelers/cli 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -9
- package/cli.js +614 -149
- package/package.json +1 -1
- package/{stacks/node/templates → shared}/build-kit/README.md +3 -3
- package/{stacks/axon/templates → shared}/build-kit/lib/ralph.js +10 -3
- package/shared/build-kit/ralph-ollama.js +2 -2
- package/{stacks/node/templates → shared}/build-kit/ralph.sh +1 -1
- package/shared/build-kit/realtime-agent.js +1 -1
- package/{stacks/supabase/templates/.claude → shared}/skills/connect/SKILL.md +10 -5
- package/stacks/cratis-csharp/templates/build-kit/lib/AGENT.md +1 -0
- package/stacks/modeling-kit/templates/kit/README.md +79 -0
- package/stacks/modeling-kit/templates/kit/lib/ralph.js +9 -2
- package/stacks/modeling-kit/templates/kit/ralph.sh +1 -1
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/axon/templates/.claude/skills/connect/SKILL.md +0 -178
- package/stacks/axon/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -611
- package/stacks/axon/templates/.claude/skills/update-slice-status/SKILL.md +0 -105
- package/stacks/axon/templates/build-kit/ralph.sh +0 -98
- package/stacks/cratis-csharp/templates/.claude/skills/connect/SKILL.md +0 -178
- package/stacks/cratis-csharp/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -609
- package/stacks/cratis-csharp/templates/.claude/skills/update-slice-status/SKILL.md +0 -105
- package/stacks/cratis-csharp/templates/build-kit/lib/agent.sh +0 -20
- package/stacks/cratis-csharp/templates/build-kit/lib/ralph.js +0 -302
- package/stacks/cratis-csharp/templates/build-kit/ralph-claude.js +0 -37
- package/stacks/cratis-csharp/templates/build-kit/ralph.sh +0 -98
- package/stacks/modeling-kit/templates/.claude/skills/connect/SKILL.md +0 -178
- package/stacks/modeling-kit/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -441
- package/stacks/modeling-kit/templates/.claude/skills/update-slice-status/SKILL.md +0 -110
- package/stacks/modeling-kit/templates/kit/lib/agent.sh +0 -20
- package/stacks/modeling-kit/templates/kit/lib/ollama-agent.js +0 -147
- package/stacks/modeling-kit/templates/kit/ralph-ollama.js +0 -38
- package/stacks/modeling-kit/templates/kit/realtime-agent.js +0 -18
- package/stacks/node/templates/.claude/skills/connect/SKILL.md +0 -178
- package/stacks/node/templates/build-kit/lib/agent.sh +0 -20
- package/stacks/node/templates/build-kit/lib/ralph.js +0 -369
- package/stacks/node/templates/build-kit/ralph-claude.js +0 -44
- package/stacks/supabase/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -628
- package/stacks/supabase/templates/.claude/skills/update-slice-status/SKILL.md +0 -110
- package/stacks/supabase/templates/build-kit/README.md +0 -86
- package/stacks/supabase/templates/build-kit/lib/agent.sh +0 -20
- package/stacks/supabase/templates/build-kit/lib/ralph.js +0 -369
- package/stacks/supabase/templates/build-kit/ralph-claude.js +0 -44
- package/stacks/supabase/templates/build-kit/ralph.sh +0 -98
- /package/{stacks/axon/templates → shared}/build-kit/lib/agent.sh +0 -0
- /package/{stacks/axon/templates → shared}/build-kit/ralph-claude.js +0 -0
- /package/{stacks/node/templates/.claude → shared}/skills/learn-eventmodelers-api/SKILL.md +0 -0
- /package/{stacks/node/templates/.claude → shared}/skills/update-slice-status/SKILL.md +0 -0
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
// Common runtime for the ralph loop + realtime agent.
|
|
2
|
-
// Not meant to be run directly — use ralph-claude.js or ralph-ollama.js.
|
|
3
|
-
//
|
|
4
|
-
// startRalph({ kitDir, projectDir, onTask, onPlannedSlice })
|
|
5
|
-
// onTask(prompt) — called when tasks.json has entries
|
|
6
|
-
// onPlannedSlice(prompt) — called when .slices/ has a "Planned" entry (omit to skip)
|
|
7
|
-
|
|
8
|
-
import { createClient } from '@supabase/supabase-js';
|
|
9
|
-
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
|
10
|
-
import { join, dirname } from 'path';
|
|
11
|
-
import { randomUUID } from 'crypto';
|
|
12
|
-
|
|
13
|
-
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
class HttpError extends Error {
|
|
16
|
-
constructor(status, body) {
|
|
17
|
-
super(`HTTP ${status}: ${body}`);
|
|
18
|
-
this.status = status;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function fetchJSON(url, options) {
|
|
23
|
-
const res = await fetch(url, options);
|
|
24
|
-
if (!res.ok) throw new HttpError(res.status, await res.text());
|
|
25
|
-
return res.json();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function retryOn401(label, fn, maxRetries = 3) {
|
|
29
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
30
|
-
try {
|
|
31
|
-
return await fn();
|
|
32
|
-
} catch (err) {
|
|
33
|
-
if (err instanceof HttpError && err.status === 401) {
|
|
34
|
-
if (attempt < maxRetries) {
|
|
35
|
-
console.warn(`[agent] ${label} — 401, retrying (${attempt}/${maxRetries})...`);
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
console.error(`[agent] ${label} — 401 after ${maxRetries} retries, shutting down`);
|
|
39
|
-
process.exit(1);
|
|
40
|
-
}
|
|
41
|
-
throw err;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ── Config ────────────────────────────────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
// Config is resolved by walking from the kit dir up through every ancestor
|
|
49
|
-
// directory's .eventmodelers/config.json, merging fields as we go — a value
|
|
50
|
-
// set by a closer (more specific) directory always wins over a farther one.
|
|
51
|
-
// The walk stops as soon as the merged config has full connection credentials
|
|
52
|
-
// (see hasCredentials); anthropicBaseUrl/model are picked up opportunistically
|
|
53
|
-
// along the way but never force the walk to continue further up.
|
|
54
|
-
function* configCandidates(kitDir) {
|
|
55
|
-
yield join(kitDir, '.eventmodelers', 'config.json');
|
|
56
|
-
let dir = dirname(kitDir);
|
|
57
|
-
while (true) {
|
|
58
|
-
yield join(dir, '.eventmodelers', 'config.json');
|
|
59
|
-
const parent = dirname(dir);
|
|
60
|
-
if (parent === dir) return;
|
|
61
|
-
dir = parent;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function loadLocalConfig(kitDir) {
|
|
66
|
-
const merged = {};
|
|
67
|
-
const sources = [];
|
|
68
|
-
|
|
69
|
-
for (const candidate of configCandidates(kitDir)) {
|
|
70
|
-
if (!existsSync(candidate)) continue;
|
|
71
|
-
let cfg;
|
|
72
|
-
try {
|
|
73
|
-
cfg = JSON.parse(readFileSync(candidate, 'utf-8'));
|
|
74
|
-
} catch {
|
|
75
|
-
console.warn(`[ralph] Skipping invalid config at ${candidate}`);
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
for (const [key, value] of Object.entries(cfg)) {
|
|
79
|
-
if (merged[key] === undefined) merged[key] = value;
|
|
80
|
-
}
|
|
81
|
-
sources.push(candidate);
|
|
82
|
-
if (hasCredentials(merged)) break;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (process.env.BASE_URL) merged.baseUrl = process.env.BASE_URL;
|
|
86
|
-
|
|
87
|
-
if (sources.length > 1) {
|
|
88
|
-
console.log(`[ralph] Merged config from: ${sources.join(', ')}`);
|
|
89
|
-
} else if (sources.length === 1 && sources[0] !== join(kitDir, '.eventmodelers', 'config.json')) {
|
|
90
|
-
console.log(`[ralph] Using credentials from ${sources[0]}`);
|
|
91
|
-
} else if (sources.length === 0) {
|
|
92
|
-
console.warn(`[ralph] Note: no .eventmodelers/config.json found — platform sync disabled.`);
|
|
93
|
-
console.warn(` To enable board sync, follow: https://app.eventmodelers.ai/documentation#build-node`);
|
|
94
|
-
console.warn(` Code generation from local slice definitions will still run.`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return merged;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function hasCredentials(cfg) {
|
|
101
|
-
return !!(cfg.token && cfg.organizationId && cfg.boardId && cfg.baseUrl);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function fetchPlatformConfig(local) {
|
|
105
|
-
const remote = await fetchJSON(`${local.baseUrl}/api/config`, {
|
|
106
|
-
headers: { 'x-token': local.token },
|
|
107
|
-
});
|
|
108
|
-
return { ...local, ...remote };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ── Realtime agent ────────────────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
async function getRealtimeToken(cfg) {
|
|
114
|
-
const { token } = await fetchJSON(
|
|
115
|
-
`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`,
|
|
116
|
-
{ headers: { 'x-token': cfg.token } },
|
|
117
|
-
);
|
|
118
|
-
return token;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function slugify(str) {
|
|
122
|
-
return str.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function fetchAndPersistSlices(cfg, kitDir) {
|
|
126
|
-
const url = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`;
|
|
127
|
-
const { slices } = await fetchJSON(url, {
|
|
128
|
-
headers: { 'x-token': cfg.token, 'x-board-id': cfg.boardId },
|
|
129
|
-
});
|
|
130
|
-
const slicesDir = join(kitDir, '.slices');
|
|
131
|
-
mkdirSync(slicesDir, { recursive: true });
|
|
132
|
-
|
|
133
|
-
// Group by context slug
|
|
134
|
-
const contexts = {};
|
|
135
|
-
for (const slice of slices) {
|
|
136
|
-
const contextSlug = slice.contextName ? slugify(slice.contextName) : 'default';
|
|
137
|
-
if (!contexts[contextSlug]) contexts[contextSlug] = { name: slice.contextName || 'default', slices: [] };
|
|
138
|
-
contexts[contextSlug].slices.push(slice);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// current_context.json is STICKY. We work within ONE context at a time and must
|
|
142
|
-
// not auto-jump to another context just because it happens to have planned work.
|
|
143
|
-
// Keep the existing context if it still exists; only seed it when absent or stale.
|
|
144
|
-
const ctxPath = join(slicesDir, 'current_context.json');
|
|
145
|
-
let activeCtx = null;
|
|
146
|
-
if (existsSync(ctxPath)) {
|
|
147
|
-
try { activeCtx = JSON.parse(readFileSync(ctxPath, 'utf-8')).name; } catch {}
|
|
148
|
-
}
|
|
149
|
-
if (!activeCtx || !contexts[activeCtx]) {
|
|
150
|
-
// First run (or the current context disappeared): seed with a context that
|
|
151
|
-
// has planned work, else the first one. This is the ONLY place we choose it.
|
|
152
|
-
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status || '').toLowerCase() === 'planned'));
|
|
153
|
-
activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
|
|
154
|
-
writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Write per-context index.json and per-slice slice.json
|
|
158
|
-
for (const [contextSlug, { slices: ctxSlices }] of Object.entries(contexts)) {
|
|
159
|
-
const contextDir = join(slicesDir, contextSlug);
|
|
160
|
-
mkdirSync(contextDir, { recursive: true });
|
|
161
|
-
|
|
162
|
-
const indexSlices = ctxSlices.map((s, i) => {
|
|
163
|
-
const folder = (s.title ?? s.id).replaceAll(' ', '').toLowerCase();
|
|
164
|
-
return {
|
|
165
|
-
id: s.id,
|
|
166
|
-
slice: s.title,
|
|
167
|
-
index: i,
|
|
168
|
-
contextName: s.contextName || contextSlug,
|
|
169
|
-
contextSlug,
|
|
170
|
-
folder,
|
|
171
|
-
status: s.status,
|
|
172
|
-
definition: { id: s.id, title: s.title, status: s.status },
|
|
173
|
-
};
|
|
174
|
-
});
|
|
175
|
-
writeFileSync(join(contextDir, 'index.json'), JSON.stringify({ slices: indexSlices }, null, 2), 'utf-8');
|
|
176
|
-
|
|
177
|
-
for (const slice of ctxSlices) {
|
|
178
|
-
const folder = (slice.title ?? slice.id).replaceAll(' ', '').toLowerCase();
|
|
179
|
-
const sliceDir = join(contextDir, folder);
|
|
180
|
-
mkdirSync(sliceDir, { recursive: true });
|
|
181
|
-
writeFileSync(join(sliceDir, 'slice.json'), JSON.stringify(slice, null, 2), 'utf-8');
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
console.log(`[agent] Persisted ${slices.length} slice(s)`);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
async function writeTask(payload, kitDir) {
|
|
189
|
-
const tasksPath = join(kitDir, 'tasks.json');
|
|
190
|
-
const existing = existsSync(tasksPath) ? JSON.parse(readFileSync(tasksPath, 'utf-8')) : [];
|
|
191
|
-
const filtered = existing.filter(t => t.payload?.sliceId !== payload.sliceId);
|
|
192
|
-
const task = { id: randomUUID(), createdAt: new Date().toISOString(), payload };
|
|
193
|
-
filtered.push(task);
|
|
194
|
-
writeFileSync(tasksPath, JSON.stringify(filtered, null, 2), 'utf-8');
|
|
195
|
-
console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function startRealtimeAgent(cfg, kitDir) {
|
|
199
|
-
let realtimeToken = await retryOn401('getRealtimeToken', () => getRealtimeToken(cfg));
|
|
200
|
-
|
|
201
|
-
await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
202
|
-
console.error('[agent] Initial slice fetch error:', err),
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
const supabase = createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, {
|
|
206
|
-
realtime: { params: { apikey: cfg.supabaseAnonKey } },
|
|
207
|
-
});
|
|
208
|
-
await supabase.realtime.setAuth(realtimeToken);
|
|
209
|
-
|
|
210
|
-
const channelName = `board:${cfg.boardId}-slicechanged`;
|
|
211
|
-
|
|
212
|
-
supabase
|
|
213
|
-
.channel(channelName, { config: { private: true } })
|
|
214
|
-
.on('broadcast', { event: 'message' }, (msg) => {
|
|
215
|
-
if (msg.payload === 'Exit') {
|
|
216
|
-
console.log('[agent] Received "Exit" — shutting down');
|
|
217
|
-
process.exit(0);
|
|
218
|
-
}
|
|
219
|
-
})
|
|
220
|
-
.on('broadcast', { event: 'slice:changed' }, async (msg) => {
|
|
221
|
-
const payload = msg.payload;
|
|
222
|
-
console.log(`[agent] slice:changed — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
223
|
-
await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
224
|
-
console.error('[agent] Slice persist error:', err),
|
|
225
|
-
);
|
|
226
|
-
// Planned slices are handled by onPlannedSlice directly — no task needed
|
|
227
|
-
if ((payload.sliceStatus || '').toLowerCase() !== 'planned') {
|
|
228
|
-
await writeTask(payload, kitDir).catch((err) => console.error('[agent] writeTask error:', err));
|
|
229
|
-
}
|
|
230
|
-
})
|
|
231
|
-
.subscribe((status) => console.log(`[agent] Channel "${channelName}": ${status}`));
|
|
232
|
-
|
|
233
|
-
setInterval(async () => {
|
|
234
|
-
try {
|
|
235
|
-
realtimeToken = await retryOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
|
|
236
|
-
supabase.realtime.setAuth(realtimeToken);
|
|
237
|
-
console.log('[agent] Token refreshed');
|
|
238
|
-
} catch (err) {
|
|
239
|
-
console.error('[agent] Token refresh failed:', err);
|
|
240
|
-
}
|
|
241
|
-
}, 10 * 60 * 1000);
|
|
242
|
-
|
|
243
|
-
const ping = async () => {
|
|
244
|
-
try {
|
|
245
|
-
const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
|
|
246
|
-
method: 'POST',
|
|
247
|
-
headers: { Authorization: `Bearer ${realtimeToken}`, 'Content-Type': 'application/json' },
|
|
248
|
-
body: JSON.stringify({ token: cfg.token }),
|
|
249
|
-
});
|
|
250
|
-
if (!res.ok) console.error(`[agent] Ping failed: ${res.status}`);
|
|
251
|
-
} catch (err) {
|
|
252
|
-
console.error('[agent] Ping error:', err);
|
|
253
|
-
}
|
|
254
|
-
};
|
|
255
|
-
await ping();
|
|
256
|
-
setInterval(ping, 30_000);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ── Ralph loop ────────────────────────────────────────────────────────────────
|
|
260
|
-
|
|
261
|
-
function hasPendingTasks(kitDir) {
|
|
262
|
-
const tasksPath = join(kitDir, 'tasks.json');
|
|
263
|
-
if (!existsSync(tasksPath)) return false;
|
|
264
|
-
try {
|
|
265
|
-
const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
|
|
266
|
-
return Array.isArray(tasks) && tasks.length > 0;
|
|
267
|
-
} catch {
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function readCurrentContext(kitDir) {
|
|
273
|
-
const ctxPath = join(kitDir, '.slices', 'current_context.json');
|
|
274
|
-
if (!existsSync(ctxPath)) return null;
|
|
275
|
-
try { return JSON.parse(readFileSync(ctxPath, 'utf-8')).name || null; } catch { return null; }
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Returns the first Planned slice IN THE CURRENT CONTEXT ONLY. If the current
|
|
279
|
-
// context has no planned work, returns null so the loop waits — it must NEVER
|
|
280
|
-
// cross into another context to find something to build.
|
|
281
|
-
function getFirstPlannedSliceTitle(kitDir) {
|
|
282
|
-
const currentCtx = readCurrentContext(kitDir);
|
|
283
|
-
if (!currentCtx) return null;
|
|
284
|
-
const indexPath = join(kitDir, '.slices', currentCtx, 'index.json');
|
|
285
|
-
if (!existsSync(indexPath)) return null;
|
|
286
|
-
try {
|
|
287
|
-
const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
288
|
-
const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === 'planned');
|
|
289
|
-
if (planned) return planned.slice || planned.id || null;
|
|
290
|
-
} catch {}
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async function runWithRetry(label, fn) {
|
|
295
|
-
while (true) {
|
|
296
|
-
try {
|
|
297
|
-
console.log(`[ralph] ${label}`);
|
|
298
|
-
await fn();
|
|
299
|
-
return;
|
|
300
|
-
} catch (err) {
|
|
301
|
-
console.error(`[ralph] Error — retrying in 60s:`, err.message);
|
|
302
|
-
await new Promise((r) => setTimeout(r, 60_000));
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice) {
|
|
308
|
-
const promptFile = join(kitDir, 'lib', 'prompt.md');
|
|
309
|
-
const backendPromptFile = join(kitDir, 'lib', 'backend-prompt.md');
|
|
310
|
-
const credentialed = hasCredentials(cfg);
|
|
311
|
-
let lastIdleCtx;
|
|
312
|
-
|
|
313
|
-
while (true) {
|
|
314
|
-
let didWork = false;
|
|
315
|
-
|
|
316
|
-
if (credentialed && hasPendingTasks(kitDir)) {
|
|
317
|
-
const prompt = readFileSync(promptFile, 'utf-8');
|
|
318
|
-
await runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
|
|
319
|
-
await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
320
|
-
didWork = true;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const plannedTitle = onPlannedSlice && getFirstPlannedSliceTitle(kitDir);
|
|
324
|
-
if (plannedTitle) {
|
|
325
|
-
const prompt = readFileSync(backendPromptFile, 'utf-8');
|
|
326
|
-
await runWithRetry(`onPlannedSlice: building slice "${plannedTitle}"...`, () => onPlannedSlice(prompt));
|
|
327
|
-
console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
328
|
-
if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
329
|
-
didWork = true;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (!didWork) {
|
|
333
|
-
// No planned work in the current context — wait, do NOT switch contexts.
|
|
334
|
-
const ctx = readCurrentContext(kitDir);
|
|
335
|
-
if (ctx !== lastIdleCtx) {
|
|
336
|
-
console.log(`[ralph] No planned slices in current context "${ctx}" — waiting. Switch context on the board to continue.`);
|
|
337
|
-
lastIdleCtx = ctx;
|
|
338
|
-
}
|
|
339
|
-
await new Promise((r) => setTimeout(r, 10_000));
|
|
340
|
-
} else {
|
|
341
|
-
lastIdleCtx = undefined;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// ── Public API ────────────────────────────────────────────────────────────────
|
|
347
|
-
|
|
348
|
-
export { loadLocalConfig, fetchPlatformConfig, retryOn401, startRealtimeAgent };
|
|
349
|
-
|
|
350
|
-
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice }) {
|
|
351
|
-
const local = loadLocalConfig(kitDir);
|
|
352
|
-
|
|
353
|
-
console.log(`Ralph — kit: ${kitDir}`);
|
|
354
|
-
console.log(` project: ${projectDir}`);
|
|
355
|
-
|
|
356
|
-
if (!hasCredentials(local)) {
|
|
357
|
-
console.log(` mode: local-only (no platform sync)\n`);
|
|
358
|
-
await ralphLoop(kitDir, local, onTask, onPlannedSlice);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const cfg = await retryOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
|
|
363
|
-
console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
|
|
364
|
-
|
|
365
|
-
await Promise.all([
|
|
366
|
-
startRealtimeAgent(cfg, kitDir),
|
|
367
|
-
ralphLoop(kitDir, cfg, onTask, onPlannedSlice),
|
|
368
|
-
]);
|
|
369
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Ralph loop + realtime agent using Claude Code as the executor.
|
|
3
|
-
// Usage: node ralph-claude.js [project_dir]
|
|
4
|
-
|
|
5
|
-
import { startRalph, loadLocalConfig } from './lib/ralph.js';
|
|
6
|
-
import { spawn } from 'child_process';
|
|
7
|
-
import { dirname, resolve } from 'path';
|
|
8
|
-
import { fileURLToPath } from 'url';
|
|
9
|
-
|
|
10
|
-
const kitDir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
|
|
12
|
-
|
|
13
|
-
const cfg = loadLocalConfig(kitDir);
|
|
14
|
-
const inlineHeader = cfg.boardId
|
|
15
|
-
? `board=${cfg.boardId} token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
|
|
16
|
-
: '';
|
|
17
|
-
|
|
18
|
-
const claudeArgs = ['--dangerously-skip-permissions'];
|
|
19
|
-
if (cfg.model) claudeArgs.push('--model', cfg.model);
|
|
20
|
-
const claudeEnv = cfg.anthropicBaseUrl
|
|
21
|
-
? { ...process.env, ANTHROPIC_BASE_URL: cfg.anthropicBaseUrl }
|
|
22
|
-
: process.env;
|
|
23
|
-
|
|
24
|
-
function runClaude(prompt) {
|
|
25
|
-
return new Promise((resolve, reject) => {
|
|
26
|
-
const proc = spawn('claude', [...claudeArgs, '-p', inlineHeader + prompt], {
|
|
27
|
-
cwd: projectDir,
|
|
28
|
-
stdio: 'inherit',
|
|
29
|
-
env: claudeEnv,
|
|
30
|
-
});
|
|
31
|
-
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`Claude exited ${code}`))));
|
|
32
|
-
proc.on('error', reject);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
startRalph({
|
|
37
|
-
kitDir,
|
|
38
|
-
projectDir,
|
|
39
|
-
onTask: runClaude,
|
|
40
|
-
onPlannedSlice: runClaude,
|
|
41
|
-
}).catch((err) => {
|
|
42
|
-
console.error('[ralph] Fatal:', err);
|
|
43
|
-
process.exit(1);
|
|
44
|
-
});
|