@eventmodelers/cli 1.0.162 → 2.0.1

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 (104) hide show
  1. package/README.md +22 -27
  2. package/RELEASE_NOTES.md +20 -2
  3. package/cli.js +461 -225
  4. package/lib/adapters/spec-kitty-adapter.js +3 -7
  5. package/lib/global-agent-id.js +45 -0
  6. package/lib/modeling-local-ai.js +4 -2
  7. package/package.json +1 -1
  8. package/shared/build-kit/README.md +4 -19
  9. package/shared/build-kit/lib/agent-lock.js +75 -0
  10. package/shared/build-kit/lib/agent-name.js +27 -0
  11. package/shared/build-kit/lib/event-prune.js +58 -0
  12. package/shared/build-kit/lib/events-view.js +121 -0
  13. package/shared/build-kit/lib/local-ai-agent.js +14 -26
  14. package/shared/build-kit/lib/mcp-register.js +17 -1
  15. package/shared/build-kit/lib/prompt-journal.js +191 -0
  16. package/shared/build-kit/lib/prompt-replay.js +35 -0
  17. package/shared/build-kit/lib/ralph.js +250 -163
  18. package/shared/build-kit/lib/slice-curation.js +40 -0
  19. package/shared/build-kit/lib/slice-events.js +93 -0
  20. package/shared/build-kit/lib/slice-git.js +86 -0
  21. package/shared/build-kit/lib/slice-learnings.js +197 -0
  22. package/shared/build-kit/lib/slice-projection.js +101 -0
  23. package/shared/build-kit/lib/slice-run.js +181 -0
  24. package/shared/build-kit/lib/slice-state.js +182 -0
  25. package/shared/build-kit/lib/slice-tasks.js +35 -0
  26. package/shared/build-kit/ralph-claude.js +4 -2
  27. package/shared/build-kit/ralph-exec.js +4 -2
  28. package/shared/build-kit/ralph-local-ai.js +5 -3
  29. package/shared/skills/connect/SKILL.md +1 -5
  30. package/shared/skills/learn-eventmodelers-api/SKILL.md +13 -0
  31. package/shared/skills/request-feedback/SKILL.md +1 -2
  32. package/shared/skills/ui-contract/SKILL.md +5 -3
  33. package/stacks/axon/templates/build-kit/CLAUDE.md +3 -3
  34. package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
  35. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
  36. package/stacks/axon/templates/build-kit/lib/prompt.md +18 -41
  37. package/stacks/blank/templates/build-kit/CLAUDE.md +3 -3
  38. package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
  39. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
  40. package/stacks/blank/templates/build-kit/lib/prompt.md +19 -42
  41. package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
  42. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  43. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  44. package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
  45. package/stacks/classic/templates/build-kit/CLAUDE.md +3 -3
  46. package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
  47. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
  48. package/stacks/classic/templates/build-kit/lib/prompt.md +19 -42
  49. package/stacks/kurrent/templates/build-kit/CLAUDE.md +3 -4
  50. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
  51. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
  52. package/stacks/kurrent/templates/build-kit/lib/prompt.md +18 -41
  53. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +1 -17
  54. package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
  55. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
  56. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
  57. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +0 -8
  58. package/stacks/modeling-kit/templates/.claude/skills/stressor-analysis/SKILL.md +0 -2
  59. package/stacks/modeling-kit/templates/.claude/skills/walkthrough/SKILL.md +1 -1
  60. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
  61. package/stacks/modeling-kit/templates/kit/AGENTS.md +0 -1
  62. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
  63. package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -72
  64. package/stacks/modeling-kit/templates/kit/README.md +1 -1
  65. package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
  66. package/stacks/node/templates/build-kit/CLAUDE.md +3 -3
  67. package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
  68. package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
  69. package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
  70. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +3 -4
  71. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
  72. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
  73. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +18 -41
  74. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  75. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
  76. package/stacks/react/templates/build-kit/CLAUDE.md +3 -3
  77. package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
  78. package/stacks/react/templates/build-kit/lib/backend-prompt.md +20 -37
  79. package/stacks/react/templates/build-kit/lib/prompt.md +19 -42
  80. package/stacks/supabase/templates/build-kit/CLAUDE.md +3 -3
  81. package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
  82. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
  83. package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
  84. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +2 -2
  85. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  86. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +3 -3
  87. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
  88. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +20 -37
  89. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +19 -42
  90. package/stacks/umadb/templates/build-kit/CLAUDE.md +3 -3
  91. package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
  92. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
  93. package/stacks/umadb/templates/build-kit/lib/prompt.md +18 -41
  94. package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
  95. package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
  96. package/shared/build-kit/lib/agent.sh +0 -20
  97. package/shared/build-kit/ralph.sh +0 -111
  98. package/shared/build-kit/realtime-agent.js +0 -20
  99. package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
  100. package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
  101. package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
  102. package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
  103. package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
  104. 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 when tasks.json has entries
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, renameSync } from 'fs';
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 } 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 bridge-kit install in the same project
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/BRIDGE/MODELING/...) inside the project ROOT
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?.agentId || process.env.RALPH_AGENT_ID || process.env.EVENTMODELERS_AGENT_ID || '';
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) {
@@ -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 hasPendingTasks/getFirstPlannedSliceTitle see live transitions; it must
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.status,
284
- definition: { ...existing?.definition, id: s.id, title: s.title, status: s.status },
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 ticket must be cleared
296
- // here too — otherwise the old one survives the merge and lands on the next build's trace.
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
- // tasks.json has two writers: this process, queueing slice changes, and the agent, removing
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
@@ -407,17 +379,19 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
407
379
  );
