@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.
Files changed (128) hide show
  1. package/README.md +48 -36
  2. package/RELEASE_NOTES.md +20 -2
  3. package/cli.js +774 -247
  4. package/lib/adapters/spec-kitty-adapter.js +3 -7
  5. package/lib/build-kits.js +23 -0
  6. package/lib/global-agent-id.js +45 -0
  7. package/lib/modeling-local-ai.js +4 -2
  8. package/lib/scaffold-model.js +71 -0
  9. package/lib/scaffold.js +224 -0
  10. package/lib/supervise.js +60 -0
  11. package/lib/ui-baseline.js +123 -0
  12. package/package.json +1 -1
  13. package/shared/build-kit/README.md +4 -19
  14. package/shared/build-kit/lib/agent-lock.js +75 -0
  15. package/shared/build-kit/lib/agent-name.js +27 -0
  16. package/shared/build-kit/lib/event-prune.js +58 -0
  17. package/shared/build-kit/lib/events-view.js +134 -0
  18. package/shared/build-kit/lib/local-ai-agent.js +14 -26
  19. package/shared/build-kit/lib/mcp-register.js +17 -1
  20. package/shared/build-kit/lib/prompt-journal.js +191 -0
  21. package/shared/build-kit/lib/prompt-replay.js +35 -0
  22. package/shared/build-kit/lib/ralph.js +218 -169
  23. package/shared/build-kit/lib/slice-curation.js +40 -0
  24. package/shared/build-kit/lib/slice-events.js +93 -0
  25. package/shared/build-kit/lib/slice-git.js +86 -0
  26. package/shared/build-kit/lib/slice-learnings.js +197 -0
  27. package/shared/build-kit/lib/slice-projection.js +101 -0
  28. package/shared/build-kit/lib/slice-run.js +181 -0
  29. package/shared/build-kit/lib/slice-state.js +184 -0
  30. package/shared/build-kit/lib/slice-tasks.js +35 -0
  31. package/shared/build-kit/ralph-claude.js +4 -2
  32. package/shared/build-kit/ralph-exec.js +4 -2
  33. package/shared/build-kit/ralph-local-ai.js +5 -3
  34. package/shared/scaffold/LICENSE-em-scaffold +21 -0
  35. package/shared/scaffold/supabase/generate.mjs +290 -0
  36. package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
  37. package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
  38. package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
  39. package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
  40. package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
  41. package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
  42. package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
  43. package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
  44. package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
  45. package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
  46. package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
  47. package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
  48. package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
  49. package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
  50. package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
  51. package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
  52. package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
  53. package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
  54. package/shared/skills/learn-eventmodelers-api/SKILL.md +17 -4
  55. package/shared/skills/request-feedback/SKILL.md +1 -2
  56. package/shared/skills/ui-contract/SKILL.md +7 -5
  57. package/shared/skills/update-slice-status/SKILL.md +1 -1
  58. package/stacks/axon/templates/build-kit/CLAUDE.md +7 -6
  59. package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
  60. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
  61. package/stacks/axon/templates/build-kit/lib/prompt.md +32 -41
  62. package/stacks/blank/templates/build-kit/CLAUDE.md +7 -6
  63. package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
  64. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
  65. package/stacks/blank/templates/build-kit/lib/prompt.md +33 -42
  66. package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
  67. package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  68. package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
  69. package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
  70. package/stacks/classic/templates/build-kit/CLAUDE.md +9 -8
  71. package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
  72. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
  73. package/stacks/classic/templates/build-kit/lib/prompt.md +33 -42
  74. package/stacks/kurrent/templates/build-kit/CLAUDE.md +7 -7
  75. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
  76. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
  77. package/stacks/kurrent/templates/build-kit/lib/prompt.md +32 -41
  78. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  79. package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
  80. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
  81. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
  82. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
  83. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
  84. package/stacks/modeling-kit/templates/kit/CLAUDE.md +18 -28
  85. package/stacks/modeling-kit/templates/kit/README.md +1 -1
  86. package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
  87. package/stacks/node/templates/build-kit/CLAUDE.md +7 -6
  88. package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
  89. package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
  90. package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
  91. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +7 -7
  92. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
  93. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
  94. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +32 -41
  95. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
  96. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
  97. package/stacks/react/templates/build-kit/CLAUDE.md +7 -6
  98. package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
  99. package/stacks/react/templates/build-kit/lib/backend-prompt.md +25 -42
  100. package/stacks/react/templates/build-kit/lib/kit.json +1 -1
  101. package/stacks/react/templates/build-kit/lib/prompt.md +47 -43
  102. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
  103. package/stacks/supabase/templates/build-kit/CLAUDE.md +7 -6
  104. package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
  105. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
  106. package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
  107. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +80 -8
  108. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +66 -13
  109. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +8 -7
  110. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
  111. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +25 -42
  112. package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
  113. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +48 -44
  114. package/stacks/umadb/templates/build-kit/CLAUDE.md +7 -6
  115. package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
  116. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
  117. package/stacks/umadb/templates/build-kit/lib/prompt.md +32 -41
  118. package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
  119. package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
  120. package/shared/build-kit/lib/agent.sh +0 -20
  121. package/shared/build-kit/ralph.sh +0 -111
  122. package/shared/build-kit/realtime-agent.js +0 -20
  123. package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
  124. package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
  125. package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
  126. package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
  127. package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
  128. package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
