@eventmodelers/cli 0.0.6 → 0.0.8
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 +633 -175
- 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,20 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Runs the AI agent with the given prompt in the project directory.
|
|
3
|
-
# Called by ralph.sh with cwd already set to the project root.
|
|
4
|
-
# Usage: ./agent.sh "<prompt>"
|
|
5
|
-
|
|
6
|
-
set -euo pipefail
|
|
7
|
-
|
|
8
|
-
PROMPT="${1:-}"
|
|
9
|
-
if [[ -z "$PROMPT" ]]; then
|
|
10
|
-
echo "ERROR: No prompt provided"
|
|
11
|
-
exit 1
|
|
12
|
-
fi
|
|
13
|
-
|
|
14
|
-
claude --dangerously-skip-permissions -p "$PROMPT"
|
|
15
|
-
|
|
16
|
-
# --- To use a local Ollama model instead, comment out the line above
|
|
17
|
-
# and uncomment the block below. Run `ollama serve` first.
|
|
18
|
-
#
|
|
19
|
-
# MODEL="${OLLAMA_MODEL:-qwen3.5:9b}"
|
|
20
|
-
# node "$(dirname "$0")/ollama-agent.js" "$MODEL"
|
|
@@ -1,302 +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 } 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
|
-
function loadLocalConfig(kitDir) {
|
|
49
|
-
const configPath = join(kitDir, '.eventmodelers', 'config.json');
|
|
50
|
-
if (!existsSync(configPath)) {
|
|
51
|
-
console.warn(`[ralph] Note: no .eventmodelers/config.json found — platform sync disabled.`);
|
|
52
|
-
console.warn(` To enable board sync, follow: https://app.eventmodelers.ai/documentation#build-cratis-csharp`);
|
|
53
|
-
console.warn(` Code generation from local slice definitions will still run.`);
|
|
54
|
-
return {};
|
|
55
|
-
}
|
|
56
|
-
const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
57
|
-
if (process.env.BASE_URL) cfg.baseUrl = process.env.BASE_URL;
|
|
58
|
-
return cfg;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function hasCredentials(cfg) {
|
|
62
|
-
return !!(cfg.token && cfg.organizationId && cfg.boardId && cfg.baseUrl);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function fetchPlatformConfig(local) {
|
|
66
|
-
const remote = await fetchJSON(`${local.baseUrl}/api/config`, {
|
|
67
|
-
headers: { 'x-token': local.token },
|
|
68
|
-
});
|
|
69
|
-
return { ...local, ...remote };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ── Realtime agent ────────────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
async function getRealtimeToken(cfg) {
|
|
75
|
-
const { token } = await fetchJSON(
|
|
76
|
-
`${cfg.baseUrl}/api/org/${cfg.organizationId}/prompts/realtime-token`,
|
|
77
|
-
{ headers: { 'x-token': cfg.token } },
|
|
78
|
-
);
|
|
79
|
-
return token;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function slugify(str) {
|
|
83
|
-
return str.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function fetchAndPersistSlices(cfg, kitDir) {
|
|
87
|
-
const url = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`;
|
|
88
|
-
const { slices } = await fetchJSON(url, {
|
|
89
|
-
headers: { 'x-token': cfg.token, 'x-board-id': cfg.boardId },
|
|
90
|
-
});
|
|
91
|
-
const slicesDir = join(kitDir, '.slices');
|
|
92
|
-
mkdirSync(slicesDir, { recursive: true });
|
|
93
|
-
|
|
94
|
-
// Group by context slug
|
|
95
|
-
const contexts = {};
|
|
96
|
-
for (const slice of slices) {
|
|
97
|
-
const contextSlug = slice.contextName ? slugify(slice.contextName) : 'default';
|
|
98
|
-
if (!contexts[contextSlug]) contexts[contextSlug] = { name: slice.contextName || 'default', slices: [] };
|
|
99
|
-
contexts[contextSlug].slices.push(slice);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Write current_context.json pointing to the context with planned work (or first)
|
|
103
|
-
const ctxPath = join(slicesDir, 'current_context.json');
|
|
104
|
-
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status || '').toLowerCase() === 'planned'));
|
|
105
|
-
const activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
|
|
106
|
-
writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
|
|
107
|
-
|
|
108
|
-
// Write per-context index.json and per-slice slice.json
|
|
109
|
-
for (const [contextSlug, { slices: ctxSlices }] of Object.entries(contexts)) {
|
|
110
|
-
const contextDir = join(slicesDir, contextSlug);
|
|
111
|
-
mkdirSync(contextDir, { recursive: true });
|
|
112
|
-
|
|
113
|
-
const indexSlices = ctxSlices.map((s, i) => {
|
|
114
|
-
const folder = (s.title ?? s.id).replaceAll(' ', '').toLowerCase();
|
|
115
|
-
return {
|
|
116
|
-
id: s.id,
|
|
117
|
-
slice: s.title,
|
|
118
|
-
index: i,
|
|
119
|
-
contextName: s.contextName || contextSlug,
|
|
120
|
-
contextSlug,
|
|
121
|
-
folder,
|
|
122
|
-
status: s.status,
|
|
123
|
-
definition: { id: s.id, title: s.title, status: s.status },
|
|
124
|
-
};
|
|
125
|
-
});
|
|
126
|
-
writeFileSync(join(contextDir, 'index.json'), JSON.stringify({ slices: indexSlices }, null, 2), 'utf-8');
|
|
127
|
-
|
|
128
|
-
for (const slice of ctxSlices) {
|
|
129
|
-
const folder = (slice.title ?? slice.id).replaceAll(' ', '').toLowerCase();
|
|
130
|
-
const sliceDir = join(contextDir, folder);
|
|
131
|
-
mkdirSync(sliceDir, { recursive: true });
|
|
132
|
-
writeFileSync(join(sliceDir, 'slice.json'), JSON.stringify(slice, null, 2), 'utf-8');
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
console.log(`[agent] Persisted ${slices.length} slice(s)`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function writeTask(payload, kitDir) {
|
|
140
|
-
const tasksPath = join(kitDir, 'tasks.json');
|
|
141
|
-
const existing = existsSync(tasksPath) ? JSON.parse(readFileSync(tasksPath, 'utf-8')) : [];
|
|
142
|
-
const filtered = existing.filter(t => t.payload?.sliceId !== payload.sliceId);
|
|
143
|
-
const task = { id: randomUUID(), createdAt: new Date().toISOString(), payload };
|
|
144
|
-
filtered.push(task);
|
|
145
|
-
writeFileSync(tasksPath, JSON.stringify(filtered, null, 2), 'utf-8');
|
|
146
|
-
console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function startRealtimeAgent(cfg, kitDir) {
|
|
150
|
-
let realtimeToken = await retryOn401('getRealtimeToken', () => getRealtimeToken(cfg));
|
|
151
|
-
|
|
152
|
-
await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
153
|
-
console.error('[agent] Initial slice fetch error:', err),
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
const supabase = createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, {
|
|
157
|
-
realtime: { params: { apikey: cfg.supabaseAnonKey } },
|
|
158
|
-
});
|
|
159
|
-
await supabase.realtime.setAuth(realtimeToken);
|
|
160
|
-
|
|
161
|
-
const channelName = `board:${cfg.boardId}-slicechanged`;
|
|
162
|
-
|
|
163
|
-
supabase
|
|
164
|
-
.channel(channelName, { config: { private: true } })
|
|
165
|
-
.on('broadcast', { event: 'message' }, (msg) => {
|
|
166
|
-
if (msg.payload === 'Exit') {
|
|
167
|
-
console.log('[agent] Received "Exit" — shutting down');
|
|
168
|
-
process.exit(0);
|
|
169
|
-
}
|
|
170
|
-
})
|
|
171
|
-
.on('broadcast', { event: 'slice:changed' }, async (msg) => {
|
|
172
|
-
const payload = msg.payload;
|
|
173
|
-
console.log(`[agent] slice:changed — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
174
|
-
await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
175
|
-
console.error('[agent] Slice persist error:', err),
|
|
176
|
-
);
|
|
177
|
-
// Planned slices are handled by onPlannedSlice directly — no task needed
|
|
178
|
-
if ((payload.sliceStatus || '').toLowerCase() !== 'planned') {
|
|
179
|
-
await writeTask(payload, kitDir).catch((err) => console.error('[agent] writeTask error:', err));
|
|
180
|
-
}
|
|
181
|
-
})
|
|
182
|
-
.subscribe((status) => console.log(`[agent] Channel "${channelName}": ${status}`));
|
|
183
|
-
|
|
184
|
-
setInterval(async () => {
|
|
185
|
-
try {
|
|
186
|
-
realtimeToken = await retryOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
|
|
187
|
-
supabase.realtime.setAuth(realtimeToken);
|
|
188
|
-
console.log('[agent] Token refreshed');
|
|
189
|
-
} catch (err) {
|
|
190
|
-
console.error('[agent] Token refresh failed:', err);
|
|
191
|
-
}
|
|
192
|
-
}, 10 * 60 * 1000);
|
|
193
|
-
|
|
194
|
-
const ping = async () => {
|
|
195
|
-
try {
|
|
196
|
-
const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
|
|
197
|
-
method: 'POST',
|
|
198
|
-
headers: { Authorization: `Bearer ${realtimeToken}`, 'Content-Type': 'application/json' },
|
|
199
|
-
body: JSON.stringify({ token: cfg.token }),
|
|
200
|
-
});
|
|
201
|
-
if (!res.ok) console.error(`[agent] Ping failed: ${res.status}`);
|
|
202
|
-
} catch (err) {
|
|
203
|
-
console.error('[agent] Ping error:', err);
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
await ping();
|
|
207
|
-
setInterval(ping, 30_000);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ── Ralph loop ────────────────────────────────────────────────────────────────
|
|
211
|
-
|
|
212
|
-
function hasPendingTasks(kitDir) {
|
|
213
|
-
const tasksPath = join(kitDir, 'tasks.json');
|
|
214
|
-
if (!existsSync(tasksPath)) return false;
|
|
215
|
-
try {
|
|
216
|
-
const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
|
|
217
|
-
return Array.isArray(tasks) && tasks.length > 0;
|
|
218
|
-
} catch {
|
|
219
|
-
return false;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function getFirstPlannedSliceTitle(kitDir) {
|
|
224
|
-
const slicesDir = join(kitDir, '.slices');
|
|
225
|
-
if (!existsSync(slicesDir)) return null;
|
|
226
|
-
for (const entry of readdirSync(slicesDir)) {
|
|
227
|
-
const indexPath = join(slicesDir, entry, 'index.json');
|
|
228
|
-
if (!existsSync(indexPath)) continue;
|
|
229
|
-
try {
|
|
230
|
-
const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
231
|
-
const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === 'planned');
|
|
232
|
-
if (planned) return planned.slice || planned.id || null;
|
|
233
|
-
} catch {}
|
|
234
|
-
}
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
async function runWithRetry(label, fn) {
|
|
239
|
-
while (true) {
|
|
240
|
-
try {
|
|
241
|
-
console.log(`[ralph] ${label}`);
|
|
242
|
-
await fn();
|
|
243
|
-
return;
|
|
244
|
-
} catch (err) {
|
|
245
|
-
console.error(`[ralph] Error — retrying in 60s:`, err.message);
|
|
246
|
-
await new Promise((r) => setTimeout(r, 60_000));
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice) {
|
|
252
|
-
const promptFile = join(kitDir, 'lib', 'prompt.md');
|
|
253
|
-
const backendPromptFile = join(kitDir, 'lib', 'backend-prompt.md');
|
|
254
|
-
const credentialed = hasCredentials(cfg);
|
|
255
|
-
|
|
256
|
-
while (true) {
|
|
257
|
-
let didWork = false;
|
|
258
|
-
|
|
259
|
-
if (credentialed && hasPendingTasks(kitDir)) {
|
|
260
|
-
const prompt = readFileSync(promptFile, 'utf-8');
|
|
261
|
-
await runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
|
|
262
|
-
await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
263
|
-
didWork = true;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const plannedTitle = onPlannedSlice && getFirstPlannedSliceTitle(kitDir);
|
|
267
|
-
if (plannedTitle) {
|
|
268
|
-
const prompt = readFileSync(backendPromptFile, 'utf-8');
|
|
269
|
-
await runWithRetry(`onPlannedSlice: building slice "${plannedTitle}"...`, () => onPlannedSlice(prompt));
|
|
270
|
-
console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
271
|
-
if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
272
|
-
didWork = true;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (!didWork) await new Promise((r) => setTimeout(r, 10_000));
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// ── Public API ────────────────────────────────────────────────────────────────
|
|
280
|
-
|
|
281
|
-
export { loadLocalConfig, fetchPlatformConfig, retryOn401, startRealtimeAgent };
|
|
282
|
-
|
|
283
|
-
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice }) {
|
|
284
|
-
const local = loadLocalConfig(kitDir);
|
|
285
|
-
|
|
286
|
-
console.log(`Ralph — kit: ${kitDir}`);
|
|
287
|
-
console.log(` project: ${projectDir}`);
|
|
288
|
-
|
|
289
|
-
if (!hasCredentials(local)) {
|
|
290
|
-
console.log(` mode: local-only (no platform sync)\n`);
|
|
291
|
-
await ralphLoop(kitDir, local, onTask, onPlannedSlice);
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const cfg = await retryOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
|
|
296
|
-
console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
|
|
297
|
-
|
|
298
|
-
await Promise.all([
|
|
299
|
-
startRealtimeAgent(cfg, kitDir),
|
|
300
|
-
ralphLoop(kitDir, cfg, onTask, onPlannedSlice),
|
|
301
|
-
]);
|
|
302
|
-
}
|
|
@@ -1,37 +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
|
-
function runClaude(prompt) {
|
|
19
|
-
return new Promise((resolve, reject) => {
|
|
20
|
-
const proc = spawn('claude', ['--dangerously-skip-permissions', '-p', inlineHeader + prompt], {
|
|
21
|
-
cwd: projectDir,
|
|
22
|
-
stdio: 'inherit',
|
|
23
|
-
});
|
|
24
|
-
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`Claude exited ${code}`))));
|
|
25
|
-
proc.on('error', reject);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
startRalph({
|
|
30
|
-
kitDir,
|
|
31
|
-
projectDir,
|
|
32
|
-
onTask: runClaude,
|
|
33
|
-
onPlannedSlice: runClaude,
|
|
34
|
-
}).catch((err) => {
|
|
35
|
-
console.error('[ralph] Fatal:', err);
|
|
36
|
-
process.exit(1);
|
|
37
|
-
});
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Ralph agent loop — two independent loops, each triggered by their own condition
|
|
3
|
-
#
|
|
4
|
-
# onTask: tasks.json has entries → load slice from board, update .build-kit/.slices/
|
|
5
|
-
# onPlannedSlice: .build-kit/.slices/ has a "Planned" slice → build it
|
|
6
|
-
#
|
|
7
|
-
# The loops are NOT causally linked — either can trigger on its own.
|
|
8
|
-
#
|
|
9
|
-
# Usage: ./ralph.sh [iterations] [project_dir]
|
|
10
|
-
# iterations — number of loop cycles to run; 0 or omitted means run forever
|
|
11
|
-
# project_dir — path to the project root; defaults to the parent of .build-kit
|
|
12
|
-
|
|
13
|
-
set -euo pipefail
|
|
14
|
-
|
|
15
|
-
KIT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
16
|
-
ITERATIONS="${1:-0}"
|
|
17
|
-
PROJECT_DIR="${2:-"$KIT_DIR/.."}"
|
|
18
|
-
TASKS_FILE="$KIT_DIR/tasks.json"
|
|
19
|
-
PROMPT_FILE="$KIT_DIR/lib/prompt.md"
|
|
20
|
-
BACKEND_PROMPT_FILE="$KIT_DIR/lib/backend-prompt.md"
|
|
21
|
-
AGENT_SCRIPT="$KIT_DIR/lib/agent.sh"
|
|
22
|
-
|
|
23
|
-
HAS_CREDENTIALS=true
|
|
24
|
-
if [[ ! -f "$KIT_DIR/.eventmodelers/config.json" ]]; then
|
|
25
|
-
echo "[ralph] Note: no .eventmodelers/config.json found — platform sync disabled." >&2
|
|
26
|
-
echo " To enable board sync, follow: https://app.eventmodelers.ai/documentation#build-cratis-csharp" >&2
|
|
27
|
-
echo " Code generation from local slice definitions will still run." >&2
|
|
28
|
-
HAS_CREDENTIALS=false
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
echo "Ralph — kit: $KIT_DIR project: $PROJECT_DIR"
|
|
32
|
-
|
|
33
|
-
# Returns 0 if tasks.json has at least one task
|
|
34
|
-
has_pending_tasks() {
|
|
35
|
-
[[ -f "$TASKS_FILE" ]] || return 1
|
|
36
|
-
local content
|
|
37
|
-
content=$(cat "$TASKS_FILE")
|
|
38
|
-
[[ "$content" != "[]" && -n "$content" ]]
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
# Returns 0 if any JSON under .build-kit/.slices/ contains a "Planned" status
|
|
42
|
-
has_planned_slices() {
|
|
43
|
-
grep -rqi '"status"[[:space:]]*:[[:space:]]*"planned"' "$KIT_DIR/.slices/" --include='index.json' 2>/dev/null
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
# Returns the title of the first "Planned" slice, or empty string
|
|
47
|
-
get_planned_slice_title() {
|
|
48
|
-
for index_file in "$KIT_DIR/.slices/"*/index.json; do
|
|
49
|
-
[[ -f "$index_file" ]] || continue
|
|
50
|
-
local title
|
|
51
|
-
title=$(node -e "
|
|
52
|
-
try {
|
|
53
|
-
const d = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf-8'));
|
|
54
|
-
const s = (d.slices||[]).find(s => (s.status||'').toLowerCase() === 'planned');
|
|
55
|
-
if (s) process.stdout.write(s.slice || s.id || '');
|
|
56
|
-
} catch(e) {}
|
|
57
|
-
" "$index_file" 2>/dev/null)
|
|
58
|
-
if [[ -n "$title" ]]; then
|
|
59
|
-
echo "$title"
|
|
60
|
-
return
|
|
61
|
-
fi
|
|
62
|
-
done
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
# Runs agent.sh with the given prompt; retries on non-zero exit
|
|
66
|
-
run_agent() {
|
|
67
|
-
local label="$1"
|
|
68
|
-
local prompt="$2"
|
|
69
|
-
while true; do
|
|
70
|
-
echo "[$(date -u +%H:%M:%S)] $label"
|
|
71
|
-
(cd "$PROJECT_DIR" && bash "$AGENT_SCRIPT" "$prompt") 2>&1 && return 0
|
|
72
|
-
echo "[$(date -u +%H:%M:%S)] Agent error — retrying in 60s..."
|
|
73
|
-
sleep 60
|
|
74
|
-
done
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
cycle=0
|
|
78
|
-
while [[ "$ITERATIONS" -eq 0 || "$cycle" -lt "$ITERATIONS" ]]; do
|
|
79
|
-
ran_something=false
|
|
80
|
-
|
|
81
|
-
if [[ "$HAS_CREDENTIALS" == true ]] && has_pending_tasks; then
|
|
82
|
-
run_agent "onTask: loading slice from board..." "$(cat "$PROMPT_FILE")"
|
|
83
|
-
ran_something=true
|
|
84
|
-
fi
|
|
85
|
-
|
|
86
|
-
if has_planned_slices; then
|
|
87
|
-
slice_title=$(get_planned_slice_title)
|
|
88
|
-
run_agent "onPlannedSlice: building \"$slice_title\"..." "$(cat "$BACKEND_PROMPT_FILE")"
|
|
89
|
-
echo "[$(date -u +%H:%M:%S)] Slice \"$slice_title\" build complete — waiting for next slice"
|
|
90
|
-
ran_something=true
|
|
91
|
-
fi
|
|
92
|
-
|
|
93
|
-
if [[ "$ran_something" == false ]]; then
|
|
94
|
-
sleep 3
|
|
95
|
-
fi
|
|
96
|
-
|
|
97
|
-
(( cycle++ )) || true
|
|
98
|
-
done
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: connect
|
|
3
|
-
description: Resolve eventmodelers connection config (token, boardId, baseUrl) from inline params or .eventmodelers/config.json — ask the user for missing values, persist them, and add the file to .gitignore. All other skills invoke this first.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Connect — Resolve Eventmodelers Config
|
|
7
|
-
|
|
8
|
-
**Every other skill invokes this skill first** before making any API calls. Do not proceed past this skill until all four values (`TOKEN`, `BOARD_ID`, `ORG_ID`, `BASE_URL`) are resolved.
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## What this skill produces
|
|
13
|
-
|
|
14
|
-
After running, the following variables are available for the rest of the session:
|
|
15
|
-
|
|
16
|
-
| Variable | Header sent to API | Description |
|
|
17
|
-
|----------|--------------------|-------------|
|
|
18
|
-
| `TOKEN` | `x-token` | API token UUID |
|
|
19
|
-
| `BOARD_ID` | `x-board-id` | Target board UUID |
|
|
20
|
-
| `ORG_ID` | — | Organization UUID (used in all board-scoped URLs) |
|
|
21
|
-
| `BASE_URL` | — | Base URL, e.g. `http://localhost:3000` |
|
|
22
|
-
|
|
23
|
-
Every API call in every skill must include these headers:
|
|
24
|
-
```
|
|
25
|
-
x-token: <TOKEN>
|
|
26
|
-
x-board-id: <BOARD_ID>
|
|
27
|
-
x-user-id: <skill-name> ← set by each skill individually
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
All board-scoped URLs follow the pattern: `<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/...`
|
|
31
|
-
|
|
32
|
-
---
|
|
33
|
-
|
|
34
|
-
## Step 0 — Check for inline parameters
|
|
35
|
-
|
|
36
|
-
Before reading the config file, scan the prompt/arguments that invoked this skill for inline overrides. Supported formats:
|
|
37
|
-
|
|
38
|
-
| Pattern | Example |
|
|
39
|
-
|---------|---------|
|
|
40
|
-
| `board=<uuid>` | `board=05cda19d-d5b8-4b51-ae88-c72f2611548a` |
|
|
41
|
-
| `token=<uuid>` | `token=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` |
|
|
42
|
-
| `org=<uuid>` | `org=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` |
|
|
43
|
-
| `baseUrl=<url>` | `baseUrl=http://localhost:3000` |
|
|
44
|
-
|
|
45
|
-
If an inline `board=<uuid>` is found, use it as `BOARD_ID` — **it takes priority over the config file**. Same for `token`, `org`, and `baseUrl`. Record which values came from inline params so they are not overwritten in Step 3.
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## Step 1 — Read config file
|
|
50
|
-
|
|
51
|
-
Search for `.eventmodelers/config.json` starting from the current working directory and walking up through all parent directories. This is the same file a build-kit installed in this project reads and writes, so credentials only need to be entered once per project:
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
dir="$PWD"
|
|
55
|
-
config_file=""
|
|
56
|
-
while [ "$dir" != "/" ]; do
|
|
57
|
-
if [ -f "$dir/.eventmodelers/config.json" ]; then
|
|
58
|
-
config_file="$dir/.eventmodelers/config.json"
|
|
59
|
-
break
|
|
60
|
-
fi
|
|
61
|
-
dir="$(dirname "$dir")"
|
|
62
|
-
done
|
|
63
|
-
[ -n "$config_file" ] && cat "$config_file"
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
If a file is found (at any level), note its path and extract any values **not already set by Step 0**:
|
|
67
|
-
- `token` → `TOKEN`
|
|
68
|
-
- `boardId` → `BOARD_ID`
|
|
69
|
-
- `organizationId` or `orgId` → `ORG_ID` (accept either field name)
|
|
70
|
-
- `baseUrl` → `BASE_URL` (default: `https://api.eventmodelers.ai` if missing)
|
|
71
|
-
|
|
72
|
-
Resolution priority: **inline param > config file > ask user**
|
|
73
|
-
|
|
74
|
-
If all four are present (from any source), skip to **Step 4 — Verify**.
|
|
75
|
-
|
|
76
|
-
---
|
|
77
|
-
|
|
78
|
-
## Step 2 — Ask for missing values
|
|
79
|
-
|
|
80
|
-
If after Steps 0 and 1 any required field is still missing, **ask the user one question first**:
|
|
81
|
-
|
|
82
|
-
> "Do you have a config from the eventmodelers accounts page? (yes / no)"
|
|
83
|
-
|
|
84
|
-
**If the user answers yes:**
|
|
85
|
-
Stop asking questions. Show this hint and wait for them to paste:
|
|
86
|
-
|
|
87
|
-
> "Great — please paste your config from https://app.eventmodelers.ai/account here."
|
|
88
|
-
|
|
89
|
-
When they paste a JSON object, parse it immediately — accept both `orgId` and `organizationId` as the organization field — apply all values, and proceed directly to Step 3.
|
|
90
|
-
|
|
91
|
-
**If the user answers no** (or pastes only a partial config), ask for each still-missing field one at a time, in this order: `token`, then `boardId`, then `orgId`. Wait for the answer before asking the next.
|
|
92
|
-
|
|
93
|
-
| Field | What to ask |
|
|
94
|
-
|-------|--------------------------------------------------------------------------------------|
|
|
95
|
-
| `token` | "Please provide your eventmodelers API token (a UUID from your workspace settings)." |
|
|
96
|
-
| `boardId` | "Please provide the board ID you want to work with (the UUID from the board URL)." |
|
|
97
|
-
| `orgId` | "Please provide your organization ID (the UUID from your organization settings)." |
|
|
98
|
-
| `baseUrl` | Do **not** ask — default to `https://api.eventmodelers.ai` silently. |
|
|
99
|
-
|
|
100
|
-
Where to find the token: users generate API tokens in their workspace settings at the eventmodelers platform. The token is shown only once at creation time. It is a UUID and must belong to the same organization as the board.
|
|
101
|
-
|
|
102
|
-
---
|
|
103
|
-
|
|
104
|
-
## Step 3 — Persist config
|
|
105
|
-
|
|
106
|
-
Once all values are collected, write the config file to `.eventmodelers/config.json` at the project root (the same file the CLI installer writes to, and the one a build-kit installed alongside this modeling-kit reads too). If Step 1 found the config in a parent directory instead of here, write back to that same path rather than creating a second copy. When writing, merge with any existing config — do **not** overwrite fields that were provided as inline params with values from a previous config (the inline param is the user's explicit intent for this session, but the persisted value should reflect the most recently user-supplied value):
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
mkdir -p .eventmodelers
|
|
110
|
-
cat > .eventmodelers/config.json << 'EOF'
|
|
111
|
-
{
|
|
112
|
-
"token": "<TOKEN>",
|
|
113
|
-
"boardId": "<BOARD_ID>",
|
|
114
|
-
"organizationId": "<ORG_ID>",
|
|
115
|
-
"baseUrl": "<BASE_URL>"
|
|
116
|
-
}
|
|
117
|
-
EOF
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
Then ensure `.eventmodelers/config.json` is in `.gitignore`. Check whether it is already present:
|
|
121
|
-
|
|
122
|
-
```bash
|
|
123
|
-
grep -q ".eventmodelers/config.json" .gitignore 2>/dev/null || echo "MISSING"
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
If `MISSING`, append it:
|
|
127
|
-
|
|
128
|
-
```bash
|
|
129
|
-
echo ".eventmodelers/config.json" >> .gitignore
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Tell the user: `"Config saved to .eventmodelers/config.json and added to .gitignore."`
|
|
133
|
-
|
|
134
|
-
---
|
|
135
|
-
|
|
136
|
-
## Step 4 — Verify
|
|
137
|
-
|
|
138
|
-
Confirm the token and board are valid with a lightweight call:
|
|
139
|
-
|
|
140
|
-
```bash
|
|
141
|
-
curl -s -o /dev/null -w "%{http_code}" \
|
|
142
|
-
-H "x-token: <TOKEN>" \
|
|
143
|
-
-H "x-board-id: <BOARD_ID>" \
|
|
144
|
-
-H "x-user-id: connect-skill" \
|
|
145
|
-
"<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes?type=CHAPTER"
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
| Response | Action |
|
|
149
|
-
|----------|--------|
|
|
150
|
-
| `200` | Config is valid. Print one line: `"Connected — board <BOARD_ID>"` and return. |
|
|
151
|
-
| `401` | Token is invalid or missing. Tell the user and re-run from Step 2, clearing `token`. |
|
|
152
|
-
| `403` | Token organization does not match board. Tell the user to check that the token was issued for the correct workspace. Re-run from Step 2 for both fields. |
|
|
153
|
-
| `404` | Board not found. Tell the user and re-run from Step 2, clearing `boardId`. |
|
|
154
|
-
| Any other | Print the status code and raw response. Ask the user how to proceed. |
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
## Config file format
|
|
159
|
-
|
|
160
|
-
`.eventmodelers/config.json`:
|
|
161
|
-
```json
|
|
162
|
-
{
|
|
163
|
-
"token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
164
|
-
"boardId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
165
|
-
"organizationId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
166
|
-
"baseUrl": "http://localhost:3000"
|
|
167
|
-
}
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
The `token` field is a secret. It is never logged or shown after initial confirmation.
|
|
171
|
-
|
|
172
|
-
---
|
|
173
|
-
|
|
174
|
-
## Security notes
|
|
175
|
-
|
|
176
|
-
- The config file is workspace-local and gitignored — never commit it.
|
|
177
|
-
- The token grants write access to all boards in its organization — treat it like a password.
|
|
178
|
-
- If a skill receives a `401` or `403` mid-session, re-invoke this skill to refresh the config before retrying.
|