408
380
  // Slices in the kit's pickup status (Planned, or Handover for UI kits) are handled by
409
381
  // onPlannedSlice directly — no task needed.
410
- // queueAllStatuses opts out of that split entirely (e.g. bridge has no
411
- // onPlannedSlice consumer, so a lingering Planned slice would otherwise
412
- // never naturally clear its own trigger — see lib/ralph.js callers).
413
- if (queueAllStatuses || (payload.sliceStatus || '').toLowerCase() !== readPickupStatus(kitDir).toLowerCase()) {
414
- await writeTask(payload, kitDir).catch((err) => console.error('[agent] writeTask error:', err));
382
+ if ((payload.sliceStatus || '').toLowerCase() !== readPickupStatus(kitDir).toLowerCase()) {
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', queueAllStatuses = false, createAdapter = createRealtimeAdapter } = {}) {
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, queueAllStatuses),
443
+ 'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir),
470
444
  }, {
471
445
  log: (msg) => console.log(`[agent] ${ts()} ${msg}`),
472
446
  refreshToken,
@@ -515,10 +489,6 @@ 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
493
  // ({ "pickupStatus": "Handover" } 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)
@@ -539,22 +509,24 @@ function readCurrentContext(kitDir) {
539
509
  try { return JSON.parse(readFileSync(ctxPath, 'utf-8')).name || null; } catch { return null; }
540
510
  }
541
511
 
542
- // The slice's ticket as of the last fetch, from its slice.json — a trace carries the ticket the
543
- // slice is built under, since it can be reassigned between builds.
544
- function readTicketNumber(kitDir, ctx, folder) {
545
- if (!folder) return null;
512
+ // The slice's ticket and assignee as of the last fetch, from its slice.json — a build carries the ones the slice is
513
+ // built under, since both can change between builds. Either is null when the slice has none.
514
+ function readSliceMeta(kitDir, ctx, folder) {
515
+ const none = { ticketNumber: null, assignee: null };
516
+ if (!folder) return none;
546
517
  try {
547
- return JSON.parse(readFileSync(join(kitDir, '.slices', ctx, folder, 'slice.json'), 'utf-8')).ticketNumber ?? null;
518
+ const slice = JSON.parse(readFileSync(join(kitDir, '.slices', ctx, folder, 'slice.json'), 'utf-8'));
519
+ return { ticketNumber: slice.ticketNumber ?? null, assignee: slice.assignee ?? null };
548
520
  } catch {
549
- return null;
521
+ return none;
550
522
  }
551
523
  }
552
524
 
553
525
  // Returns the first Planned slice, preferring the current context so a context is finished
554
526
  // before the next one starts. When the current context has no planned work, the first other
555
527
  // context that does becomes the current one (current_context.json is rewritten), so a slice
556
- // set to Planned in any context is eventually built.
557
- function getFirstPlannedSlice(kitDir) {
528
+ // set to Planned in any context is eventually built. Ids in `skipIds` are passed over.
529
+ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
558
530
  const slicesDir = join(kitDir, '.slices');
559
531
  const currentCtx = readCurrentContext(kitDir);
560
532
  let others = [];
@@ -568,78 +540,61 @@ function getFirstPlannedSlice(kitDir) {
568
540
  if (!existsSync(indexPath)) continue;
569
541
  try {
570
542
  const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
571
- const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === pickup);
543
+ const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === pickup && !skipIds.has(s.id));
572
544
  if (!planned) continue;
573
545
  if (ctx !== currentCtx) {
574
546
  writeFileSync(join(slicesDir, 'current_context.json'), JSON.stringify({ name: ctx }, null, 2), 'utf-8');
575
547
  console.log(`[ralph] No planned slices in context "${currentCtx}" — switching to "${ctx}".`);
576
548
  }
577
- return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx, ticketNumber: readTicketNumber(kitDir, ctx, planned.folder) };
549
+ return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx, ...readSliceMeta(kitDir, ctx, planned.folder) };
578
550
  } catch {}
