@eventmodelers/cli 1.0.163 → 2.0.2
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 +48 -36
- package/RELEASE_NOTES.md +20 -2
- package/cli.js +774 -247
- package/lib/adapters/spec-kitty-adapter.js +3 -7
- package/lib/build-kits.js +23 -0
- package/lib/global-agent-id.js +45 -0
- package/lib/modeling-local-ai.js +4 -2
- package/lib/scaffold-model.js +71 -0
- package/lib/scaffold.js +224 -0
- package/lib/supervise.js +60 -0
- package/lib/ui-baseline.js +123 -0
- package/package.json +1 -1
- package/shared/build-kit/README.md +4 -19
- package/shared/build-kit/lib/agent-lock.js +75 -0
- package/shared/build-kit/lib/agent-name.js +27 -0
- package/shared/build-kit/lib/event-prune.js +58 -0
- package/shared/build-kit/lib/events-view.js +134 -0
- package/shared/build-kit/lib/local-ai-agent.js +14 -26
- package/shared/build-kit/lib/mcp-register.js +17 -1
- package/shared/build-kit/lib/prompt-journal.js +191 -0
- package/shared/build-kit/lib/prompt-replay.js +35 -0
- package/shared/build-kit/lib/ralph.js +218 -169
- package/shared/build-kit/lib/slice-curation.js +40 -0
- package/shared/build-kit/lib/slice-events.js +93 -0
- package/shared/build-kit/lib/slice-git.js +86 -0
- package/shared/build-kit/lib/slice-learnings.js +197 -0
- package/shared/build-kit/lib/slice-projection.js +101 -0
- package/shared/build-kit/lib/slice-run.js +181 -0
- package/shared/build-kit/lib/slice-state.js +184 -0
- package/shared/build-kit/lib/slice-tasks.js +35 -0
- package/shared/build-kit/ralph-claude.js +4 -2
- package/shared/build-kit/ralph-exec.js +4 -2
- package/shared/build-kit/ralph-local-ai.js +5 -3
- package/shared/scaffold/LICENSE-em-scaffold +21 -0
- package/shared/scaffold/supabase/generate.mjs +290 -0
- package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
- package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
- package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
- package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
- package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
- package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
- package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
- package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
- package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
- package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
- package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
- package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
- package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
- package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
- package/shared/skills/learn-eventmodelers-api/SKILL.md +17 -4
- package/shared/skills/request-feedback/SKILL.md +1 -2
- package/shared/skills/ui-contract/SKILL.md +7 -5
- package/shared/skills/update-slice-status/SKILL.md +1 -1
- package/stacks/axon/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/axon/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/blank/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
- package/stacks/blank/templates/build-kit/lib/prompt.md +33 -42
- package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
- package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
- package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
- package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
- package/stacks/classic/templates/build-kit/CLAUDE.md +9 -8
- package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
- package/stacks/classic/templates/build-kit/lib/prompt.md +33 -42
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +7 -7
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
- package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +18 -28
- package/stacks/modeling-kit/templates/kit/README.md +1 -1
- package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +7 -7
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
- package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
- package/stacks/react/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +25 -42
- package/stacks/react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/react/templates/build-kit/lib/prompt.md +47 -43
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +80 -8
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +66 -13
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +8 -7
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +25 -42
- package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +48 -44
- package/stacks/umadb/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/umadb/templates/build-kit/lib/prompt.md +32 -41
- package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
- package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
- package/shared/build-kit/lib/agent.sh +0 -20
- package/shared/build-kit/ralph.sh +0 -111
- package/shared/build-kit/realtime-agent.js +0 -20
- package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
- package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
- package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
- package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
- package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
- package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
// Not meant to be run directly — use ralph-claude.js or ralph-local-ai.js.
|
|
3
3
|
//
|
|
4
4
|
// startRalph({ kitDir, projectDir, onTask, onPlannedSlice })
|
|
5
|
-
// onTask(prompt) — called
|
|
5
|
+
// onTask(prompt) — called once per queued slice change (the oldest first; see lib/slice-tasks.js)
|
|
6
6
|
// onPlannedSlice(prompt, slice) — called when .slices/ has a "Planned" entry (omit to skip);
|
|
7
|
-
// `slice` ({sliceId, sliceTitle, context, ticketNumber, boardId, attempt}) lets a runner record what building it cost.
|
|
7
|
+
// `slice` ({sliceId, sliceTitle, context, ticketNumber, assignee, boardId, attempt}) lets a runner record what building it cost.
|
|
8
8
|
// May resolve with the agent's final reply — quoted in the note if the slice ends up auto-blocked.
|
|
9
9
|
|
|
10
|
-
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync
|
|
11
|
-
import { join, dirname } from 'path';
|
|
10
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
|
11
|
+
import { join, dirname, basename } from 'path';
|
|
12
12
|
import { homedir } from 'os';
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
13
14
|
import { randomUUID } from 'crypto';
|
|
14
15
|
import { createRealtimeAdapter, keepSubscribed } from './adapters/realtime-adapter.js';
|
|
15
16
|
import { backoffMs, sleep } from './backoff.js';
|
|
17
|
+
import { adoptAgentEdits, observeBoard, projectStatus, statusOf } from './slice-projection.js';
|
|
18
|
+
import { fold, statusKey } from './slice-state.js';
|
|
19
|
+
import { readEvents } from './slice-events.js';
|
|
20
|
+
import { claimSlice, recordEvent, settleSlice, settleLeftoverClaims } from './slice-run.js';
|
|
21
|
+
import { isTreeClean } from './slice-git.js';
|
|
22
|
+
import { syncLearnings } from './slice-learnings.js';
|
|
23
|
+
import { curateIfDue } from './slice-curation.js';
|
|
24
|
+
import { queueTask, workNextTask } from './slice-tasks.js';
|
|
25
|
+
import { defaultAgentName, withInstanceSuffix } from './agent-name.js';
|
|
26
|
+
import { acquireAgentId } from './agent-lock.js';
|
|
16
27
|
import { redactConsole } from './redact.js';
|
|
17
28
|
|
|
18
29
|
// Backoff for calls to the platform that failed for a reason on its side (startup).
|
|
@@ -148,11 +159,11 @@ function hasCredentials(cfg) {
|
|
|
148
159
|
}
|
|
149
160
|
|
|
150
161
|
// Distinguishes this agent process from any other agent pinging the same
|
|
151
|
-
// token/board — e.g. a build-kit and a
|
|
162
|
+
// token/board — e.g. a build-kit and a modeling-kit install in the same project
|
|
152
163
|
// share one root config.json, and without a per-agent id both would upsert the
|
|
153
164
|
// same alive row and race each other. The platform already keys the alive-ping
|
|
154
165
|
// on the (agent_type, agent_id) pair, so one shared file works: agentIds is
|
|
155
|
-
// namespaced by agentType (BUILD/
|
|
166
|
+
// namespaced by agentType (BUILD/MODELING/...) inside the project ROOT
|
|
156
167
|
// .eventmodelers/config.json — the same file credentials already live in —
|
|
157
168
|
// instead of each kit dir keeping its own separate config.json. Falls back to
|
|
158
169
|
// a pre-existing kit-local agentId (older installs, before this consolidation)
|
|
@@ -191,11 +202,21 @@ function ensureAgentId(kitDir, agentType) {
|
|
|
191
202
|
// ensureAgentId above / RALPH_AGENT_ID). The heartbeat says this agent is alive; the header says
|
|
192
203
|
// which calls are its, so its board writes are attributed to it and a prompt the user addressed
|
|
193
204
|
// to one preferred agent is only ever claimed by that agent.
|
|
205
|
+
function agentIdOf(cfg) {
|
|
206
|
+
return cfg?.agentId || process.env.RALPH_AGENT_ID || process.env.EVENTMODELERS_AGENT_ID || '';
|
|
207
|
+
}
|
|
208
|
+
|
|
194
209
|
function agentHeaders(cfg) {
|
|
195
|
-
const agentId = cfg
|
|
210
|
+
const agentId = agentIdOf(cfg);
|
|
196
211
|
return agentId ? { 'x-agent-id': agentId } : {};
|
|
197
212
|
}
|
|
198
213
|
|
|
214
|
+
// What every slice-state event is stamped with: this agent, its type (this loop builds unless told otherwise) and the
|
|
215
|
+
// organization and board it runs against.
|
|
216
|
+
function eventMeta(cfg) {
|
|
217
|
+
return { agent_id: agentIdOf(cfg), agent_type: cfg?.agentType ?? 'BUILD', organization_id: cfg?.organizationId, board_id: cfg?.boardId };
|
|
218
|
+
}
|
|
219
|
+
|
|
199
220
|
async function fetchPlatformConfig(local) {
|
|
200
221
|
const remote = await fetchJSON(`${local.baseUrl}/api/config`, {
|
|
201
222
|
headers: { 'x-token': local.token, ...agentHeaders(local) },
|
|
@@ -220,7 +241,7 @@ function slugify(str) {
|
|
|
220
241
|
return str.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
221
242
|
}
|
|
222
243
|
|
|
223
|
-
async function fetchAndPersistSlices(cfg, kitDir) {
|
|
244
|
+
export async function fetchAndPersistSlices(cfg, kitDir) {
|
|
224
245
|
const url = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`;
|
|
225
246
|
const { slices } = await fetchJSON(url, {
|
|
226
247
|
headers: { 'x-token': cfg.token, ...agentHeaders(cfg) },
|
|
@@ -228,6 +249,12 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
228
249
|
const slicesDir = join(kitDir, '.slices');
|
|
229
250
|
mkdirSync(slicesDir, { recursive: true });
|
|
230
251
|
|
|
252
|
+
// The board's status is an observation, not the truth for the local files: while a build holds a claim
|
|
253
|
+
// a refresh that still shows InProgress must not revert the outcome the agent recorded. The status
|
|
254
|
+
// written below is the log's (statusOf), which falls back to the board's for slices it knows nothing of.
|
|
255
|
+
const meta = eventMeta(cfg);
|
|
256
|
+
const state = observeBoard(kitDir, slices, meta, adoptAgentEdits(kitDir, fold(readEvents(kitDir)), meta));
|
|
257
|
+
|
|
231
258
|
// Group by context slug
|
|
232
259
|
const contexts = {};
|
|
233
260
|
for (const slice of slices) {
|
|
@@ -246,8 +273,8 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
246
273
|
if (!activeCtx || !contexts[activeCtx]) {
|
|
247
274
|
// First run (or the current context disappeared): seed with a context that
|
|
248
275
|
// has planned work, else the first one. This is the ONLY place we choose it.
|
|
249
|
-
const pickup = readPickupStatus(kitDir)
|
|
250
|
-
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status
|
|
276
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
277
|
+
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => statusKey(s.status) === pickup));
|
|
251
278
|
activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
|
|
252
279
|
writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
|
|
253
280
|
}
|
|
@@ -256,7 +283,7 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
256
283
|
//
|
|
257
284
|
// This endpoint (`/slicedata/slices`) is the CHEAP summary one — `{ id, title, status }`
|
|
258
285
|
// only, no commands/events/specifications/codeGen prompts. Its whole job here is to keep
|
|
259
|
-
// `status` fresh so
|
|
286
|
+
// `status` fresh so the loop's pickup (getFirstPlannedSlice) sees live transitions; it must
|
|
260
287
|
// NEVER clobber the richer slice.json a full fetch (`/load-slice`, `eventmodelers fetch`,
|
|
261
288
|
// `eventmodelers listen`) already wrote for the same slice. So every write below merges
|
|
262
289
|
// onto whatever's already on disk — spreading the existing object first, the fresh summary
|
|
@@ -280,8 +307,8 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
280
307
|
contextName: s.contextName || contextSlug,
|
|
281
308
|
contextSlug,
|
|
282
309
|
folder,
|
|
283
|
-
status: s
|
|
284
|
-
definition: { ...existing?.definition, id: s.id, title: s.title, status: s
|
|
310
|
+
status: statusOf(state, s),
|
|
311
|
+
definition: { ...existing?.definition, id: s.id, title: s.title, status: statusOf(state, s) },
|
|
285
312
|
};
|
|
286
313
|
});
|
|
287
314
|
writeFileSync(indexPath, JSON.stringify({ slices: indexSlices }, null, 2), 'utf-8');
|
|
@@ -292,12 +319,15 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
292
319
|
mkdirSync(sliceDir, { recursive: true });
|
|
293
320
|
const sliceJsonPath = join(sliceDir, 'slice.json');
|
|
294
321
|
const existingSlice = existsSync(sliceJsonPath) ? JSON.parse(readFileSync(sliceJsonPath, 'utf-8')) : {};
|
|
295
|
-
// The summary omits ticketNumber when the slice has none, so a cleared
|
|
296
|
-
// here too — otherwise the old one survives the merge and lands on the next build
|
|
297
|
-
writeFileSync(sliceJsonPath, JSON.stringify({ ...existingSlice, ...slice, ticketNumber: slice.ticketNumber ?? null }, null, 2), 'utf-8');
|
|
322
|
+
// The summary omits ticketNumber and assignee when the slice has none, so a cleared one must be cleared
|
|
323
|
+
// here too — otherwise the old one survives the merge and lands on the next build.
|
|
324
|
+
writeFileSync(sliceJsonPath, JSON.stringify({ ...existingSlice, ...slice, status: statusOf(state, slice), ticketNumber: slice.ticketNumber ?? null, assignee: slice.assignee ?? null }, null, 2), 'utf-8');
|
|
298
325
|
}
|
|
299
326
|
}
|
|
300
327
|
|
|
328
|
+
// Blocked notes (reason, time) follow the log too; a no-op when nothing changed.
|
|
329
|
+
projectStatus(kitDir, state);
|
|
330
|
+
|
|
301
331
|
console.log(`[agent] Persisted ${slices.length} slice(s)`);
|
|
302
332
|
}
|
|
303
333
|
|
|
@@ -332,65 +362,7 @@ async function fetchFullSlice(cfg, kitDir, sliceId) {
|
|
|
332
362
|
}
|
|
333
363
|
}
|
|
334
364
|
|
|
335
|
-
|
|
336
|
-
// the task it just handled (as each stack's prompt.md tells it to). An unlocked read-modify-
|
|
337
|
-
// write from both sides at once lost whichever update landed first. So the two never overlap:
|
|
338
|
-
// while an onTask turn runs (see inTurn), a change is held here instead — latest per slice —
|
|
339
|
-
// and written the moment the turn ends. Every write goes through a temp file and a rename,
|
|
340
|
-
// so a reader never sees a half-written file either.
|
|
341
|
-
let heldTasks = null; // sliceId → payload while an onTask turn runs, null otherwise
|
|
342
|
-
|
|
343
|
-
function writeTasksAtomically(tasksPath, tasks) {
|
|
344
|
-
const tmp = `${tasksPath}.${process.pid}.tmp`;
|
|
345
|
-
writeFileSync(tmp, JSON.stringify(tasks, null, 2), 'utf-8');
|
|
346
|
-
renameSync(tmp, tasksPath);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function readTasks(tasksPath) {
|
|
350
|
-
if (!existsSync(tasksPath)) return [];
|
|
351
|
-
try {
|
|
352
|
-
const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
|
|
353
|
-
return Array.isArray(tasks) ? tasks : [];
|
|
354
|
-
} catch {
|
|
355
|
-
return []; // the agent left it unparseable — start over rather than wedge the queue
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function appendTasks(kitDir, payloads) {
|
|
360
|
-
const tasksPath = join(kitDir, 'tasks.json');
|
|
361
|
-
const replaced = new Set(payloads.map((p) => p.sliceId));
|
|
362
|
-
const tasks = readTasks(tasksPath).filter((t) => !replaced.has(t.payload?.sliceId));
|
|
363
|
-
for (const payload of payloads) tasks.push({ id: randomUUID(), createdAt: new Date().toISOString(), payload });
|
|
364
|
-
writeTasksAtomically(tasksPath, tasks);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function writeTask(payload, kitDir) {
|
|
368
|
-
if (heldTasks) {
|
|
369
|
-
heldTasks.set(payload.sliceId, payload);
|
|
370
|
-
console.log(`[agent] Task held until the current turn ends — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
appendTasks(kitDir, [payload]);
|
|
374
|
-
console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Runs one agent turn that may rewrite tasks.json, keeping this process's own writes out of
|
|
378
|
-
// its way; whatever arrived meanwhile is written as soon as it's over, success or not.
|
|
379
|
-
async function inTurn(kitDir, fn) {
|
|
380
|
-
heldTasks = new Map();
|
|
381
|
-
try {
|
|
382
|
-
return await fn();
|
|
383
|
-
} finally {
|
|
384
|
-
const payloads = [...heldTasks.values()];
|
|
385
|
-
heldTasks = null;
|
|
386
|
-
if (payloads.length > 0) {
|
|
387
|
-
appendTasks(kitDir, payloads);
|
|
388
|
-
console.log(`[agent] Wrote ${payloads.length} task(s) held during the turn`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
|
|
365
|
+
async function handleSliceChanged(payload, cfg, kitDir) {
|
|
394
366
|
console.log(`[agent] slice:changed — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
395
367
|
// This agent's own status writes (claim → InProgress, → Done, → Blocked) come straight back
|
|
396
368
|
// as slice:changed. Queuing them only buys a turn in which the agent reads its own echo and
|
|
@@ -405,19 +377,21 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
|
|
|
405
377
|
await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
406
378
|
console.error('[agent] Slice persist error:', err),
|
|
407
379
|
);
|
|
408
|
-
// Slices in the kit's pickup status (Planned, or
|
|
380
|
+
// Slices in the kit's pickup status (Planned, or Waiting for UI kits) are handled by
|
|
409
381
|
// onPlannedSlice directly — no task needed.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
382
|
+
if (statusKey(payload.sliceStatus) !== statusKey(readPickupStatus(kitDir))) {
|
|
383
|
+
try {
|
|
384
|
+
queueTask({ kitDir, payload, meta: eventMeta(cfg) });
|
|
385
|
+
console.log(`[agent] Task queued — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error('[agent] queueTask error:', err);
|
|
388
|
+
}
|
|
415
389
|
}
|
|
416
390
|
}
|
|
417
391
|
|
|
418
392
|
// createAdapter is the realtime transport factory — injectable so the channel handling can be
|
|
419
393
|
// tested against a fake transport.
|
|
420
|
-
async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD',
|
|
394
|
+
async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', createAdapter = createRealtimeAdapter } = {}) {
|
|
421
395
|
let realtimeToken = await retryTransient('getRealtimeToken', () => getRealtimeToken(cfg));
|
|
422
396
|
|
|
423
397
|
await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
@@ -466,7 +440,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
466
440
|
console.log(`[agent] ${ts()} Received kill (instruction: ${payload.instruction ?? 'exit'}) — shutting down`);
|
|
467
441
|
process.exit(0);
|
|
468
442
|
},
|
|
469
|
-
'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir
|
|
443
|
+
'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir),
|
|
470
444
|
}, {
|
|
471
445
|
log: (msg) => console.log(`[agent] ${ts()} ${msg}`),
|
|
472
446
|
refreshToken,
|
|
@@ -515,20 +489,20 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
515
489
|
|
|
516
490
|
// ── Ralph loop ────────────────────────────────────────────────────────────────
|
|
517
491
|
|
|
518
|
-
function hasPendingTasks(kitDir) {
|
|
519
|
-
return readTasks(join(kitDir, 'tasks.json')).length > 0;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
492
|
// The slice status this kit claims work from. A stack declares it in `lib/kit.json`
|
|
523
|
-
// ({ "pickupStatus": "
|
|
493
|
+
// ({ "pickupStatus": "Waiting" } for the UI kits, which build what a backend kit handed over);
|
|
524
494
|
// absent means "Planned", the backend default. `run --status-trigger <status>` (RALPH_STATUS_TRIGGER)
|
|
525
495
|
// overrides either for that run.
|
|
496
|
+
// `Handover` was renamed `Waiting`; a kit.json or --status-trigger written before that still says it,
|
|
497
|
+
// and a board served by an older platform still reports it.
|
|
498
|
+
const canonicalStatus = (s) => (statusKey(s) === 'waiting' ? 'Waiting' : s);
|
|
499
|
+
|
|
526
500
|
export function readPickupStatus(kitDir) {
|
|
527
501
|
const override = (process.env.RALPH_STATUS_TRIGGER || '').trim();
|
|
528
|
-
if (override) return override;
|
|
502
|
+
if (override) return canonicalStatus(override);
|
|
529
503
|
try {
|
|
530
504
|
const s = JSON.parse(readFileSync(join(kitDir, 'lib', 'kit.json'), 'utf-8')).pickupStatus;
|
|
531
|
-
if (typeof s === 'string' && s.trim()) return s.trim();
|
|
505
|
+
if (typeof s === 'string' && s.trim()) return canonicalStatus(s.trim());
|
|
532
506
|
} catch {}
|
|
533
507
|
return 'Planned';
|
|
534
508
|
}
|
|
@@ -539,14 +513,16 @@ function readCurrentContext(kitDir) {
|
|
|
539
513
|
try { return JSON.parse(readFileSync(ctxPath, 'utf-8')).name || null; } catch { return null; }
|
|
540
514
|
}
|
|
541
515
|
|
|
542
|
-
// The slice's ticket as of the last fetch, from its slice.json — a
|
|
543
|
-
//
|
|
544
|
-
function
|
|
545
|
-
|
|
516
|
+
// The slice's ticket and assignee as of the last fetch, from its slice.json — a build carries the ones the slice is
|
|
517
|
+
// built under, since both can change between builds. Either is null when the slice has none.
|
|
518
|
+
function readSliceMeta(kitDir, ctx, folder) {
|
|
519
|
+
const none = { ticketNumber: null, assignee: null };
|
|
520
|
+
if (!folder) return none;
|
|
546
521
|
try {
|
|
547
|
-
|
|
522
|
+
const slice = JSON.parse(readFileSync(join(kitDir, '.slices', ctx, folder, 'slice.json'), 'utf-8'));
|
|
523
|
+
return { ticketNumber: slice.ticketNumber ?? null, assignee: slice.assignee ?? null };
|
|
548
524
|
} catch {
|
|
549
|
-
return
|
|
525
|
+
return none;
|
|
550
526
|
}
|
|
551
527
|
}
|
|
552
528
|
|
|
@@ -561,20 +537,20 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
|
|
|
561
537
|
try {
|
|
562
538
|
others = readdirSync(slicesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
563
539
|
} catch {}
|
|
564
|
-
const pickup = readPickupStatus(kitDir)
|
|
540
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
565
541
|
const candidates = [...(currentCtx ? [currentCtx] : []), ...others.filter((c) => c !== currentCtx)];
|
|
566
542
|
for (const ctx of candidates) {
|
|
567
543
|
const indexPath = join(slicesDir, ctx, 'index.json');
|
|
568
544
|
if (!existsSync(indexPath)) continue;
|
|
569
545
|
try {
|
|
570
546
|
const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
571
|
-
const planned = slices && slices.find((s) => (s.status
|
|
547
|
+
const planned = slices && slices.find((s) => statusKey(s.status) === pickup && !skipIds.has(s.id));
|
|
572
548
|
if (!planned) continue;
|
|
573
549
|
if (ctx !== currentCtx) {
|
|
574
550
|
writeFileSync(join(slicesDir, 'current_context.json'), JSON.stringify({ name: ctx }, null, 2), 'utf-8');
|
|
575
551
|
console.log(`[ralph] No planned slices in context "${currentCtx}" — switching to "${ctx}".`);
|
|
576
552
|
}
|
|
577
|
-
return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx,
|
|
553
|
+
return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx, ...readSliceMeta(kitDir, ctx, planned.folder) };
|
|
578
554
|
} catch {}
|
|
579
555
|
}
|
|
580
556
|
return null;
|
|
@@ -583,14 +559,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
|
|
|
583
559
|
// Ids of every slice currently waiting for pickup, across all contexts.
|
|
584
560
|
function listPlannedIds(kitDir) {
|
|
585
561
|
const slicesDir = join(kitDir, '.slices');
|
|
586
|
-
const pickup = readPickupStatus(kitDir)
|
|
562
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
587
563
|
const ids = new Set();
|
|
588
564
|
try {
|
|
589
565
|
for (const d of readdirSync(slicesDir, { withFileTypes: true })) {
|
|
590
566
|
if (!d.isDirectory()) continue;
|
|
591
567
|
try {
|
|
592
568
|
const { slices } = JSON.parse(readFileSync(join(slicesDir, d.name, 'index.json'), 'utf-8'));
|
|
593
|
-
for (const s of slices ?? []) if ((s.status
|
|
569
|
+
for (const s of slices ?? []) if (statusKey(s.status) === pickup) ids.add(s.id);
|
|
594
570
|
} catch {}
|
|
595
571
|
}
|
|
596
572
|
} catch {}
|
|
@@ -614,52 +590,15 @@ const SWITCH_AFTER_ATTEMPTS = Math.min(2, MAX_PLANNED_ATTEMPTS);
|
|
|
614
590
|
// records why, so the loop can move on instead of looping or exiting.
|
|
615
591
|
// `lastReply` is the build agent's final message on the last attempt (when the runner reports one),
|
|
616
592
|
// so the note says what actually happened instead of only listing what might have.
|
|
617
|
-
async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts, lastReply) {
|
|
593
|
+
export async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts, lastReply) {
|
|
618
594
|
const now = new Date().toISOString();
|
|
619
595
|
const reason = `Ralph loop picked up this slice ${attempts} times in a row without its status ever leaving ` +
|
|
620
596
|
`"Planned" — the build agent kept declining to build it, or kept building it but its own status change kept ` +
|
|
621
597
|
`getting reverted (e.g. a failed check). Auto-blocked to stop the loop from retrying it forever.` +
|
|
622
598
|
(lastReply ? ` Build agent's last reply: "${String(lastReply).replace(/\s+/g, ' ').trim().slice(0, 500)}"` : '');
|
|
623
599
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
try {
|
|
627
|
-
const indexData = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
628
|
-
const entry = (indexData.slices ?? []).find((s) => s.id === planned.id);
|
|
629
|
-
if (entry) {
|
|
630
|
-
entry.status = 'Blocked';
|
|
631
|
-
entry.blockedReason = reason;
|
|
632
|
-
entry.blockedAt = now;
|
|
633
|
-
folder = entry.folder;
|
|
634
|
-
writeFileSync(indexPath, JSON.stringify(indexData, null, 2), 'utf-8');
|
|
635
|
-
}
|
|
636
|
-
} catch (err) {
|
|
637
|
-
console.error(`[ralph] Failed to write Blocked status to ${indexPath}:`, err.message);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (folder) {
|
|
641
|
-
const sliceJsonPath = join(kitDir, '.slices', planned.ctx, folder, 'slice.json');
|
|
642
|
-
try {
|
|
643
|
-
if (existsSync(sliceJsonPath)) {
|
|
644
|
-
const sliceData = JSON.parse(readFileSync(sliceJsonPath, 'utf-8'));
|
|
645
|
-
sliceData.status = 'Blocked';
|
|
646
|
-
sliceData.blockedReason = reason;
|
|
647
|
-
sliceData.blockedAt = now;
|
|
648
|
-
writeFileSync(sliceJsonPath, JSON.stringify(sliceData, null, 2), 'utf-8');
|
|
649
|
-
}
|
|
650
|
-
} catch (err) {
|
|
651
|
-
console.error(`[ralph] Failed to write Blocked status to ${sliceJsonPath}:`, err.message);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
try {
|
|
656
|
-
const progressPath = join(dirname(kitDir), 'progress.txt');
|
|
657
|
-
const existing = existsSync(progressPath) ? readFileSync(progressPath, 'utf-8') : '';
|
|
658
|
-
const note = `\n## ${now} — Slice auto-blocked\n\nSlice: ${planned.title} (id=${planned.id}, context=${planned.ctx})\n\n- ${reason}\n---\n`;
|
|
659
|
-
writeFileSync(progressPath, existing + note, 'utf-8');
|
|
660
|
-
} catch (err) {
|
|
661
|
-
console.error('[ralph] Failed to append progress.txt note:', err.message);
|
|
662
|
-
}
|
|
600
|
+
// The status lives in the event log; index.json / slice.json show it (and the note) as a projection.
|
|
601
|
+
recordEvent(kitDir, { type: 'blocked', slice: planned.id, reason, at: now }, eventMeta(cfg));
|
|
663
602
|
|
|
664
603
|
// Best-effort: also reflect Blocked on the board itself so a synced fetch
|
|
665
604
|
// doesn't just pull "Planned" back down over our local fix. Never fatal —
|
|
@@ -695,10 +634,10 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts, las
|
|
|
695
634
|
body: JSON.stringify({ text: `Slice "${planned.title}" was auto-blocked by the Ralph loop. ${reason}`, type: 'COMMENT', author: 'ralph-loop' }),
|
|
696
635
|
});
|
|
697
636
|
} catch (err) {
|
|
698
|
-
console.error(`[ralph] Failed to post the auto-block comment on "${planned.title}" — the reason is only
|
|
637
|
+
console.error(`[ralph] Failed to post the auto-block comment on "${planned.title}" — the reason is only on its blocked event:`, err.message);
|
|
699
638
|
}
|
|
700
639
|
} else {
|
|
701
|
-
console.error(`[ralph] No board credentials — could not comment on "${planned.title}"; the reason is only
|
|
640
|
+
console.error(`[ralph] No board credentials — could not comment on "${planned.title}"; the reason is only on its blocked event.`);
|
|
702
641
|
}
|
|
703
642
|
|
|
704
643
|
console.error(`[ralph] ${reason} Marked "${planned.title}" (id=${planned.id}) as Blocked — moving on.`);
|
|
@@ -731,77 +670,178 @@ async function runWithRetry(label, fn) {
|
|
|
731
670
|
// alone, the agent read it as background, answered "what would you like me to help with?" with
|
|
732
671
|
// no tool calls, and a Planned slice stayed Planned until the stuck-slice guard blocked it. So
|
|
733
672
|
// every turn ends with the concrete request — here, once, for every runner.
|
|
734
|
-
// Whether a backend kit finishes UI-bearing slices at `
|
|
673
|
+
// Whether a backend kit finishes UI-bearing slices at `Waiting` instead of `Done`: this run's
|
|
735
674
|
// `run --handover` (RALPH_HANDOVER) over the project's `handover` setting in config.json.
|
|
736
675
|
export function resolveHandover(cfg) {
|
|
737
676
|
return process.env.RALPH_HANDOVER === '1' || cfg?.handover === true;
|
|
738
677
|
}
|
|
739
678
|
|
|
740
|
-
|
|
679
|
+
// The board as the slice-state shell sees it. Slice status on the board is the loop's job, not the build
|
|
680
|
+
// agent's: the loop claims a slice (InProgress) before the build and pushes the outcome afterwards.
|
|
681
|
+
// Transient failures (network, 429, 5xx) are retried until they go through; a 4xx is a real rejection
|
|
682
|
+
// (e.g. the move-into-same-status guard that lets two agents race for one slice) and surfaces.
|
|
683
|
+
export function createBoard(cfg) {
|
|
684
|
+
const base = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}`;
|
|
685
|
+
return {
|
|
686
|
+
async setStatus(sliceId, status) {
|
|
687
|
+
let attempts = 0;
|
|
688
|
+
await retryTransient(`set "${status}" on slice ${sliceId}`, async () => {
|
|
689
|
+
attempts++;
|
|
690
|
+
try {
|
|
691
|
+
await fetchJSON(`${base}/nodes/events`, {
|
|
692
|
+
method: 'POST',
|
|
693
|
+
headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'ralph-loop', ...agentHeaders(cfg) },
|
|
694
|
+
body: JSON.stringify([{
|
|
695
|
+
id: randomUUID(),
|
|
696
|
+
eventType: 'node:changed',
|
|
697
|
+
nodeId: sliceId,
|
|
698
|
+
boardId: cfg.boardId,
|
|
699
|
+
timestamp: Date.now(),
|
|
700
|
+
changedAttributes: ['sliceStatus'],
|
|
701
|
+
meta: { type: 'SLICE_BORDER', sliceStatus: status },
|
|
702
|
+
}]),
|
|
703
|
+
});
|
|
704
|
+
} catch (err) {
|
|
705
|
+
// An earlier attempt may have landed with its response lost; the same-status guard then
|
|
706
|
+
// rejects the retry. A genuine lost race is rejected on the first attempt, so only a 4xx
|
|
707
|
+
// on a retry is forgiven.
|
|
708
|
+
if (attempts > 1 && err instanceof HttpError && err.status >= 400 && err.status < 500) return;
|
|
709
|
+
throw err;
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
},
|
|
713
|
+
async readStatus(sliceId) {
|
|
714
|
+
const { slices } = await retryTransient(`read slices of board ${cfg.boardId}`, () => fetchJSON(`${base}/slicedata/slices`, {
|
|
715
|
+
headers: { 'x-token': cfg.token, ...agentHeaders(cfg) },
|
|
716
|
+
}));
|
|
717
|
+
return slices.find((sl) => sl.id === sliceId)?.status ?? null;
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function taskRequest(planned = null, cfg = null, pickupStatus = 'Planned', queued = null) {
|
|
741
723
|
const task = planned
|
|
742
724
|
? `build the ${pickupStatus} slice "${planned.title}" (id=${planned.id}, context="${planned.ctx}")`
|
|
743
|
-
:
|
|
725
|
+
: `process this queued task (id=${queued?.id}) — the slice change that queued it: ${JSON.stringify(queued?.payload ?? {})}`;
|
|
744
726
|
// Stated explicitly so the agent never has to work it out from config.json: this run's effective
|
|
745
|
-
// value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup
|
|
746
|
-
const handover = planned && pickupStatus
|
|
727
|
+
// value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup Waiting) ignore it.
|
|
728
|
+
const handover = planned && statusKey(pickupStatus) !== 'waiting' ? `\nHandover: ${resolveHandover(cfg) ? 'on' : 'off'}\n` : '';
|
|
747
729
|
return `\n\n---\n\nYour task now: ${task}, following the instructions above. Start immediately.\n${handover}`;
|
|
748
730
|
}
|
|
749
731
|
|
|
750
|
-
|
|
732
|
+
// The slice-state event store (events/, events.log) holds pids and claims of this checkout and must never be
|
|
733
|
+
// versioned. AGENTS.md is rendered from the learning events and adopts what a merge brings in, so where it
|
|
734
|
+
// is tracked, two branches that both regenerated it should keep both sides' lines rather than conflict:
|
|
735
|
+
// `merge=union`. The rules go in .git/info/exclude and .git/info/attributes: local to this clone, never
|
|
736
|
+
// tracked, so the loop does not dirty the working tree it is about to judge (a dirty tree at claim time is
|
|
737
|
+
// taken for a person's own work and is never discarded). Idempotent; best effort.
|
|
738
|
+
function ensureLearningsGitRules(kitDir) {
|
|
739
|
+
const projectDir = dirname(kitDir);
|
|
740
|
+
if (!existsSync(join(projectDir, '.git'))) return;
|
|
741
|
+
const rules = [
|
|
742
|
+
['info/exclude', [`${basename(kitDir)}/events/`, `${basename(kitDir)}/events.log`]],
|
|
743
|
+
['info/attributes', [`${basename(kitDir)}/AGENTS.md merge=union`]],
|
|
744
|
+
];
|
|
745
|
+
for (const [name, lines] of rules) {
|
|
746
|
+
try {
|
|
747
|
+
// `--git-path` also resolves for a worktree, where .git is a file.
|
|
748
|
+
const path = join(projectDir, execFileSync('git', ['rev-parse', '--git-path', name], { cwd: projectDir, encoding: 'utf-8' }).trim());
|
|
749
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
750
|
+
const current = existsSync(path) ? readFileSync(path, 'utf-8') : '';
|
|
751
|
+
const have = new Set(current.split('\n').map((l) => l.trim()));
|
|
752
|
+
const missing = lines.filter((l) => !have.has(l));
|
|
753
|
+
if (!missing.length) continue;
|
|
754
|
+
writeFileSync(path, `${current}${current && !current.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`, 'utf-8');
|
|
755
|
+
console.log(`[ralph] Added to .git/${name}: ${missing.join(', ')}`);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
console.error(`[ralph] Could not update .git/${name}:`, err.message);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false, onCurate = null) {
|
|
751
763
|
const promptFile = join(kitDir, 'lib', 'prompt.md');
|
|
752
764
|
const backendPromptFile = join(kitDir, 'lib', 'backend-prompt.md');
|
|
753
765
|
// --local must mean zero board contact even when .eventmodelers/config.json
|
|
754
766
|
// happens to hold valid credentials — never let a locally-present token flip
|
|
755
767
|
// this back on.
|
|
756
768
|
const credentialed = !localOnly && hasCredentials(cfg);
|
|
769
|
+
const board = credentialed ? createBoard(cfg) : null;
|
|
770
|
+
const meta = eventMeta(cfg);
|
|
771
|
+
ensureLearningsGitRules(kitDir);
|
|
757
772
|
let lastIdleCtx;
|
|
758
|
-
// Builds so far per slice id that is still Planned — see MAX_PLANNED_ATTEMPTS above. An id drops
|
|
759
|
-
// out as soon as its slice leaves Planned, so only builds that failed to move it count.
|
|
760
|
-
const plannedAttempts = new Map();
|
|
761
773
|
// The build agent's final reply on the most recent planned-slice turn — quoted in the Blocked note.
|
|
762
774
|
let lastReply = null;
|
|
763
775
|
|
|
764
776
|
while (true) {
|
|
765
777
|
let didWork = false;
|
|
766
778
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
779
|
+
// A claim still open here is a leftover: its process died mid-build, or settling failed last time.
|
|
780
|
+
await settleLeftoverClaims({ kitDir, board, meta, pickupStatus: readPickupStatus(kitDir) });
|
|
781
|
+
|
|
782
|
+
// AGENTS.md is the projection of the learning events; anything added to it by hand (or by a pull) is adopted first.
|
|
783
|
+
syncLearnings({ kitDir, meta });
|
|
784
|
+
// Enough new learnings, and no build in progress: one agent turn tidies them (merge / retract only).
|
|
785
|
+
if (await curateIfDue({ kitDir, meta, onCurate })) didWork = true;
|
|
786
|
+
|
|
787
|
+
// The oldest queued slice change, one turn at a time. The task is completed once the turn finished; one that
|
|
788
|
+
// timed out stays queued and is handed out again.
|
|
789
|
+
const workedTask = credentialed && await workNextTask({
|
|
790
|
+
kitDir,
|
|
791
|
+
meta,
|
|
792
|
+
run: async (task) => {
|
|
793
|
+
await fetchFullSlice(cfg, kitDir, task.payload?.sliceId);
|
|
794
|
+
const prompt = readFileSync(promptFile, 'utf-8') + taskRequest(null, null, 'Planned', task);
|
|
795
|
+
return runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
if (workedTask) {
|
|
771
799
|
await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
772
800
|
didWork = true;
|
|
773
801
|
}
|
|
774
802
|
|
|
775
803
|
let planned = null;
|
|
776
804
|
if (onPlannedSlice) {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const
|
|
805
|
+
// Attempts are the failed builds the log holds for a slice (see MAX_PLANNED_ATTEMPTS above); they
|
|
806
|
+
// survive a restart and reset once the slice moves off the status it was put back to.
|
|
807
|
+
const attemptsBySlice = fold(readEvents(kitDir)).slices;
|
|
808
|
+
const stuckIds = new Set([...listPlannedIds(kitDir)].filter((id) => (attemptsBySlice[id]?.attempts ?? 0) >= SWITCH_AFTER_ATTEMPTS));
|
|
780
809
|
// Prefer a slice that hasn't been tried to the switch point; fall back to a stuck one.
|
|
781
810
|
planned = getFirstPlannedSlice(kitDir, stuckIds) ?? (stuckIds.size ? getFirstPlannedSlice(kitDir) : null);
|
|
782
811
|
}
|
|
783
812
|
if (planned) {
|
|
784
|
-
const attempts =
|
|
813
|
+
const attempts = fold(readEvents(kitDir)).slices[planned.id]?.attempts ?? 0;
|
|
785
814
|
if (planned.id !== null && attempts >= MAX_PLANNED_ATTEMPTS) {
|
|
786
815
|
await blockStuckSlice(kitDir, cfg, credentialed, planned, attempts, lastReply);
|
|
787
|
-
plannedAttempts.delete(planned.id);
|
|
788
816
|
didWork = true;
|
|
789
817
|
continue;
|
|
790
818
|
}
|
|
791
|
-
if (planned.id !== null) plannedAttempts.set(planned.id, attempts + 1);
|
|
792
819
|
|
|
793
820
|
const prompt = readFileSync(backendPromptFile, 'utf-8') + taskRequest(planned, cfg, readPickupStatus(kitDir));
|
|
794
821
|
if (credentialed) await fetchFullSlice(cfg, kitDir, planned.id);
|
|
822
|
+
if (planned.id) {
|
|
823
|
+
const claimed = await claimSlice({ kitDir, planned, board, meta, cleanTree: isTreeClean(kitDir), pickupStatus: readPickupStatus(kitDir) });
|
|
824
|
+
if (claimed !== 'won') {
|
|
825
|
+
console.log(claimed === 'held'
|
|
826
|
+
? `[ralph] Another run loop holds "${planned.title}" in this kit — waiting.`
|
|
827
|
+
: `[ralph] Could not claim "${planned.title}" — skipping it this iteration.`);
|
|
828
|
+
didWork = true;
|
|
829
|
+
await sleep(5_000);
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
795
833
|
lastReply = null;
|
|
796
834
|
const built = await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, async () => { lastReply = await onPlannedSlice(prompt, {
|
|
797
835
|
sliceId: planned.id,
|
|
798
836
|
sliceTitle: planned.title,
|
|
799
837
|
context: planned.ctx,
|
|
800
838
|
ticketNumber: planned.ticketNumber,
|
|
839
|
+
assignee: planned.assignee,
|
|
801
840
|
boardId: cfg.boardId ?? null,
|
|
802
841
|
attempt: attempts + 1,
|
|
803
842
|
}); });
|
|
804
843
|
if (built) console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
844
|
+
if (planned.id) await settleSlice({ kitDir, planned, board, meta, pickupStatus: readPickupStatus(kitDir), details: { built, lastReply } });
|
|
805
845
|
if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
806
846
|
didWork = true;
|
|
807
847
|
}
|
|
@@ -822,7 +862,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
822
862
|
|
|
823
863
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
824
864
|
|
|
825
|
-
export { HttpError, loadLocalConfig, fetchPlatformConfig, retryTransient, startRealtimeAgent,
|
|
865
|
+
export { HttpError, loadLocalConfig, fetchPlatformConfig, retryTransient, startRealtimeAgent, ensureLearningsGitRules };
|
|
826
866
|
|
|
827
867
|
// The connect line a runner puts in front of every prompt. --local must mean zero board
|
|
828
868
|
// contact, so it is empty then — handed live credentials, the agent would treat itself as
|
|
@@ -845,13 +885,22 @@ export function connectHeader(cfg, localOnly) {
|
|
|
845
885
|
// plain run. Exported so a runner can resolve it at load time, before startRalph runs — the env
|
|
846
886
|
// it hands its `claude` is built then. Idempotent: ensureAgentId returns the id it persisted.
|
|
847
887
|
export function resolveAgentIdentity(kitDir, agentType = 'BUILD', cfg = loadLocalConfig(kitDir)) {
|
|
848
|
-
|
|
849
|
-
|
|
888
|
+
// --id is the operator's choice: used as given, no lock. Otherwise the project's stable id — unless another live
|
|
889
|
+
// process is already running under it, in which case this one gets a fresh id (see agent-lock.js).
|
|
890
|
+
const pinned = process.env.RALPH_AGENT_ID;
|
|
891
|
+
const { agentId, fellBack } = pinned
|
|
892
|
+
? { agentId: pinned, fellBack: false }
|
|
893
|
+
: acquireAgentId({ lockDir: join(dirname(kitDir), '.eventmodelers', 'locks'), stableId: ensureAgentId(kitDir, agentType) });
|
|
894
|
+
// --name, else the configured agentName, else the repository's name — never the bare uuid on the board. A name
|
|
895
|
+
// nobody gave is marked with the instance when the id fell back, so the board does not show two of the same.
|
|
896
|
+
const given = process.env.RALPH_AGENT_NAME || cfg.agentName;
|
|
897
|
+
const fallback = defaultAgentName(dirname(kitDir), agentType);
|
|
898
|
+
const agentName = given || (fellBack ? withInstanceSuffix(fallback, agentId) : fallback);
|
|
850
899
|
process.env.EVENTMODELERS_AGENT_ID = agentId;
|
|
851
900
|
return { agentId, agentName, agentType };
|
|
852
901
|
}
|
|
853
902
|
|
|
854
|
-
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice,
|
|
903
|
+
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, onCurate = null, agentType = 'BUILD', localOnly = false }) {
|
|
855
904
|
const local = loadLocalConfig(kitDir);
|
|
856
905
|
redactConsole([local.token]);
|
|
857
906
|
Object.assign(local, resolveAgentIdentity(kitDir, agentType, local));
|
|
@@ -866,7 +915,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
|
|
|
866
915
|
// reaches out to the platform at all.
|
|
867
916
|
if (localOnly || !hasCredentials(local)) {
|
|
868
917
|
console.log(` mode: local-only (no platform sync)${localOnly ? ' — forced by --local' : ''}\n`);
|
|
869
|
-
await ralphLoop(kitDir, local, onTask, onPlannedSlice, localOnly);
|
|
918
|
+
await ralphLoop(kitDir, local, onTask, onPlannedSlice, localOnly, onCurate);
|
|
870
919
|
return;
|
|
871
920
|
}
|
|
872
921
|
|
|
@@ -874,7 +923,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
|
|
|
874
923
|
console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
|
|
875
924
|
|
|
876
925
|
await Promise.all([
|
|
877
|
-
startRealtimeAgent(cfg, kitDir, { agentType
|
|
878
|
-
ralphLoop(kitDir, cfg, onTask, onPlannedSlice),
|
|
926
|
+
startRealtimeAgent(cfg, kitDir, { agentType }),
|
|
927
|
+
ralphLoop(kitDir, cfg, onTask, onPlannedSlice, false, onCurate),
|
|
879
928
|
]);
|
|
880
929
|
}
|