@@ -0,0 +1,181 @@
1
+ // The run loop's shell around the pure slice-state core: claim a slice, settle it after the build, and
2
+ // release claims a dead process left behind. Every step appends events and re-projects status for the
3
+ // build agent; `decide*` (slice-state.js) says what to do, this executes it.
4
+ //
5
+ // `board` is injected: { setStatus(sliceId, status), readStatus(sliceId) → status | null }. null means
6
+ // no board (--local): nothing is pushed or read, the log alone decides.
7
+
8
+ import { appendEvent, readEvents } from './slice-events.js';
9
+ import { fold, decideSettle, decideRecovery, statusKey } from './slice-state.js';
10
+ import { adoptAgentEdits, projectStatus } from './slice-projection.js';
11
+ import { discardUnfinishedWork, gitBase } from './slice-git.js';
12
+
13
+ // Appends an event and re-projects status, so the files the agent reads never lag the log.
14
+ export function recordEvent(kitDir, event, meta) {
15
+ appendEvent(kitDir, event, meta);
16
+ const state = fold(readEvents(kitDir));
17
+ projectStatus(kitDir, state);
18
+ return state;
19
+ }
20
+
21
+ export function isProcessAlive(pid) {
22
+ if (!Number.isInteger(pid) || pid <= 0) return false;
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ } catch (err) {
27
+ return err.code === 'EPERM'; // exists, just not ours; ESRCH means gone
28
+ }
29
+ }
30
+
31
+ // The build agent's side (behind `eventmodelers slice-status`): it records the outcome of the slice the
32
+ // loop has claimed, instead of editing index.json. The loop pushes it to the board when the build ends.
33
+ // The slice is the one open claim, or the one named by id or title. Appends `agent-recorded` and
34
+ // projects straight away — the file the agent reads must never lag the log, or the next refresh would
35
+ // take the stale file for an edit.
36
+ export function recordAgentStatus({ kitDir, status, sliceId, meta }) {
37
+ const claimed = Object.entries(fold(readEvents(kitDir)).slices).filter(([, s]) => s.claim);
38
+ let match;
39
+ if (sliceId) {
40
+ const wanted = sliceId.trim().toLowerCase();
41
+ match = claimed.find(([id, s]) => id === sliceId || s.claim.title?.toLowerCase() === wanted);
42
+ if (!match) throw new Error(`slice "${sliceId}" is not claimed by the run loop — only a slice the loop is building has its status recorded here`);
43
+ } else if (claimed.length === 1) {
44
+ match = claimed[0];
45
+ } else if (claimed.length > 1) {
46
+ throw new Error(`${claimed.length} slices are claimed (${claimed.map(([, s]) => s.claim.title).join(', ')}) — name one with --slice`);
47
+ } else {
48
+ throw new Error('no slice is claimed by the run loop. For a task from tasks.json, set the status with the update-slice-status skill instead');
49
+ }
50
+ const [id, { claim }] = match;
51
+ recordEvent(kitDir, { type: 'agent-recorded', slice: id, status }, meta);
52
+ return { slice: id, title: claim.title ?? id, status };
53
+ }
54
+
55
+ // Claims a slice before it is built. The log is the local arbiter (the first claim wins); the board is
56
+ // the cross-agent one — it rejects a move into the status a slice is already in, and a claim first checks the slice is
57
+ // still in the pickup status. Returns 'won', 'held'
58
+ // (another live process owns it in this kit) or 'lost' (the board refused; nothing was built).
59
+ export async function claimSlice({ kitDir, planned, board, meta, cleanTree, pid = process.pid, pickupStatus = 'Planned' }) {
60
+ const open = fold(readEvents(kitDir)).slices[planned.id]?.claim;
61
+ if (open && open.pid !== pid && isProcessAlive(open.pid)) return 'held';
62
+ // Look at the board before claiming. Its same-status guard only turns away an InProgress over an InProgress, so an
63
+ // agent working from a stale view (Planned, while another has already built and finished it) would otherwise move
64
+ // a Done slice back to InProgress and build it twice. What the board really shows is recorded, which also puts the
65
+ // loop's stale local view right. (Two claims in the same instant are still decided by that same-status guard.)
66
+ if (board) {
67
+ let current;
68
+ try {
69
+ current = await board.readStatus(planned.id);
70
+ } catch (err) {
71
+ console.error(`[ralph] Could not read the board before claiming "${planned.title}":`, err.message);
72
+ return 'lost';
73
+ }
74
+ if (current && statusKey(current) !== statusKey(pickupStatus)) {
75
+ console.log(`[ralph] "${planned.title}" is ${current} on the board, not ${pickupStatus} — someone else got there first.`);
76
+ recordEvent(kitDir, { type: 'board-observed', slice: planned.id, status: current }, meta);
77
+ return 'lost';
78
+ }
79
+ }
80
+ const state = recordEvent(kitDir, {
81
+ type: 'claimed', slice: planned.id, ctx: planned.ctx, title: planned.title, pid, base: gitBase(kitDir), cleanTree,
82
+ // What the board says about the slice at build time, when it says anything: both can change between builds.
83
+ ...(planned.ticketNumber ? { ticketNumber: planned.ticketNumber } : {}),
84
+ ...(planned.assignee ? { assignee: planned.assignee } : {}),
85
+ }, meta);
86
+ if (state.slices[planned.id].claim?.pid !== pid) return 'held';
87
+ if (board) {
88
+ try {
89
+ await board.setStatus(planned.id, 'InProgress');
90
+ } catch (err) {
91
+ console.error(`[ralph] Could not claim "${planned.title}" on the board:`, err.message);
92
+ recordEvent(kitDir, { type: 'released', slice: planned.id, reason: 'lost' }, meta);
93
+ // Counted like any attempt that did not move the slice, so one the board keeps refusing is switched away from.
94
+ recordEvent(kitDir, { type: 'attempt-failed', slice: planned.id, reason: 'claim-lost', lastReply: null }, meta);
95
+ return 'lost';
96
+ }
97
+ }
98
+ recordEvent(kitDir, { type: 'claim-confirmed', slice: planned.id }, meta);
99
+ return 'won';
100
+ }
101
+
102
+ // Executes what decideSettle / decideRecovery returned for one slice. false when the board refused the
103
+ // push — no `settled` is appended then, so the claim stays open and the next iteration settles it again.
104
+ async function runActions({ kitDir, planned, board, meta, actions, details }) {
105
+ const unfinished = actions.some((a) => a.type === 'discard');
106
+ let discarded = null;
107
+ for (const a of actions) {
108
+ if (a.type === 'discard') {
109
+ discarded = discardUnfinishedWork(kitDir, planned, !a.skip, a.base);
110
+ } else if (a.type === 'push') {
111
+ if (board) {
112
+ try {
113
+ await board.setStatus(planned.id, a.status);
114
+ } catch (err) {
115
+ console.error(`[ralph] Could not settle the status of "${planned.title}":`, err.message);
116
+ return false;
117
+ }
118
+ }
119
+ recordEvent(kitDir, { type: 'settled', slice: planned.id, status: a.status }, meta);
120
+ console.log(`[ralph] "${planned.title}" InProgress → ${a.status}.`);
121
+ } else if (a.type === 'release') {
122
+ recordEvent(kitDir, { type: 'released', slice: planned.id, reason: a.reason }, meta);
123
+ }
124
+ }
125
+ // Counted once the slice is actually back, so a retried settle cannot count the attempt twice.
126
+ if (unfinished) {
127
+ recordEvent(kitDir, {
128
+ type: 'attempt-failed',
129
+ slice: planned.id,
130
+ reason: details.built === false ? 'turn-failed' : 'no-outcome',
131
+ status: actions.find((a) => a.type === 'push')?.status,
132
+ lastReply: details.lastReply ?? null,
133
+ ...(discarded ?? {}),
134
+ }, meta);
135
+ console.log(`[ralph] "${planned.title}" ended without an outcome.`);
136
+ }
137
+ return true;
138
+ }
139
+
140
+ // After a build: the outcome the agent recorded is pushed to the board; no outcome means unfinished —
141
+ // its work is discarded and the slice goes back to `pickupStatus`. Returns true once settled; false when
142
+ // it could not be (the claim stays open and the loop tries again at the top of its next iteration).
143
+ export async function settleSlice({ kitDir, planned, board, meta, pickupStatus = 'Planned', details = {} }) {
144
+ const state = adoptAgentEdits(kitDir, fold(readEvents(kitDir)), meta);
145
+ if (!state.slices[planned.id]?.claim) return true;
146
+ let boardStatus = null;
147
+ if (board) {
148
+ try {
149
+ boardStatus = await board.readStatus(planned.id);
150
+ } catch (err) {
151
+ console.error(`[ralph] Could not read the board to settle "${planned.title}":`, err.message);
152
+ return false;
153
+ }
154
+ }
155
+ const actions = decideSettle(state, planned.id, { boardStatus, pickupStatus });
156
+ return runActions({ kitDir, planned, board, meta, actions, details });
157
+ }
158
+
159
+ // Claims still open at the top of an iteration are leftovers: the process died mid-build, or settling
160
+ // failed last time. Each is settled like a finished build; one held by another live process is left alone.
161
+ export async function settleLeftoverClaims({ kitDir, board, meta, pickupStatus = 'Planned', selfPid = process.pid, isAlive = isProcessAlive }) {
162
+ const state = adoptAgentEdits(kitDir, fold(readEvents(kitDir)), meta);
163
+ const boardStatuses = {};
164
+ const unreadable = new Set();
165
+ for (const [id, s] of Object.entries(state.slices)) {
166
+ if (!s.claim || !board || (s.claim.pid !== selfPid && isAlive(s.claim.pid))) continue;
167
+ try {
168
+ boardStatuses[id] = await board.readStatus(id);
169
+ } catch (err) {
170
+ console.error(`[ralph] Could not read the board to settle the leftover claim on "${s.claim.title ?? id}":`, err.message);
171
+ unreadable.add(id);
172
+ }
173
+ }
174
+ for (const { slice, actions } of decideRecovery(state, { isAlive, selfPid, boardStatuses, pickupStatus })) {
175
+ if (unreadable.has(slice)) continue;
176
+ const claim = state.slices[slice].claim;
177
+ const planned = { id: slice, title: claim.title ?? slice, ctx: claim.ctx };
178
+ console.log(`[ralph] Found an unsettled claim on "${planned.title}" — settling it.`);
179
+ await runActions({ kitDir, planned, board, meta, actions, details: { built: false } });
180
+ }
181
+ }
@@ -0,0 +1,184 @@
1
+ // Local slice state as an event log: `fold` derives it, `decide*` turn it into actions. Both are pure —
2
+ // the run loop appends the events, executes the actions and appends what came of them.
3
+ // Design and the precedence rule: slice-state-test-design.md.
4
+ //
5
+ // Events (one writer per type): board-observed, claimed, claim-confirmed, agent-recorded, settled,
6
+ // released, attempt-failed, blocked, learning-recorded, learning-retracted, learning-merged, learnings-curated,
7
+ // task-queued, task-done, prompt-received, prompt-finished. Unknown types are ignored, so a
8
+ // newer loop's log is readable.
9
+
10
+ const emptySlice = () => ({
11
+ status: undefined,
12
+ claim: null,
13
+ pendingOutcome: null,
14
+ lastBoardStatus: undefined,
15
+ attempts: 0,
16
+ retryStatus: undefined,
17
+ lastFailure: null,
18
+ blocked: null,
19
+ });
20
+
21
+ // `Handover` is the old name of `Waiting`: boards and files written before the rename still hold it.
22
+ export const statusKey = (s) => { const k = (s || '').toLowerCase(); return k === 'handover' ? 'waiting' : k; };
23
+ const same = (a, b) => typeof a === 'string' && typeof b === 'string' && statusKey(a) === statusKey(b);
24
+
25
+ const apply = {
26
+ // While a claim is open the board's view is recorded but does not touch `status`: a refresh that
27
+ // still shows InProgress must not revert the outcome the agent recorded. Attempts reset only between
28
+ // builds — our own claim moving the board to InProgress is not the slice leaving Planned.
29
+ 'board-observed'(s, e) {
30
+ s.lastBoardStatus = e.status;
31
+ if (s.claim) return;
32
+ s.status = e.status;
33
+ // Attempts count builds that failed to move the slice off the status it was put back to (Planned, or
34
+ // Waiting for UI kits); any other status means it moved. Any status but Blocked lifts a block.
35
+ if (!same(e.status, s.retryStatus ?? 'Planned')) { s.attempts = 0; s.lastFailure = null; }
36
+ if (!same(e.status, 'Blocked')) s.blocked = null;
37
+ },
38
+ // First claim wins: while one is open a second `claimed` for the slice is ignored.
39
+ claimed(s, e) {
40
+ if (s.claim) return;
41
+ s.claim = { pid: e.pid, confirmed: false, base: e.base ?? null, cleanTree: e.cleanTree, ctx: e.ctx, title: e.title, prevStatus: s.status };
42
+ s.pendingOutcome = null;
43
+ s.status = 'InProgress';
44
+ },
45
+ 'claim-confirmed'(s) {
46
+ if (s.claim) s.claim.confirmed = true;
47
+ },
48
+ 'agent-recorded'(s, e) {
49
+ s.status = e.status;
50
+ if (s.claim) s.pendingOutcome = e.status;
51
+ },
52
+ settled(s, e) {
53
+ s.claim = null;
54
+ s.pendingOutcome = null;
55
+ s.status = e.status;
56
+ s.lastBoardStatus = e.status;
57
+ },
58
+ // The claim ended without anything pushed: the slice shows what the board last said (someone else may
59
+ // have moved it meanwhile), or what it was before the claim if the board was never observed.
60
+ released(s) {
61
+ if (!s.claim) return;
62
+ s.status = s.lastBoardStatus ?? s.claim.prevStatus;
63
+ s.claim = null;
64
+ s.pendingOutcome = null;
65
+ },
66
+ 'attempt-failed'(s, e) {
67
+ s.attempts += 1;
68
+ s.retryStatus = e.status ?? 'Planned';
69
+ // What the next build is told about the one that failed.
70
+ s.lastFailure = { at: e.at, reason: e.reason, lastReply: e.lastReply ?? null, branch: e.branch ?? null, created: e.created ?? [], changed: e.changed ?? '' };
71
+ },
72
+ // A block starts the count afresh: an unblocked slice gets a full budget again.
73
+ blocked(s, e) {
74
+ s.blocked = { reason: e.reason, at: e.at };
75
+ s.status = 'Blocked';
76
+ s.attempts = 0;
77
+ s.lastFailure = null;
78
+ },
79
+ };
80
+
81
+ // The curator's merge: the known sources are replaced by one learning, in the slot of the first of them.
82
+ function mergeLearnings(learnings, e) {
83
+ const sources = learnings.filter((l) => e.learnings?.includes(l.id));
84
+ if (!sources.length) return learnings;
85
+ const tags = [...new Set([...sources.flatMap((l) => l.tags), ...(e.tags ?? [])])];
86
+ const merged = { id: e.id, text: e.text, topic: e.topic ?? sources[0].topic, slice: undefined, scope: e.scope ?? sources[0].scope, tags, curated: true };
87
+ return learnings.flatMap((l) => (l === sources[0] ? [merged] : sources.includes(l) ? [] : [l]));
88
+ }
89
+
90
+ export function fold(events) {
91
+ const slices = {};
92
+ let learnings = [];
93
+ let tasks = [];
94
+ const prompts = new Map(); // board prompts the modeling agent took and has not finished, in the order received
95
+ for (const e of events) {
96
+ if (!e) continue;
97
+ if (e.type === 'prompt-received' && e.prompt?.id) {
98
+ const { id, text, board_id, priority, agent_id } = e.prompt;
99
+ prompts.set(id, { id, pid: e.pid, at: e.at, text, board_id, priority, agent_id: agent_id ?? null });
100
+ } else if (e.type === 'prompt-finished') {
101
+ prompts.delete(e.prompt);
102
+ }
103
+ // The task queue: slice changes the agent has to react to, oldest first. A newer change to the same
104
+ // slice replaces its queued task (and goes to the back); `task-done` names a task by id, so a change that
105
+ // arrived while the agent was working the old one is a new task and survives.
106
+ if (e.type === 'task-queued') {
107
+ const sliceId = e.payload?.sliceId;
108
+ tasks = tasks.filter((t) => sliceId === undefined || t.payload?.sliceId !== sliceId);
109
+ tasks.push({ id: e.id, at: e.at, payload: e.payload });
110
+ } else if (e.type === 'task-done') {
111
+ tasks = tasks.filter((t) => t.id !== e.task);
112
+ }
113
+ // Learnings are not about one slice (the slice is just where it was learned), so they sit beside them.
114
+ if (e.type === 'learning-recorded') learnings.push({ id: e.id, text: e.text, topic: e.topic, slice: e.slice, scope: e.scope ?? 'repo', tags: e.tags ?? [], curated: false });
115
+ else if (e.type === 'learning-retracted') learnings = learnings.filter((l) => l.id !== e.learning);
116
+ else if (e.type === 'learning-merged') learnings = mergeLearnings(learnings, e);
117
+ else if (e.type === 'learnings-curated') learnings = learnings.map((l) => ({ ...l, curated: true }));
118
+ if (!e.slice || !Object.hasOwn(apply, e.type)) continue;
119
+ apply[e.type](slices[e.slice] ??= emptySlice(), e);
120
+ }
121
+ return { slices, learnings, tasks, prompts: [...prompts.values()] };
122
+ }
123
+
124
+ // The curator (an LLM, writing events) is worth a turn once this many learnings have been recorded since
125
+ // it last reviewed them.
126
+ export function needsCuration(state, threshold) {
127
+ return state.learnings.filter((l) => !l.curated).length >= threshold && threshold > 0;
128
+ }
129
+
130
+ // Two learnings that differ only in case or whitespace are the same learning.
131
+ export function learningKey(text) {
132
+ return String(text).replace(/\s+/g, ' ').trim().toLowerCase();
133
+ }
134
+
135
+ // AGENTS.md: the learnings, grouped by topic (in order of first appearance), one line each, duplicates
136
+ // dropped. Nothing is written into the file by hand — it is regenerated from the log.
137
+ export function renderLearnings(state) {
138
+ const topics = new Map();
139
+ const seen = new Set();
140
+ for (const { text, topic } of state.learnings) {
141
+ const key = learningKey(text);
142
+ if (!key || seen.has(key)) continue;
143
+ seen.add(key);
144
+ const name = topic?.trim() || 'General';
145
+ if (!topics.has(name)) topics.set(name, []);
146
+ topics.get(name).push(String(text).trim());
147
+ }
148
+ if (!topics.size) return '';
149
+ const body = [...topics].map(([name, lines]) => `## ${name}\n${lines.map((l) => `- ${l}`).join('\n')}\n`).join('\n');
150
+ return `<!-- Generated from the slice-state event log. Record learnings with the learn command; do not edit. -->\n\n${body}`;
151
+ }
152
+
153
+ // What to do with a claimed slice once its build is over. `boardStatus` is the board's current status
154
+ // for it (null when unknown or there is no board); `pickupStatus` is what an unfinished slice goes back
155
+ // to (Planned, or Waiting for UI kits). Actions: discard {base, skip}, push {status}, release {reason} —
156
+ // the loop appends `settled` after a successful push and `released` for a release. A failed push appends
157
+ // nothing, so the claim stays open and the next iteration settles it again.
158
+ export function decideSettle(state, sliceId, { boardStatus = null, pickupStatus = 'Planned' } = {}) {
159
+ const s = state.slices[sliceId];
160
+ const claim = s?.claim;
161
+ if (!claim) return [];
162
+ // Never confirmed on the board: nothing of ours to push there — the slice may belong to the winner of the race.
163
+ if (!claim.confirmed) return [{ type: 'release', reason: 'unconfirmed' }];
164
+ // Someone else moved it (a person, request-feedback's Blocked) — theirs to keep.
165
+ if (boardStatus && !same(boardStatus, 'InProgress')) return [{ type: 'release', reason: 'moved' }];
166
+ const outcome = s.pendingOutcome;
167
+ const unfinished = !outcome || same(outcome, 'InProgress') || same(outcome, pickupStatus);
168
+ if (!unfinished) return [{ type: 'push', status: outcome }];
169
+ // `skip`: the tree already held someone's edits at claim time, so none of it is the build's debris.
170
+ return [{ type: 'discard', base: claim.base, skip: !claim.cleanTree }, { type: 'push', status: pickupStatus }];
171
+ }
172
+
173
+ // Claims still open at the top of a loop iteration are leftovers: the process died mid-build, or settling
174
+ // failed last time. Each is settled the way a finished build is. A claim held by another live process
175
+ // belongs to it — left alone.
176
+ export function decideRecovery(state, { isAlive, selfPid, boardStatuses = {}, pickupStatus = 'Planned' }) {
177
+ const result = [];
178
+ for (const [slice, s] of Object.entries(state.slices)) {
179
+ if (!s.claim) continue;
180
+ if (s.claim.pid !== selfPid && isAlive(s.claim.pid)) continue;
181
+ result.push({ slice, actions: decideSettle(state, slice, { boardStatus: boardStatuses[slice] ?? null, pickupStatus }) });
182
+ }
183
+ return result;
184
+ }
@@ -0,0 +1,35 @@
1
+ // The task queue is the event log: the realtime agent queues a slice change as `task-queued`, the loop replays
2
+ // the log to find the oldest task still waiting, hands it to the agent, and appends `task-done` when the turn is
3
+ // over. Nobody reads or rewrites a queue file, so there is nothing for two writers to collide on — a change that
4
+ // lands mid-turn is just another event, and `task-done` names the task by id.
5
+
6
+ import { appendEvent, readEvents } from './slice-events.js';
7
+ import { fold } from './slice-state.js';
8
+
9
+ // `payload` is the slice change ({ sliceId, sliceTitle, sliceStatus, ... }). Returns { id } of the task.
10
+ export function queueTask({ kitDir, payload, meta }) {
11
+ return { id: appendEvent(kitDir, { type: 'task-queued', payload }, meta) };
12
+ }
13
+
14
+ export function pendingTasks(kitDir) {
15
+ return fold(readEvents(kitDir)).tasks;
16
+ }
17
+
18
+ // The oldest task waiting, or null. Handing it out does not consume it — `completeTask` does.
19
+ export function nextTask(kitDir) {
20
+ return pendingTasks(kitDir)[0] ?? null;
21
+ }
22
+
23
+ export function completeTask({ kitDir, task, meta }) {
24
+ appendEvent(kitDir, { type: 'task-done', task }, meta);
25
+ }
26
+
27
+ // One turn for the oldest waiting task. `run(task)` is the agent turn and says whether it finished (false: it
28
+ // timed out — the task stays queued and is handed out again; a turn that throws leaves it queued too). The
29
+ // task is completed only after a finished turn. Returns whether a task was worked.
30
+ export async function workNextTask({ kitDir, meta, run }) {
31
+ const task = nextTask(kitDir);
32
+ if (!task) return false;
33
+ if (await run(task)) completeTask({ kitDir, task: task.id, meta });
34
+ return true;
35
+ }
@@ -78,7 +78,7 @@ function describeToolUse(block) {
78
78
  }