579
551
  }
580
552
  return null;
581
553
  }
582
554
 
583
- // If the exact same Planned slice (by id) comes back up this many times in a
584
- // row without its status ever leaving "Planned", onPlannedSlice is stuck on
555
+ // Ids of every slice currently waiting for pickup, across all contexts.
556
+ function listPlannedIds(kitDir) {
557
+ const slicesDir = join(kitDir, '.slices');
558
+ const pickup = readPickupStatus(kitDir).toLowerCase();
559
+ const ids = new Set();
560
+ try {
561
+ for (const d of readdirSync(slicesDir, { withFileTypes: true })) {
562
+ if (!d.isDirectory()) continue;
563
+ try {
564
+ const { slices } = JSON.parse(readFileSync(join(slicesDir, d.name, 'index.json'), 'utf-8'));
565
+ for (const s of slices ?? []) if ((s.status || '').toLowerCase() === pickup) ids.add(s.id);
566
+ } catch {}
567
+ }
568
+ } catch {}
569
+ return ids;
570
+ }
571
+
572
+ // If the exact same Planned slice (by id) has been built this many times without its status ever leaving "Planned", onPlannedSlice is stuck on
585
573
  // it — declining to build it, or building it but its own status change keeps
586
574
  // getting reverted (e.g. a failed check). Rather than retry it forever (or
587
575
  // crash the whole loop, which would take down every other slice with it),
588
576
  // mark it Blocked with a note explaining why and move on to other work.
589
577
  // Critical for unsupervised/CI runs, which have no human watching to notice
590
578
  // a stall. Configurable for teams that want more slack.
591
- const MAX_PLANNED_ATTEMPTS = Number(process.env.RALPH_MAX_PLANNED_ATTEMPTS) || 2;
579
+ const MAX_PLANNED_ATTEMPTS = Number(process.env.RALPH_MAX_PLANNED_ATTEMPTS) || 5;
580
+
581
+ // After this many builds that left a slice Planned, the loop builds any other Planned slice first
582
+ // and comes back to the stuck one only when nothing else is waiting (until MAX_PLANNED_ATTEMPTS).
583
+ const SWITCH_AFTER_ATTEMPTS = Math.min(2, MAX_PLANNED_ATTEMPTS);
592
584
 
593
585
  // Marks a stuck slice Blocked (locally, and on the board if credentialed) and
