@eventmodelers/cli 0.0.7 → 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.
Files changed (48) hide show
  1. package/README.md +42 -9
  2. package/cli.js +612 -149
  3. package/package.json +1 -1
  4. package/{stacks/node/templates → shared}/build-kit/README.md +3 -3
  5. package/{stacks/axon/templates → shared}/build-kit/lib/ralph.js +10 -3
  6. package/shared/build-kit/ralph-ollama.js +2 -2
  7. package/{stacks/node/templates → shared}/build-kit/ralph.sh +1 -1
  8. package/shared/build-kit/realtime-agent.js +1 -1
  9. package/{stacks/supabase/templates/.claude → shared}/skills/connect/SKILL.md +10 -5
  10. package/stacks/cratis-csharp/templates/build-kit/lib/AGENT.md +1 -0
  11. package/stacks/modeling-kit/templates/kit/README.md +79 -0
  12. package/stacks/modeling-kit/templates/kit/lib/ralph.js +9 -2
  13. package/stacks/modeling-kit/templates/kit/ralph.sh +1 -1
  14. package/stacks/node/templates/build-kit/lib/backend-prompt.md +1 -1
  15. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +1 -1
  16. package/stacks/axon/templates/.claude/skills/connect/SKILL.md +0 -178
  17. package/stacks/axon/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -611
  18. package/stacks/axon/templates/.claude/skills/update-slice-status/SKILL.md +0 -105
  19. package/stacks/axon/templates/build-kit/ralph.sh +0 -98
  20. package/stacks/cratis-csharp/templates/.claude/skills/connect/SKILL.md +0 -178
  21. package/stacks/cratis-csharp/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -609
  22. package/stacks/cratis-csharp/templates/.claude/skills/update-slice-status/SKILL.md +0 -105
  23. package/stacks/cratis-csharp/templates/build-kit/lib/agent.sh +0 -20
  24. package/stacks/cratis-csharp/templates/build-kit/lib/ralph.js +0 -302
  25. package/stacks/cratis-csharp/templates/build-kit/ralph-claude.js +0 -37
  26. package/stacks/cratis-csharp/templates/build-kit/ralph.sh +0 -98
  27. package/stacks/modeling-kit/templates/.claude/skills/connect/SKILL.md +0 -178
  28. package/stacks/modeling-kit/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -441
  29. package/stacks/modeling-kit/templates/.claude/skills/update-slice-status/SKILL.md +0 -110
  30. package/stacks/modeling-kit/templates/kit/lib/agent.sh +0 -20
  31. package/stacks/modeling-kit/templates/kit/lib/ollama-agent.js +0 -147
  32. package/stacks/modeling-kit/templates/kit/ralph-ollama.js +0 -38
  33. package/stacks/modeling-kit/templates/kit/realtime-agent.js +0 -18
  34. package/stacks/node/templates/.claude/skills/connect/SKILL.md +0 -178
  35. package/stacks/node/templates/build-kit/lib/agent.sh +0 -20
  36. package/stacks/node/templates/build-kit/lib/ralph.js +0 -369
  37. package/stacks/node/templates/build-kit/ralph-claude.js +0 -44
  38. package/stacks/supabase/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +0 -628
  39. package/stacks/supabase/templates/.claude/skills/update-slice-status/SKILL.md +0 -110
  40. package/stacks/supabase/templates/build-kit/README.md +0 -86
  41. package/stacks/supabase/templates/build-kit/lib/agent.sh +0 -20
  42. package/stacks/supabase/templates/build-kit/lib/ralph.js +0 -369
  43. package/stacks/supabase/templates/build-kit/ralph-claude.js +0 -44
  44. package/stacks/supabase/templates/build-kit/ralph.sh +0 -98
  45. /package/{stacks/axon/templates → shared}/build-kit/lib/agent.sh +0 -0
  46. /package/{stacks/axon/templates → shared}/build-kit/ralph-claude.js +0 -0
  47. /package/{stacks/node/templates/.claude → shared}/skills/learn-eventmodelers-api/SKILL.md +0 -0
  48. /package/{stacks/node/templates/.claude → shared}/skills/update-slice-status/SKILL.md +0 -0