79
79
 
80
80
  // `slice` is set for a planned-slice build (see startRalph) — the only turns that are traced.
81
- function runClaude(prompt, slice = null) {
81
+ function runClaude(prompt, slice = null, extraEnv = {}) {
82
82
  return new Promise((resolve, reject) => {
83
83
  // The prompt goes in on stdin, never as a `-p` argument: on Windows a multi-line argv entry
84
84
  // is cut at its first newline, so `claude` saw only the connect header, answered "I don't see
@@ -86,7 +86,7 @@ function runClaude(prompt, slice = null) {
86
86
  const proc = spawn('claude', [...claudeArgs, '-p'], {
87
87
  cwd: projectDir,
88
88
  stdio: ['pipe', 'pipe', 'inherit'],
89
- env: claudeEnv,
89
+ env: { ...claudeEnv, ...extraEnv },
90
90
  });
91
91
  proc.stdin.end(inlineHeader + prompt);
92
92
  const turn = superviseTurn(proc, { timeoutMs, label: 'Claude turn', log: (line) => console.error(`[ralph] ${line}`) });
@@ -134,6 +134,8 @@ startRalph({
134
134
  projectDir,
135
135
  onTask: runClaude,
136
136
  onPlannedSlice: runClaude,
137
+ // The curation turn: the learnings tidied by an agent that may only merge and retract (see lib/slice-curation.js).
138
+ onCurate: (prompt, { env } = {}) => runClaude(prompt, null, env),
137
139
  localOnly,
138
140
  }).catch((err) => {
139
141
  console.error('[ralph] Fatal:', err);
@@ -102,7 +102,7 @@ function collectUsage(stream) {
102
102
  }
103
103
 
104
104
  // `slice` is set for a planned-slice build (see startRalph) — the only turns that are traced.
105
- function runExec(prompt, slice = null) {
105
+ function runExec(prompt, slice = null, extraEnv = {}) {
106
106
  return new Promise((resolvePromise, reject) => {
107
107
  const full = inlineHeader + prompt;
108
108
  const promptFile = join(promptDir, 'prompt.md');
@@ -118,7 +118,7 @@ function runExec(prompt, slice = null) {
118
118
  cwd: projectDir,
119
119
  stdio: ['inherit', traced ? 'pipe' : 'inherit', 'inherit'],
120
120
  shell: true,
121
- env: { ...childEnv, RALPH_PROMPT_FILE: promptFile },
121
+ env: { ...childEnv, RALPH_PROMPT_FILE: promptFile, ...extraEnv },
122
122
  });
123
123
  const turn = superviseTurn(proc, { timeoutMs, label: 'exec turn', viaShell: true, log: (line) => console.error(`[ralph-exec] ${line}`) });
124
124
  const usage = traced ? collectUsage(proc.stdout) : null;
@@ -141,6 +141,8 @@ startRalph({
141
141
  projectDir,
142
142
  onTask: runExec,
143
143
  onPlannedSlice: runExec,
144
+ // The curation turn: the learnings tidied by an agent that may only merge and retract (see lib/slice-curation.js).
145
+ onCurate: (prompt, { env } = {}) => runExec(prompt, null, env),
144
146
  localOnly,
145
147
  }).catch((err) => {
146
148
  console.error('[ralph] Fatal:', err);
@@ -16,13 +16,15 @@ const kitDir = dirname(fileURLToPath(import.meta.url));
16
16
  const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
17
17
 
18
18
 
19
- function runLocalAi() {
19
+ // The task goes in on stdin, as it does for the Claude runner: the loop has already picked it.
20
+ function runLocalAi(prompt) {
20
21
  return new Promise((resolve, reject) => {
21
22
  const proc = spawn('node', [join(kitDir, 'lib', 'local-ai-agent.js')], {
22
23
  cwd: projectDir,
23
- stdio: 'inherit',
24
+ stdio: ['pipe', 'inherit', 'inherit'],
24
25
  env: process.env,
25
26
  });
27
+ proc.stdin.end(prompt);
26
28
  proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`local-ai-agent exited ${code}`))));
27
29
  proc.on('error', reject);
28
30
  });
@@ -32,7 +34,7 @@ startRalph({
32
34
  kitDir,
33
35
  projectDir,
34
36
  onTask: runLocalAi,
35
- // onPlannedSlice omitted — local-ai-agent manages its own task queue
37
+ // onPlannedSlice omitted — this runner works the queued tasks only
36
38
  localOnly: process.env.RALPH_LOCAL === '1',
37
39
  }).catch((err) => {
38
40
  console.error('[ralph] Fatal:', err);
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adrian Mowat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.