594
586
  // records why, so the loop can move on instead of looping or exiting.
595
587
  // `lastReply` is the build agent's final message on the last attempt (when the runner reports one),
596
588
  // so the note says what actually happened instead of only listing what might have.
597
- async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts, lastReply) {
589
+ export async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts, lastReply) {
598
590
  const now = new Date().toISOString();
599
591
  const reason = `Ralph loop picked up this slice ${attempts} times in a row without its status ever leaving ` +
600
592
  `"Planned" — the build agent kept declining to build it, or kept building it but its own status change kept ` +
601
593
  `getting reverted (e.g. a failed check). Auto-blocked to stop the loop from retrying it forever.` +
602
594
  (lastReply ? ` Build agent's last reply: "${String(lastReply).replace(/\s+/g, ' ').trim().slice(0, 500)}"` : '');
603
595
 
604
- const indexPath = join(kitDir, '.slices', planned.ctx, 'index.json');
605
- let folder;
606
- try {
607
- const indexData = JSON.parse(readFileSync(indexPath, 'utf-8'));
608
- const entry = (indexData.slices ?? []).find((s) => s.id === planned.id);
609
- if (entry) {
610
- entry.status = 'Blocked';
611
- entry.blockedReason = reason;
612
- entry.blockedAt = now;
613
- folder = entry.folder;
614
- writeFileSync(indexPath, JSON.stringify(indexData, null, 2), 'utf-8');
615
- }
616
- } catch (err) {
617
- console.error(`[ralph] Failed to write Blocked status to ${indexPath}:`, err.message);
618
- }
619
-
620
- if (folder) {
621
- const sliceJsonPath = join(kitDir, '.slices', planned.ctx, folder, 'slice.json');
622
- try {
623
- if (existsSync(sliceJsonPath)) {
624
- const sliceData = JSON.parse(readFileSync(sliceJsonPath, 'utf-8'));
625
- sliceData.status = 'Blocked';
626
- sliceData.blockedReason = reason;
627
- sliceData.blockedAt = now;
628
- writeFileSync(sliceJsonPath, JSON.stringify(sliceData, null, 2), 'utf-8');
629
- }
630
- } catch (err) {
631
- console.error(`[ralph] Failed to write Blocked status to ${sliceJsonPath}:`, err.message);
632
- }
633
- }
634
-
635
- try {
636
- const progressPath = join(dirname(kitDir), 'progress.txt');
637
- const existing = existsSync(progressPath) ? readFileSync(progressPath, 'utf-8') : '';
638
- const note = `\n## ${now} — Slice auto-blocked\n\nSlice: ${planned.title} (id=${planned.id}, context=${planned.ctx})\n\n- ${reason}\n---\n`;
639
- writeFileSync(progressPath, existing + note, 'utf-8');
640
- } catch (err) {
641
- console.error('[ralph] Failed to append progress.txt note:', err.message);
642
- }
596
+ // The status lives in the event log; index.json / slice.json show it (and the note) as a projection.
597
+ recordEvent(kitDir, { type: 'blocked', slice: planned.id, reason, at: now }, eventMeta(cfg));
643
598
 
644
599
  // Best-effort: also reflect Blocked on the board itself so a synced fetch
645
600
  // doesn't just pull "Planned" back down over our local fix. Never fatal —
@@ -664,6 +619,23 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts, las
664
619
  }
665
620
  }
666
621
 
