@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
|
@@ -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
|
-
|
|
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 —
|
|
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.
|