@@ -1,110 +0,0 @@
1
- ---
2
- name: update-slice-status
3
- description: Update the status of a single slice on an eventmodelers board by changing the SLICE_BORDER node's sliceStatus field
4
- ---
5
-
6
- # Update Slice Status
7
-
8
- > **Before doing anything else**, invoke the `connect` skill to resolve `TOKEN`, `BOARD_ID`, `ORG_ID`, and `BASE_URL`. Do not proceed until the connect skill has completed.
9
-
10
- ---
11
-
12
- ## Step 1 — Parse arguments
13
-
14
- From `$ARGUMENTS`, extract:
15
-
16
- | Field | How to find it | Default |
17
- |-------|---------------|---------|
18
- | `sliceName` | the slice title to update (case-insensitive match) | **required** |
19
- | `newStatus` | the target status value | **required** |
20
-
21
- Valid status values (case-sensitive):
22
-
23
- | Value | Meaning |
24
- |-------|---------|
25
- | `Created` | Default — slice has been created but not started |
26
- | `Planned` | Work is planned |
27
- | `InProgress` | Work is actively in progress |
28
- | `Review` | Ready for review |
29
- | `Done` | Completed |
30
- | `Blocked` | Blocked by something |
31
- | `Assigned` | Assigned to someone |
32
- | `Informational` | Informational / reference slice |
33
-
34
- If `newStatus` is not one of these exact values, stop and tell the user the valid options.
35
-
36
- ---
37
-
38
- ## Step 2 — List all slices
39
-
40
- Fetch all slices on the board:
41
-
42
- ```bash
43
- curl -s \
44
- -H "x-token: <TOKEN>" \
45
- -H "x-board-id: <BOARD_ID>" \
46
- -H "x-user-id: update-slice-status-skill" \
47
- "<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/slicedata/slices"
48
- ```
49
-
50
- Response: `{ "slices": [{ "id": "<nodeId>", "title": "<title>", "status": "<status>" }] }`
51
-
52
- The `id` here is the `SLICE_BORDER` node ID — use it directly in Step 3.
53
-
54
- Find the slice whose `title` matches `sliceName` (case-insensitive). If no match is found, stop and list the available slice titles so the user can pick one.
55
-
56
- Save the matched slice as:
57
- - `SLICE_NODE_ID` — the node ID of the SLICE_BORDER
58
- - `CURRENT_STATUS` — the current status value
59
-
60
- ---
61
-
62
- ## Step 3 — Update the slice status
63
-
64
- Send a `node:changed` event to update the `sliceStatus` field in the SLICE_BORDER node's meta:
65
-
66
- ```bash
67
- curl -s -X POST "<BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/events" \
68
- -H "Content-Type: application/json" \
69
- -H "x-token: <TOKEN>" \
70
- -H "x-board-id: <BOARD_ID>" \
71
- -H "x-user-id: update-slice-status-skill" \
72
- -d '[{
73
- "id": "<new-random-uuid>",
74
- "eventType": "node:changed",
75
- "nodeId": "<SLICE_NODE_ID>",
76
- "boardId": "<BOARD_ID>",
77
- "timestamp": <Date.now()>,
78
- "changedAttributes": ["sliceStatus"],
79
- "meta": {
80
- "sliceStatus": "<newStatus>"
81
- }
82
- }]'
83
- ```
84
-
85
- Response: `{ "hashes": { "<eventId>": "<hash>" } }`
86
-
87
- ### If the API rejects the update because the slice is already in `newStatus`
88
-
89
- The API refuses to move a slice into a status it is already in — this is a deliberate concurrency guard so two agents racing to claim the same slice can't both succeed. If Step 3 fails with an error indicating the slice is already at `<newStatus>` (e.g. a `409`, or an error body mentioning "already"), this is **not a failure to surface as broken** — it means another agent already claimed or moved the slice first. Report this as a distinct `ALREADY_IN_STATUS` outcome (see Step 4) rather than a generic error, and do not retry the same update. Callers trying to claim a `Planned` slice for building should treat this as a signal to pick a different slice, not to stop.
90
-
91
- ---
92
-
93
- ## Step 4 — Report back
94
-
95
- Tell the user:
96
-
97
- - **Slice**: the title that was updated
98
- - **Previous status**: `CURRENT_STATUS`
99
- - **New status**: `newStatus`
100
- - **Node ID**: `SLICE_NODE_ID`
101
- - **Outcome**: `SUCCESS`, `ALREADY_IN_STATUS` (another agent got there first — see above), or `ERROR`
102
- - **Any errors**: raw API message if something failed for a reason other than `ALREADY_IN_STATUS`
103
-
104
- Example success output:
105
- ```
106
- Updated: "Order Placed" slice
107
- Before: InProgress
108
- After: Done
109
- Node ID: a1b2c3d4-…
110
- ```
@@ -1,86 +0,0 @@
1
- # .build-kit
2
-
3
- Ralph's runtime directory. Contains the agent loop, realtime subscription, prompts, and Claude skills.
4
-
5
- ## Quick start
6
-
7
- ```bash
8
- # Claude (default)
9
- node .build-kit/ralph-claude.js
10
-
11
- # Local Ollama model — run `ollama serve` first
12
- OLLAMA_MODEL=qwen3:8b node .build-kit/ralph-ollama.js
13
-
14
- # Custom project directory (defaults to the parent of .build-kit)
15
- node .build-kit/ralph-claude.js /path/to/project
16
- ```
17
-
18
- ## Files
19
-
20
- **Entry points** (top level):
21
-
22
- | File | Purpose |
23
- |------|---------|
24
- | `ralph-claude.js` | Runs the full loop using Claude Code as the executor |
25
- | `ralph-ollama.js` | Runs the full loop using a local Ollama model |
26
- | `ralph.sh` | Shell-based loop — alternative to the JS entry points |
27
- | `realtime-agent.js` | Standalone realtime agent — only needed to run it in a separate terminal |
28
-
29
- **Internals** (`lib/`):
30
-
31
- | File | Purpose |
32
- |------|---------|
33
- | `lib/ralph.js` | Shared library — realtime agent + loop logic; imported by the entry points |
34
- | `lib/ollama-agent.js` | Ollama executor — called by `ralph-ollama.js`, can also run manually |
35
- | `lib/agent.sh` | Thin shell wrapper around `claude` — called by `ralph.sh` |
36
- | `lib/prompt.md` | Phase 1 prompt: tells Claude how to load a slice from the board |
37
- | `lib/backend-prompt.md` | Phase 2 prompt: tells Claude how to build a planned slice |
38
- | `lib/AGENT.md` | Agent instructions included in Claude's context |
39
-
40
- ## How it works
41
-
42
- **Phase 1** — triggered when `tasks.json` has entries:
43
- - The realtime agent writes a task to `tasks.json` each time a `slice:changed` event arrives from the board
44
- - The loop picks it up and runs Claude (or Ollama) with `prompt.md`
45
- - Claude loads the slice data and updates `.slices/`
46
-
47
- **Phase 2** — triggered when any file in `.slices/` contains `"status": "Planned"`:
48
- - The loop runs Claude with `backend-prompt.md`
49
- - Claude implements the slice in the project
50
- - Phase 2 is Claude-only; Ollama mode skips it (ollama-agent handles its own queue)
51
-
52
- Both phases run in a continuous loop with a 3-second idle sleep. The realtime agent runs concurrently in the same process.
53
-
54
- ## Running the realtime agent separately
55
-
56
- If you want the board subscription in one terminal and the Claude loop in another:
57
-
58
- ```bash
59
- # Terminal 1 — realtime agent only
60
- node .build-kit/realtime-agent.js
61
-
62
- # Terminal 2 — loop only (poll tasks.json without the realtime subscription)
63
- .build-kit/ralph.sh
64
- ```
65
-
66
- ## Ollama configuration
67
-
68
- ```bash
69
- OLLAMA_MODEL=qwen3:8b # model to use (default: qwen3:8b)
70
- OLLAMA_URL=http://host:11434 # Ollama server URL (default: http://localhost:11434)
71
- ```
72
-
73
- ## Config
74
-
75
- Credentials are stored in `.build-kit/.eventmodelers/config.json` (written by `ralph-li install`):
76
-
77
- ```json
78
- {
79
- "organizationId": "...",
80
- "boardId": "...",
81
- "token": "...",
82
- "baseUrl": "https://api.eventmodelers.ai"
83
- }
84
- ```
85
-
86
- Claude skills live in `.build-kit/.claude/skills/` and are available inside any Claude Code session started from `.build-kit/`.
@@ -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,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-supabase`);
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
- });