622
+ // Always leave a comment on the slice saying why it was blocked — a Blocked slice with no
623
+ // explanation is a dead end for whoever finds it. Its own try block, so a failed status sync
624
+ // above can't skip it; without credentials there is no board to comment on, so say so loudly.
625
+ if (credentialed) {
626
+ try {
627
+ await fetchJSON(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/${planned.id}/comments`, {
628
+ method: 'POST',
629
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'ralph-loop', ...agentHeaders(cfg) },
630
+ body: JSON.stringify({ text: `Slice "${planned.title}" was auto-blocked by the Ralph loop. ${reason}`, type: 'COMMENT', author: 'ralph-loop' }),
631
+ });
632
+ } catch (err) {
633
+ console.error(`[ralph] Failed to post the auto-block comment on "${planned.title}" — the reason is only on its blocked event:`, err.message);
634
+ }
635
+ } else {
636
+ console.error(`[ralph] No board credentials — could not comment on "${planned.title}"; the reason is only on its blocked event.`);
637
+ }
638
+
667
639
  console.error(`[ralph] ${reason} Marked "${planned.title}" (id=${planned.id}) as Blocked — moving on.`);
668
640
  }
669
641
 
@@ -700,66 +672,172 @@ export function resolveHandover(cfg) {
700
672
  return process.env.RALPH_HANDOVER === '1' || cfg?.handover === true;
701
673
  }
702
674
 
703
- function taskRequest(planned = null, cfg = null, pickupStatus = 'Planned') {
675
+ // The board as the slice-state shell sees it. Slice status on the board is the loop's job, not the build
676
+ // agent's: the loop claims a slice (InProgress) before the build and pushes the outcome afterwards.
677
+ // Transient failures (network, 429, 5xx) are retried until they go through; a 4xx is a real rejection
678
+ // (e.g. the move-into-same-status guard that lets two agents race for one slice) and surfaces.
679
+ export function createBoard(cfg) {
680
+ const base = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}`;
681
+ return {
682
+ async setStatus(sliceId, status) {
683
+ let attempts = 0;
684
+ await retryTransient(`set "${status}" on slice ${sliceId}`, async () => {
685
+ attempts++;
686
+ try {
687
+ await fetchJSON(`${base}/nodes/events`, {
688
+ method: 'POST',
689
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-user-id': 'ralph-loop', ...agentHeaders(cfg) },
690
+ body: JSON.stringify([{
691
+ id: randomUUID(),
692
+ eventType: 'node:changed',
693
+ nodeId: sliceId,
694
+ boardId: cfg.boardId,
695
+ timestamp: Date.now(),
696
+ changedAttributes: ['sliceStatus'],
697
+ meta: { type: 'SLICE_BORDER', sliceStatus: status },
698
+ }]),
699
+ });
700
+ } catch (err) {
701
+ // An earlier attempt may have landed with its response lost; the same-status guard then
702
+ // rejects the retry. A genuine lost race is rejected on the first attempt, so only a 4xx
703
+ // on a retry is forgiven.
704
+ if (attempts > 1 && err instanceof HttpError && err.status >= 400 && err.status < 500) return;
705
+ throw err;
706
+ }
707
+ });
708
+ },
709
+ async readStatus(sliceId) {
710
+ const { slices } = await retryTransient(`read slices of board ${cfg.boardId}`, () => fetchJSON(`${base}/slicedata/slices`, {
711
+ headers: { 'x-token': cfg.token, ...agentHeaders(cfg) },
712
+ }));
713
+ return slices.find((sl) => sl.id === sliceId)?.status ?? null;
714
+ },
715
+ };
716
+ }
717
+
718
+ function taskRequest(planned = null, cfg = null, pickupStatus = 'Planned', queued = null) {
704
719
  const task = planned
705
720
  ? `build the ${pickupStatus} slice "${planned.title}" (id=${planned.id}, context="${planned.ctx}")`
706
- : 'process the pending tasks in tasks.json';
721
+ : `process this queued task (id=${queued?.id}) — the slice change that queued it: ${JSON.stringify(queued?.payload ?? {})}`;
707
722
  // Stated explicitly so the agent never has to work it out from config.json: this run's effective
708
723
  // value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup Handover) ignore it.
709
724
  const handover = planned && pickupStatus.toLowerCase() !== 'handover' ? `\nHandover: ${resolveHandover(cfg) ? 'on' : 'off'}\n` : '';
710
725
  return `\n\n---\n\nYour task now: ${task}, following the instructions above. Start immediately.\n${handover}`;
711
726
  }
712
727
 
713
- async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false) {
728
+ // The slice-state event store (events/, events.log) holds pids and claims of this checkout and must never be
729
+ // versioned. AGENTS.md is rendered from the learning events and adopts what a merge brings in, so where it
730
+ // is tracked, two branches that both regenerated it should keep both sides' lines rather than conflict:
731
+ // `merge=union`. The rules go in .git/info/exclude and .git/info/attributes: local to this clone, never
732
+ // tracked, so the loop does not dirty the working tree it is about to judge (a dirty tree at claim time is
733
+ // taken for a person's own work and is never discarded). Idempotent; best effort.
734
+ function ensureLearningsGitRules(kitDir) {
735
+ const projectDir = dirname(kitDir);
736
+ if (!existsSync(join(projectDir, '.git'))) return;
737
+ const rules = [
738
+ ['info/exclude', [`${basename(kitDir)}/events/`, `${basename(kitDir)}/events.log`]],
739
+ ['info/attributes', [`${basename(kitDir)}/AGENTS.md merge=union`]],
740
+ ];
741
+ for (const [name, lines] of rules) {
742
+ try {
743
+ // `--git-path` also resolves for a worktree, where .git is a file.
744
+ const path = join(projectDir, execFileSync('git', ['rev-parse', '--git-path', name], { cwd: projectDir, encoding: 'utf-8' }).trim());
745
+ mkdirSync(dirname(path), { recursive: true });
746
+ const current = existsSync(path) ? readFileSync(path, 'utf-8') : '';
747
+ const have = new Set(current.split('\n').map((l) => l.trim()));
748
+ const missing = lines.filter((l) => !have.has(l));
749
+ if (!missing.length) continue;
750
+ writeFileSync(path, `${current}${current && !current.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`, 'utf-8');
751
+ console.log(`[ralph] Added to .git/${name}: ${missing.join(', ')}`);
752
+ } catch (err) {
753
+ console.error(`[ralph] Could not update .git/${name}:`, err.message);
754
+ }
755
+ }
756
+ }
757
+
758
+ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false, onCurate = null) {
714
759
  const promptFile = join(kitDir, 'lib', 'prompt.md');
715
760
  const backendPromptFile = join(kitDir, 'lib', 'backend-prompt.md');
716
761
  // --local must mean zero board contact even when .eventmodelers/config.json
717
762
  // happens to hold valid credentials — never let a locally-present token flip
718
763
  // this back on.
719
764
  const credentialed = !localOnly && hasCredentials(cfg);
765
+ const board = credentialed ? createBoard(cfg) : null;
766
+ const meta = eventMeta(cfg);
767
+ ensureLearningsGitRules(kitDir);
720
768
  let lastIdleCtx;
721
- // Tracks consecutive sightings of the same Planned slice id — see
722
- // MAX_PLANNED_ATTEMPTS above.
723
- let stuckSlice = { id: null, count: 0 };
724
769
  // The build agent's final reply on the most recent planned-slice turn — quoted in the Blocked note.
725
770
  let lastReply = null;
726
771
 
727
772
  while (true) {
728
773
  let didWork = false;
729
774
 
730
- if (credentialed && hasPendingTasks(kitDir)) {
731
- const prompt = readFileSync(promptFile, 'utf-8') + taskRequest();
732
- for (const t of readTasks(join(kitDir, 'tasks.json'))) await fetchFullSlice(cfg, kitDir, t.payload?.sliceId);
733
- await runWithRetry('onTask: loading slice from board...', () => inTurn(kitDir, () => onTask(prompt)));
775
+ // A claim still open here is a leftover: its process died mid-build, or settling failed last time.
776
+ await settleLeftoverClaims({ kitDir, board, meta, pickupStatus: readPickupStatus(kitDir) });
777
+
778
+ // AGENTS.md is the projection of the learning events; anything added to it by hand (or by a pull) is adopted first.
779
+ syncLearnings({ kitDir, meta });
780
+ // Enough new learnings, and no build in progress: one agent turn tidies them (merge / retract only).
781
+ if (await curateIfDue({ kitDir, meta, onCurate })) didWork = true;
782
+
783
+ // The oldest queued slice change, one turn at a time. The task is completed once the turn finished; one that
784
+ // timed out stays queued and is handed out again.
785
+ const workedTask = credentialed && await workNextTask({
786
+ kitDir,
787
+ meta,
788
+ run: async (task) => {
789
+ await fetchFullSlice(cfg, kitDir, task.payload?.sliceId);
790
+ const prompt = readFileSync(promptFile, 'utf-8') + taskRequest(null, null, 'Planned', task);
791
+ return runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
792
+ },
793
+ });
794
+ if (workedTask) {
734
795
  await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
735
796
  didWork = true;
736
797
  }
737
798
 
738
- const planned = onPlannedSlice && getFirstPlannedSlice(kitDir);
799
+ let planned = null;
800
+ if (onPlannedSlice) {
801
+ // Attempts are the failed builds the log holds for a slice (see MAX_PLANNED_ATTEMPTS above); they
802
+ // survive a restart and reset once the slice moves off the status it was put back to.
803
+ const attemptsBySlice = fold(readEvents(kitDir)).slices;
804
+ const stuckIds = new Set([...listPlannedIds(kitDir)].filter((id) => (attemptsBySlice[id]?.attempts ?? 0) >= SWITCH_AFTER_ATTEMPTS));
805
+ // Prefer a slice that hasn't been tried to the switch point; fall back to a stuck one.
806
+ planned = getFirstPlannedSlice(kitDir, stuckIds) ?? (stuckIds.size ? getFirstPlannedSlice(kitDir) : null);
807
+ }
739
808
  if (planned) {
740
- stuckSlice = planned.id !== null && planned.id === stuckSlice.id
741
- ? { id: stuckSlice.id, count: stuckSlice.count + 1 }
742
- : { id: planned.id, count: 1 };
743
-
744
- if (stuckSlice.count > MAX_PLANNED_ATTEMPTS) {
745
- await blockStuckSlice(kitDir, cfg, credentialed, planned, stuckSlice.count, lastReply);
746
- stuckSlice = { id: null, count: 0 };
809
+ const attempts = fold(readEvents(kitDir)).slices[planned.id]?.attempts ?? 0;
810
+ if (planned.id !== null && attempts >= MAX_PLANNED_ATTEMPTS) {
811
+ await blockStuckSlice(kitDir, cfg, credentialed, planned, attempts, lastReply);
747
812
  didWork = true;
748
813
  continue;
749
814
  }
750
815
 
751
816
  const prompt = readFileSync(backendPromptFile, 'utf-8') + taskRequest(planned, cfg, readPickupStatus(kitDir));
752
817
  if (credentialed) await fetchFullSlice(cfg, kitDir, planned.id);
818
+ if (planned.id) {
819
+ const claimed = await claimSlice({ kitDir, planned, board, meta, cleanTree: isTreeClean(kitDir), pickupStatus: readPickupStatus(kitDir) });
820
+ if (claimed !== 'won') {
821
+ console.log(claimed === 'held'
822
+ ? `[ralph] Another run loop holds "${planned.title}" in this kit — waiting.`
823
+ : `[ralph] Could not claim "${planned.title}" — skipping it this iteration.`);
824
+ didWork = true;
825
+ await sleep(5_000);
826
+ continue;
827
+ }
828
+ }
753
829
  lastReply = null;
754
830
  const built = await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, async () => { lastReply = await onPlannedSlice(prompt, {
755
831
  sliceId: planned.id,
756
832
  sliceTitle: planned.title,
757
833
  context: planned.ctx,
758
834
  ticketNumber: planned.ticketNumber,
835
+ assignee: planned.assignee,
759
836
  boardId: cfg.boardId ?? null,
760
- attempt: stuckSlice.count,
837
+ attempt: attempts + 1,
761
838
  }); });
762
839
  if (built) console.log(`[ralph] Slice build complete — waiting for next slice`);
840
+ if (planned.id) await settleSlice({ kitDir, planned, board, meta, pickupStatus: readPickupStatus(kitDir), details: { built, lastReply } });
763
841
  if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
764
842
  didWork = true;
765
843
  }
@@ -780,7 +858,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
780
858
 
781
859
  // ── Public API ────────────────────────────────────────────────────────────────
782
860
 
783
- export { HttpError, loadLocalConfig, fetchPlatformConfig, retryTransient, startRealtimeAgent, writeTask, inTurn, hasPendingTasks };
861
+ export { HttpError, loadLocalConfig, fetchPlatformConfig, retryTransient, startRealtimeAgent, ensureLearningsGitRules };
784
862
 
785
863
  // The connect line a runner puts in front of every prompt. --local must mean zero board
786
864
  // contact, so it is empty then — handed live credentials, the agent would treat itself as
@@ -803,13 +881,22 @@ export function connectHeader(cfg, localOnly) {
803
881
  // plain run. Exported so a runner can resolve it at load time, before startRalph runs — the env
804
882
  // it hands its `claude` is built then. Idempotent: ensureAgentId returns the id it persisted.
805
883
  export function resolveAgentIdentity(kitDir, agentType = 'BUILD', cfg = loadLocalConfig(kitDir)) {
806
- const agentId = process.env.RALPH_AGENT_ID || ensureAgentId(kitDir, agentType);
807
- const agentName = process.env.RALPH_AGENT_NAME || cfg.agentName || '';
884
+ // --id is the operator's choice: used as given, no lock. Otherwise the project's stable id — unless another live
885
+ // process is already running under it, in which case this one gets a fresh id (see agent-lock.js).
886
+ const pinned = process.env.RALPH_AGENT_ID;
887
+ const { agentId, fellBack } = pinned
888
+ ? { agentId: pinned, fellBack: false }
889
+ : acquireAgentId({ lockDir: join(dirname(kitDir), '.eventmodelers', 'locks'), stableId: ensureAgentId(kitDir, agentType) });
890
+ // --name, else the configured agentName, else the repository's name — never the bare uuid on the board. A name
891
+ // nobody gave is marked with the instance when the id fell back, so the board does not show two of the same.
892
+ const given = process.env.RALPH_AGENT_NAME || cfg.agentName;
893
+ const fallback = defaultAgentName(dirname(kitDir), agentType);
894
+ const agentName = given || (fellBack ? withInstanceSuffix(fallback, agentId) : fallback);
808
895
  process.env.EVENTMODELERS_AGENT_ID = agentId;
809
896
  return { agentId, agentName, agentType };
810
897
  }
811
898
 
812
- export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
899
+ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, onCurate = null, agentType = 'BUILD', localOnly = false }) {
813
900
  const local = loadLocalConfig(kitDir);
814
901
  redactConsole([local.token]);
815
902
  Object.assign(local, resolveAgentIdentity(kitDir, agentType, local));
@@ -824,7 +911,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
824
911
  // reaches out to the platform at all.
825
912
  if (localOnly || !hasCredentials(local)) {
826
913
  console.log(` mode: local-only (no platform sync)${localOnly ? ' — forced by --local' : ''}\n`);
827
- await ralphLoop(kitDir, local, onTask, onPlannedSlice, localOnly);
914
+ await ralphLoop(kitDir, local, onTask, onPlannedSlice, localOnly, onCurate);
828
915
  return;
829
916
  }
830
917
 
@@ -832,7 +919,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
832
919
  console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
833
920
 
834
921
  await Promise.all([
835
- startRealtimeAgent(cfg, kitDir, { agentType, queueAllStatuses }),
836
- ralphLoop(kitDir, cfg, onTask, onPlannedSlice),
922
+ startRealtimeAgent(cfg, kitDir, { agentType }),
923
+ ralphLoop(kitDir, cfg, onTask, onPlannedSlice, false, onCurate),
837
924
  ]);
838
925
  }