@eventmodelers/cli 1.0.54 → 1.0.56
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 +6 -0
- package/cli.js +24 -0
- package/package.json +1 -1
- package/shared/build-kit/lib/ralph.js +103 -5
- package/shared/skills/learn-eventmodelers-api/SKILL.md +3 -0
- package/stacks/node/templates/.claude/skills/build-automation/SKILL.md +5 -1
- package/stacks/node/templates/.claude/skills/build-state-change/SKILL.md +90 -2
- package/stacks/node/templates/.claude/skills/build-state-view/SKILL.md +81 -3
- package/stacks/node/templates/build-kit/CLAUDE.md +6 -1
- package/stacks/node/templates/build-kit/lib/checks/60-openapi-annotation.cjs +72 -0
- package/stacks/react/templates/.claude/skills/build-automation/SKILL.md +42 -0
- package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +43 -0
- package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +42 -0
- package/stacks/react/templates/build-kit/CLAUDE.md +62 -0
- package/stacks/react/templates/build-kit/README.md +79 -0
- package/stacks/react/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +135 -0
- package/stacks/react/templates/build-kit/lib/prompt.md +139 -0
- package/stacks/react/templates/build-kit/lib/ralph.js +508 -0
- package/stacks/react/templates/build-kit/package.json +9 -0
- package/stacks/react/templates/build-kit/ralph-claude.js +107 -0
- package/stacks/react/templates/build-kit/ralph-ollama.js +40 -0
- package/stacks/supabase/templates/.claude/skills/build-automation/SKILL.md +5 -1
- package/stacks/supabase/templates/.claude/skills/build-state-change/SKILL.md +90 -2
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +80 -2
- package/stacks/supabase/templates/.claude/skills/build-webhook/SKILL.md +73 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +6 -1
- package/stacks/supabase/templates/build-kit/lib/checks/60-openapi-annotation.cjs +72 -0
- package/stacks/supabase/templates/root/src/swagger.ts +3 -1
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +305 -0
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +238 -0
- package/stacks/supabase-react/templates/.claude/skills/init-style-guide/SKILL.md +60 -0
- package/stacks/supabase-react/templates/.claude/skills/learn-styleguide/SKILL.md +28 -0
- package/stacks/supabase-react/templates/.claude/skills/learn-styleguide/references/README.md +5 -0
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +149 -0
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +139 -0
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +145 -0
- package/stacks/supabase-react/templates/root/.env.example +12 -0
- package/stacks/supabase-react/templates/root/.oxlintrc.json +9 -0
- package/stacks/supabase-react/templates/root/README.md +49 -0
- package/stacks/supabase-react/templates/root/index.html +13 -0
- package/stacks/supabase-react/templates/root/package.json +26 -0
- package/stacks/supabase-react/templates/root/public/favicon.svg +1 -0
- package/stacks/supabase-react/templates/root/public/icons.svg +24 -0
- package/stacks/supabase-react/templates/root/src/App.css +184 -0
- package/stacks/supabase-react/templates/root/src/App.tsx +122 -0
- package/stacks/supabase-react/templates/root/src/assets/hero.png +0 -0
- package/stacks/supabase-react/templates/root/src/assets/react.svg +1 -0
- package/stacks/supabase-react/templates/root/src/assets/vite.svg +1 -0
- package/stacks/supabase-react/templates/root/src/index.css +111 -0
- package/stacks/supabase-react/templates/root/src/lib/api.ts +127 -0
- package/stacks/supabase-react/templates/root/src/lib/supabase.ts +10 -0
- package/stacks/supabase-react/templates/root/src/main.tsx +10 -0
- package/stacks/supabase-react/templates/root/src/slices/.gitkeep +0 -0
- package/stacks/supabase-react/templates/root/src/vite-env.d.ts +13 -0
- package/stacks/supabase-react/templates/root/tsconfig.app.json +26 -0
- package/stacks/supabase-react/templates/root/tsconfig.json +7 -0
- package/stacks/supabase-react/templates/root/tsconfig.node.json +23 -0
- package/stacks/supabase-react/templates/root/vite.config.ts +7 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// Common runtime for the ralph loop + board poller.
|
|
2
|
+
// Not meant to be run directly — use ralph-claude.js or ralph-ollama.js.
|
|
3
|
+
//
|
|
4
|
+
// This kit has no Supabase/PocketBase realtime integration and never touches a
|
|
5
|
+
// database table directly — board changes are picked up purely through the plain
|
|
6
|
+
// REST slicedata endpoint on api.eventmodelers.ai, polled on an interval (see
|
|
7
|
+
// "Board polling" below). If you need instant push notifications instead of
|
|
8
|
+
// polling, use the supabase-react stack.
|
|
9
|
+
//
|
|
10
|
+
// startRalph({ kitDir, projectDir, onTask, onPlannedSlice })
|
|
11
|
+
// onTask(prompt) — called when tasks.json has entries
|
|
12
|
+
// onPlannedSlice(prompt) — called when .slices/ has a "Planned" entry (omit to skip)
|
|
13
|
+
|
|
14
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
|
|
19
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
class HttpError extends Error {
|
|
22
|
+
constructor(status, body) {
|
|
23
|
+
super(`HTTP ${status}: ${body}`);
|
|
24
|
+
this.status = status;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function fetchJSON(url, options) {
|
|
29
|
+
const res = await fetch(url, options);
|
|
30
|
+
if (!res.ok) throw new HttpError(res.status, await res.text());
|
|
31
|
+
return res.json();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function retryOn401(label, fn, maxRetries = 3) {
|
|
35
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
return await fn();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (err instanceof HttpError && err.status === 401) {
|
|
40
|
+
if (attempt < maxRetries) {
|
|
41
|
+
console.warn(`[agent] ${label} — 401, retrying (${attempt}/${maxRetries})...`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
console.error(`[agent] ${label} — 401 after ${maxRetries} retries, shutting down`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
// Config is resolved by walking from the kit dir up through every ancestor
|
|
55
|
+
// directory's .eventmodelers/config.json, merging fields as we go — a value
|
|
56
|
+
// set by a closer (more specific) directory always wins over a farther one.
|
|
57
|
+
// The walk stops as soon as the merged config has full connection credentials
|
|
58
|
+
// (see hasCredentials); anthropicBaseUrl/model are picked up opportunistically
|
|
59
|
+
// along the way but never force the walk to continue further up.
|
|
60
|
+
function* configCandidates(kitDir) {
|
|
61
|
+
yield join(kitDir, '.eventmodelers', 'config.json');
|
|
62
|
+
let dir = dirname(kitDir);
|
|
63
|
+
while (true) {
|
|
64
|
+
yield join(dir, '.eventmodelers', 'config.json');
|
|
65
|
+
const parent = dirname(dir);
|
|
66
|
+
if (parent === dir) break;
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
// Last resort: the walk above only passes through $HOME if the project happens
|
|
70
|
+
// to live under it. A project outside $HOME (e.g. /tmp/foo) never sees it, so
|
|
71
|
+
// check it explicitly — this is where `eventmodelers init-config --global` writes
|
|
72
|
+
// account-wide defaults (organizationId/token) shared across every project.
|
|
73
|
+
yield join(homedir(), '.eventmodelers', 'config.json');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function loadLocalConfig(kitDir) {
|
|
77
|
+
const merged = {};
|
|
78
|
+
const sources = [];
|
|
79
|
+
|
|
80
|
+
for (const candidate of configCandidates(kitDir)) {
|
|
81
|
+
if (sources.includes(candidate) || !existsSync(candidate)) continue;
|
|
82
|
+
let cfg;
|
|
83
|
+
try {
|
|
84
|
+
cfg = JSON.parse(readFileSync(candidate, 'utf-8'));
|
|
85
|
+
} catch {
|
|
86
|
+
console.warn(`[ralph] Skipping invalid config at ${candidate}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
90
|
+
if (merged[key] === undefined) merged[key] = value;
|
|
91
|
+
}
|
|
92
|
+
sources.push(candidate);
|
|
93
|
+
if (hasCredentials(merged)) break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (process.env.BASE_URL) merged.baseUrl = process.env.BASE_URL;
|
|
97
|
+
else if (!merged.baseUrl) merged.baseUrl = 'https://api.eventmodelers.ai';
|
|
98
|
+
|
|
99
|
+
if (sources.length > 1) {
|
|
100
|
+
console.log(`[ralph] Merged config from: ${sources.join(', ')}`);
|
|
101
|
+
} else if (sources.length === 1 && sources[0] !== join(kitDir, '.eventmodelers', 'config.json')) {
|
|
102
|
+
console.log(`[ralph] Using credentials from ${sources[0]}`);
|
|
103
|
+
} else if (sources.length === 0) {
|
|
104
|
+
console.warn(`[ralph] Note: no .eventmodelers/config.json found — platform sync disabled.`);
|
|
105
|
+
console.warn(` To enable board sync, follow: https://app.eventmodelers.ai/documentation#build`);
|
|
106
|
+
console.warn(` Code generation from local slice definitions will still run.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return merged;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function hasCredentials(cfg) {
|
|
113
|
+
return !!(cfg.token && cfg.organizationId && cfg.boardId && cfg.baseUrl);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Distinguishes this agent process from any other agent pinging the same
|
|
117
|
+
// token/board — e.g. a build-kit and a bridge-kit install in the same project
|
|
118
|
+
// share one root config.json, and without a per-agent id both would upsert the
|
|
119
|
+
// same alive row and race each other. The platform already keys the alive-ping
|
|
120
|
+
// on the (agent_type, agent_id) pair, so one shared file works: agentIds is
|
|
121
|
+
// namespaced by agentType (BUILD/BRIDGE/MODELING/...) inside the project ROOT
|
|
122
|
+
// .eventmodelers/config.json — the same file credentials already live in —
|
|
123
|
+
// instead of each kit dir keeping its own separate config.json. Falls back to
|
|
124
|
+
// a pre-existing kit-local agentId (older installs, before this consolidation)
|
|
125
|
+
// so an upgrade doesn't mint a new identity the platform hasn't seen before.
|
|
126
|
+
function ensureAgentId(kitDir, agentType) {
|
|
127
|
+
const rootConfigPath = join(dirname(kitDir), '.eventmodelers', 'config.json');
|
|
128
|
+
let rootCfg = {};
|
|
129
|
+
if (existsSync(rootConfigPath)) {
|
|
130
|
+
try {
|
|
131
|
+
rootCfg = JSON.parse(readFileSync(rootConfigPath, 'utf-8'));
|
|
132
|
+
} catch {
|
|
133
|
+
console.warn(`[ralph] Skipping invalid config at ${rootConfigPath}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
rootCfg.agentIds = rootCfg.agentIds || {};
|
|
137
|
+
if (rootCfg.agentIds[agentType]) return rootCfg.agentIds[agentType];
|
|
138
|
+
|
|
139
|
+
const legacyKitConfigPath = join(kitDir, '.eventmodelers', 'config.json');
|
|
140
|
+
let legacyAgentId;
|
|
141
|
+
if (existsSync(legacyKitConfigPath)) {
|
|
142
|
+
try {
|
|
143
|
+
legacyAgentId = JSON.parse(readFileSync(legacyKitConfigPath, 'utf-8')).agentId;
|
|
144
|
+
} catch {
|
|
145
|
+
console.warn(`[ralph] Skipping invalid config at ${legacyKitConfigPath}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const agentId = legacyAgentId || randomUUID();
|
|
150
|
+
rootCfg.agentIds[agentType] = agentId;
|
|
151
|
+
mkdirSync(dirname(rootConfigPath), { recursive: true });
|
|
152
|
+
writeFileSync(rootConfigPath, JSON.stringify(rootCfg, null, 2));
|
|
153
|
+
return agentId;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function fetchPlatformConfig(local) {
|
|
157
|
+
const remote = await fetchJSON(`${local.baseUrl}/api/config`, {
|
|
158
|
+
headers: { 'x-token': local.token },
|
|
159
|
+
});
|
|
160
|
+
return { ...local, ...remote };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Board polling ─────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function slugify(str) {
|
|
166
|
+
return str.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function fetchAndPersistSlices(cfg, kitDir) {
|
|
170
|
+
const url = `${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata/slices`;
|
|
171
|
+
const { slices } = await fetchJSON(url, {
|
|
172
|
+
headers: { 'x-token': cfg.token, 'x-board-id': cfg.boardId },
|
|
173
|
+
});
|
|
174
|
+
const slicesDir = join(kitDir, '.slices');
|
|
175
|
+
mkdirSync(slicesDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
// Group by context slug
|
|
178
|
+
const contexts = {};
|
|
179
|
+
for (const slice of slices) {
|
|
180
|
+
const contextSlug = slice.contextName ? slugify(slice.contextName) : 'default';
|
|
181
|
+
if (!contexts[contextSlug]) contexts[contextSlug] = { name: slice.contextName || 'default', slices: [] };
|
|
182
|
+
contexts[contextSlug].slices.push(slice);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// current_context.json is STICKY. We work within ONE context at a time and must
|
|
186
|
+
// not auto-jump to another context just because it happens to have planned work.
|
|
187
|
+
// Keep the existing context if it still exists; only seed it when absent or stale.
|
|
188
|
+
const ctxPath = join(slicesDir, 'current_context.json');
|
|
189
|
+
let activeCtx = null;
|
|
190
|
+
if (existsSync(ctxPath)) {
|
|
191
|
+
try { activeCtx = JSON.parse(readFileSync(ctxPath, 'utf-8')).name; } catch {}
|
|
192
|
+
}
|
|
193
|
+
if (!activeCtx || !contexts[activeCtx]) {
|
|
194
|
+
// First run (or the current context disappeared): seed with a context that
|
|
195
|
+
// has planned work, else the first one. This is the ONLY place we choose it.
|
|
196
|
+
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status || '').toLowerCase() === 'planned'));
|
|
197
|
+
activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
|
|
198
|
+
writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Write per-context index.json and per-slice slice.json
|
|
202
|
+
for (const [contextSlug, { slices: ctxSlices }] of Object.entries(contexts)) {
|
|
203
|
+
const contextDir = join(slicesDir, contextSlug);
|
|
204
|
+
mkdirSync(contextDir, { recursive: true });
|
|
205
|
+
|
|
206
|
+
const indexSlices = ctxSlices.map((s, i) => {
|
|
207
|
+
const folder = (s.title ?? s.id).replaceAll(' ', '').toLowerCase();
|
|
208
|
+
return {
|
|
209
|
+
id: s.id,
|
|
210
|
+
slice: s.title,
|
|
211
|
+
index: i,
|
|
212
|
+
contextName: s.contextName || contextSlug,
|
|
213
|
+
contextSlug,
|
|
214
|
+
folder,
|
|
215
|
+
status: s.status,
|
|
216
|
+
definition: { id: s.id, title: s.title, status: s.status },
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
writeFileSync(join(contextDir, 'index.json'), JSON.stringify({ slices: indexSlices }, null, 2), 'utf-8');
|
|
220
|
+
|
|
221
|
+
for (const slice of ctxSlices) {
|
|
222
|
+
const folder = (slice.title ?? slice.id).replaceAll(' ', '').toLowerCase();
|
|
223
|
+
const sliceDir = join(contextDir, folder);
|
|
224
|
+
mkdirSync(sliceDir, { recursive: true });
|
|
225
|
+
writeFileSync(join(sliceDir, 'slice.json'), JSON.stringify(slice, null, 2), 'utf-8');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(`[agent] Persisted ${slices.length} slice(s)`);
|
|
230
|
+
return slices;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function writeTask(payload, kitDir) {
|
|
234
|
+
const tasksPath = join(kitDir, 'tasks.json');
|
|
235
|
+
const existing = existsSync(tasksPath) ? JSON.parse(readFileSync(tasksPath, 'utf-8')) : [];
|
|
236
|
+
const filtered = existing.filter(t => t.payload?.sliceId !== payload.sliceId);
|
|
237
|
+
const task = { id: randomUUID(), createdAt: new Date().toISOString(), payload };
|
|
238
|
+
filtered.push(task);
|
|
239
|
+
writeFileSync(tasksPath, JSON.stringify(filtered, null, 2), 'utf-8');
|
|
240
|
+
console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// How often to re-fetch the board's slices when idle, in ms. A slice that goes
|
|
244
|
+
// straight to "Planned" is picked up by onPlannedSlice's own index.json scan the
|
|
245
|
+
// moment fetchAndPersistSlices writes it; this diff only exists to turn any OTHER
|
|
246
|
+
// status change into a tasks.json entry for onTask (mirrors what a push channel's
|
|
247
|
+
// slice:changed event used to do, one poll tick later instead of instantly).
|
|
248
|
+
const POLL_INTERVAL_MS = Number(process.env.RALPH_POLL_INTERVAL_MS) || 10_000;
|
|
249
|
+
|
|
250
|
+
async function pollForChanges(cfg, kitDir, seen, queueAllStatuses) {
|
|
251
|
+
const slices = await fetchAndPersistSlices(cfg, kitDir);
|
|
252
|
+
for (const slice of slices) {
|
|
253
|
+
const status = (slice.status || '').toLowerCase();
|
|
254
|
+
const previous = seen.get(slice.id);
|
|
255
|
+
seen.set(slice.id, status);
|
|
256
|
+
if (previous === undefined || previous === status) continue;
|
|
257
|
+
|
|
258
|
+
console.log(`[agent] slice changed — slice="${slice.title}" status="${slice.status}"`);
|
|
259
|
+
// Planned slices are handled by onPlannedSlice directly — no task needed.
|
|
260
|
+
// queueAllStatuses opts out of that split entirely (e.g. bridge has no
|
|
261
|
+
// onPlannedSlice consumer, so a lingering Planned slice would otherwise
|
|
262
|
+
// never naturally clear its own trigger — see lib/ralph.js callers).
|
|
263
|
+
if (queueAllStatuses || status !== 'planned') {
|
|
264
|
+
const payload = {
|
|
265
|
+
event: 'slice:changed',
|
|
266
|
+
organizationId: cfg.organizationId,
|
|
267
|
+
boardId: cfg.boardId,
|
|
268
|
+
sliceId: slice.id,
|
|
269
|
+
sliceTitle: slice.title,
|
|
270
|
+
sliceStatus: slice.status,
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
};
|
|
273
|
+
await writeTask(payload, kitDir).catch((err) => console.error('[agent] writeTask error:', err));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function startPolling(cfg, kitDir, { queueAllStatuses = false } = {}) {
|
|
279
|
+
const seen = new Map();
|
|
280
|
+
const initial = await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) => {
|
|
281
|
+
console.error('[agent] Initial slice fetch error:', err);
|
|
282
|
+
return [];
|
|
283
|
+
});
|
|
284
|
+
for (const slice of initial) seen.set(slice.id, (slice.status || '').toLowerCase());
|
|
285
|
+
|
|
286
|
+
while (true) {
|
|
287
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
288
|
+
await retryOn401('pollForChanges', () => pollForChanges(cfg, kitDir, seen, queueAllStatuses)).catch((err) =>
|
|
289
|
+
console.error('[agent] Poll error:', err),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Ralph loop ────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
function hasPendingTasks(kitDir) {
|
|
297
|
+
const tasksPath = join(kitDir, 'tasks.json');
|
|
298
|
+
if (!existsSync(tasksPath)) return false;
|
|
299
|
+
try {
|
|
300
|
+
const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
|
|
301
|
+
return Array.isArray(tasks) && tasks.length > 0;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function readCurrentContext(kitDir) {
|
|
308
|
+
const ctxPath = join(kitDir, '.slices', 'current_context.json');
|
|
309
|
+
if (!existsSync(ctxPath)) return null;
|
|
310
|
+
try { return JSON.parse(readFileSync(ctxPath, 'utf-8')).name || null; } catch { return null; }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Returns the first Planned slice IN THE CURRENT CONTEXT ONLY. If the current
|
|
314
|
+
// context has no planned work, returns null so the loop waits — it must NEVER
|
|
315
|
+
// cross into another context to find something to build.
|
|
316
|
+
function getFirstPlannedSlice(kitDir) {
|
|
317
|
+
const currentCtx = readCurrentContext(kitDir);
|
|
318
|
+
if (!currentCtx) return null;
|
|
319
|
+
const indexPath = join(kitDir, '.slices', currentCtx, 'index.json');
|
|
320
|
+
if (!existsSync(indexPath)) return null;
|
|
321
|
+
try {
|
|
322
|
+
const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
323
|
+
const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === 'planned');
|
|
324
|
+
if (planned) return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx: currentCtx };
|
|
325
|
+
} catch {}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// If the exact same Planned slice (by id) comes back up this many times in a
|
|
330
|
+
// row without its status ever leaving "Planned", onPlannedSlice is stuck on
|
|
331
|
+
// it — declining to build it, or building it but its own status change keeps
|
|
332
|
+
// getting reverted (e.g. a failed check). Rather than retry it forever (or
|
|
333
|
+
// crash the whole loop, which would take down every other slice with it),
|
|
334
|
+
// mark it Blocked with a note explaining why and move on to other work.
|
|
335
|
+
// Critical for unsupervised/CI runs, which have no human watching to notice
|
|
336
|
+
// a stall. Configurable for teams that want more slack.
|
|
337
|
+
const MAX_PLANNED_ATTEMPTS = Number(process.env.RALPH_MAX_PLANNED_ATTEMPTS) || 2;
|
|
338
|
+
|
|
339
|
+
// Marks a stuck slice Blocked (locally, and on the board if credentialed) and
|
|
340
|
+
// records why, so the loop can move on instead of looping or exiting.
|
|
341
|
+
async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
|
|
342
|
+
const now = new Date().toISOString();
|
|
343
|
+
const reason = `Ralph loop picked up this slice ${attempts} times in a row without its status ever leaving ` +
|
|
344
|
+
`"Planned" — the build agent kept declining to build it, or kept building it but its own status change kept ` +
|
|
345
|
+
`getting reverted (e.g. a failed check). Auto-blocked to stop the loop from retrying it forever.`;
|
|
346
|
+
|
|
347
|
+
const indexPath = join(kitDir, '.slices', planned.ctx, 'index.json');
|
|
348
|
+
let folder;
|
|
349
|
+
try {
|
|
350
|
+
const indexData = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
351
|
+
const entry = (indexData.slices ?? []).find((s) => s.id === planned.id);
|
|
352
|
+
if (entry) {
|
|
353
|
+
entry.status = 'Blocked';
|
|
354
|
+
entry.blockedReason = reason;
|
|
355
|
+
entry.blockedAt = now;
|
|
356
|
+
folder = entry.folder;
|
|
357
|
+
writeFileSync(indexPath, JSON.stringify(indexData, null, 2), 'utf-8');
|
|
358
|
+
}
|
|
359
|
+
} catch (err) {
|
|
360
|
+
console.error(`[ralph] Failed to write Blocked status to ${indexPath}:`, err.message);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (folder) {
|
|
364
|
+
const sliceJsonPath = join(kitDir, '.slices', planned.ctx, folder, 'slice.json');
|
|
365
|
+
try {
|
|
366
|
+
if (existsSync(sliceJsonPath)) {
|
|
367
|
+
const sliceData = JSON.parse(readFileSync(sliceJsonPath, 'utf-8'));
|
|
368
|
+
sliceData.status = 'Blocked';
|
|
369
|
+
sliceData.blockedReason = reason;
|
|
370
|
+
sliceData.blockedAt = now;
|
|
371
|
+
writeFileSync(sliceJsonPath, JSON.stringify(sliceData, null, 2), 'utf-8');
|
|
372
|
+
}
|
|
373
|
+
} catch (err) {
|
|
374
|
+
console.error(`[ralph] Failed to write Blocked status to ${sliceJsonPath}:`, err.message);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const progressPath = join(dirname(kitDir), 'progress.txt');
|
|
380
|
+
const existing = existsSync(progressPath) ? readFileSync(progressPath, 'utf-8') : '';
|
|
381
|
+
const note = `\n## ${now} — Slice auto-blocked\n\nSlice: ${planned.title} (id=${planned.id}, context=${planned.ctx})\n\n- ${reason}\n---\n`;
|
|
382
|
+
writeFileSync(progressPath, existing + note, 'utf-8');
|
|
383
|
+
} catch (err) {
|
|
384
|
+
console.error('[ralph] Failed to append progress.txt note:', err.message);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Best-effort: also reflect Blocked on the board itself so a synced fetch
|
|
388
|
+
// doesn't just pull "Planned" back down over our local fix. Never fatal —
|
|
389
|
+
// this loop must keep going locally even if the board call fails.
|
|
390
|
+
if (credentialed) {
|
|
391
|
+
try {
|
|
392
|
+
await fetchJSON(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
|
|
393
|
+
method: 'POST',
|
|
394
|
+
headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'ralph-loop' },
|
|
395
|
+
body: JSON.stringify([{
|
|
396
|
+
id: randomUUID(),
|
|
397
|
+
eventType: 'node:changed',
|
|
398
|
+
nodeId: planned.id,
|
|
399
|
+
boardId: cfg.boardId,
|
|
400
|
+
timestamp: Date.now(),
|
|
401
|
+
changedAttributes: ['sliceStatus'],
|
|
402
|
+
meta: { sliceStatus: 'Blocked' },
|
|
403
|
+
}]),
|
|
404
|
+
});
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error(`[ralph] Failed to sync Blocked status to the board:`, err.message);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
console.error(`[ralph] ${reason} Marked "${planned.title}" (id=${planned.id}) as Blocked — moving on.`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function runWithRetry(label, fn) {
|
|
414
|
+
while (true) {
|
|
415
|
+
try {
|
|
416
|
+
console.log(`[ralph] ${label}`);
|
|
417
|
+
await fn();
|
|
418
|
+
return;
|
|
419
|
+
} catch (err) {
|
|
420
|
+
console.error(`[ralph] Error — retrying in 60s:`, err.message);
|
|
421
|
+
await new Promise((r) => setTimeout(r, 60_000));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice) {
|
|
427
|
+
const promptFile = join(kitDir, 'lib', 'prompt.md');
|
|
428
|
+
const backendPromptFile = join(kitDir, 'lib', 'backend-prompt.md');
|
|
429
|
+
const credentialed = hasCredentials(cfg);
|
|
430
|
+
let lastIdleCtx;
|
|
431
|
+
// Tracks consecutive sightings of the same Planned slice id — see
|
|
432
|
+
// MAX_PLANNED_ATTEMPTS above.
|
|
433
|
+
let stuckSlice = { id: null, count: 0 };
|
|
434
|
+
|
|
435
|
+
while (true) {
|
|
436
|
+
let didWork = false;
|
|
437
|
+
|
|
438
|
+
if (credentialed && hasPendingTasks(kitDir)) {
|
|
439
|
+
const prompt = readFileSync(promptFile, 'utf-8');
|
|
440
|
+
await runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
|
|
441
|
+
await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
442
|
+
didWork = true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const planned = onPlannedSlice && getFirstPlannedSlice(kitDir);
|
|
446
|
+
if (planned) {
|
|
447
|
+
stuckSlice = planned.id !== null && planned.id === stuckSlice.id
|
|
448
|
+
? { id: stuckSlice.id, count: stuckSlice.count + 1 }
|
|
449
|
+
: { id: planned.id, count: 1 };
|
|
450
|
+
|
|
451
|
+
if (stuckSlice.count > MAX_PLANNED_ATTEMPTS) {
|
|
452
|
+
await blockStuckSlice(kitDir, cfg, credentialed, planned, stuckSlice.count);
|
|
453
|
+
stuckSlice = { id: null, count: 0 };
|
|
454
|
+
didWork = true;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const prompt = readFileSync(backendPromptFile, 'utf-8');
|
|
459
|
+
await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt));
|
|
460
|
+
console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
461
|
+
if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
462
|
+
didWork = true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!didWork) {
|
|
466
|
+
// No planned work in the current context — wait, do NOT switch contexts.
|
|
467
|
+
const ctx = readCurrentContext(kitDir);
|
|
468
|
+
if (ctx !== lastIdleCtx) {
|
|
469
|
+
console.log(`[ralph] No planned slices in current context "${ctx}" — waiting. Switch context on the board to continue.`);
|
|
470
|
+
lastIdleCtx = ctx;
|
|
471
|
+
}
|
|
472
|
+
await new Promise((r) => setTimeout(r, 10_000));
|
|
473
|
+
} else {
|
|
474
|
+
lastIdleCtx = undefined;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
export { loadLocalConfig, fetchPlatformConfig, retryOn401, startPolling };
|
|
482
|
+
|
|
483
|
+
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
|
|
484
|
+
const local = loadLocalConfig(kitDir);
|
|
485
|
+
local.agentId = ensureAgentId(kitDir, agentType);
|
|
486
|
+
|
|
487
|
+
console.log(`Ralph — kit: ${kitDir}`);
|
|
488
|
+
console.log(` project: ${projectDir}`);
|
|
489
|
+
|
|
490
|
+
// localOnly (set via `eventmodelers run --local`) forces this branch even when
|
|
491
|
+
// credentials are present — it skips fetchPlatformConfig's network call to
|
|
492
|
+
// ${baseUrl}/api/config and startPolling entirely, so the loop never reaches
|
|
493
|
+
// out to the platform at all.
|
|
494
|
+
if (localOnly || !hasCredentials(local)) {
|
|
495
|
+
console.log(` mode: local-only (no platform sync)${localOnly ? ' — forced by --local' : ''}\n`);
|
|
496
|
+
await ralphLoop(kitDir, local, onTask, onPlannedSlice);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const cfg = await retryOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
|
|
501
|
+
console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
|
|
502
|
+
console.log(` board sync: polling every ${POLL_INTERVAL_MS}ms (REST only — no realtime/table subscription)\n`);
|
|
503
|
+
|
|
504
|
+
await Promise.all([
|
|
505
|
+
startPolling(cfg, kitDir, { queueAllStatuses }),
|
|
506
|
+
ralphLoop(kitDir, cfg, onTask, onPlannedSlice),
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ralph loop + board poller using Claude Code as the executor.
|
|
3
|
+
// Usage: node ralph-claude.js [project_dir]
|
|
4
|
+
|
|
5
|
+
import { startRalph, loadLocalConfig } from './lib/ralph.js';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { dirname, resolve } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const kitDir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
|
|
12
|
+
|
|
13
|
+
const cfg = loadLocalConfig(kitDir);
|
|
14
|
+
const localOnly = process.env.RALPH_LOCAL === '1';
|
|
15
|
+
// --local must mean zero board contact — never hand Claude live board
|
|
16
|
+
// credentials via the inline header, even if config.json has them, or it'll
|
|
17
|
+
// treat them as already-connected and skip straight to board sync.
|
|
18
|
+
const inlineHeader = !localOnly && cfg.boardId
|
|
19
|
+
? `board=${cfg.boardId} token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
|
|
20
|
+
: '';
|
|
21
|
+
|
|
22
|
+
// --verbose here (set via `eventmodelers run --verbose`, passed down as RALPH_VERBOSE)
|
|
23
|
+
// logs full tool input and assistant reasoning text; the default (condensed) mode logs
|
|
24
|
+
// only the high-level step — a skill name, or a bare tool name — mirroring `run --modeling`'s
|
|
25
|
+
// own two-tier logging in cli.js.
|
|
26
|
+
const verbose = process.env.RALPH_VERBOSE === '1';
|
|
27
|
+
|
|
28
|
+
const claudeArgs = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
|
|
29
|
+
if (cfg.model) claudeArgs.push('--model', cfg.model);
|
|
30
|
+
const claudeEnv = {
|
|
31
|
+
...process.env,
|
|
32
|
+
...(cfg.anthropicBaseUrl ? { ANTHROPIC_BASE_URL: cfg.anthropicBaseUrl } : {}),
|
|
33
|
+
...(cfg.token ? { EVENTMODELERS_TOKEN: cfg.token } : {}),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Collapses whitespace/newlines to a single line and truncates past `max` chars — a long
|
|
37
|
+
// multi-line curl command wrapped across many terminal lines is just as unreadable as no
|
|
38
|
+
// detail at all. Keeps one tool call to one log line.
|
|
39
|
+
function oneLine(s, max) {
|
|
40
|
+
const collapsed = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
41
|
+
return collapsed.length > max ? `${collapsed.slice(0, max)}…` : collapsed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function describeToolUse(block) {
|
|
45
|
+
const input = block.input ?? {};
|
|
46
|
+
switch (block.name) {
|
|
47
|
+
case 'Bash': return `Bash: ${oneLine(input.command, 100)}`;
|
|
48
|
+
case 'Skill': return `Skill: ${input.skill}${input.args ? ` ${oneLine(input.args, 60)}` : ''}`;
|
|
49
|
+
case 'Read': return `Read: ${input.file_path}`;
|
|
50
|
+
case 'Edit': return `Edit: ${input.file_path}`;
|
|
51
|
+
case 'Write': return `Write: ${input.file_path}`;
|
|
52
|
+
case 'Grep': return `Grep: ${oneLine(input.pattern, 60)}`;
|
|
53
|
+
case 'Glob': return `Glob: ${input.pattern}`;
|
|
54
|
+
case 'WebFetch': return `WebFetch: ${input.url}`;
|
|
55
|
+
case 'Agent': return `Agent: ${oneLine(input.description ?? input.subagent_type ?? '', 60)}`;
|
|
56
|
+
default: return block.name;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runClaude(prompt) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const proc = spawn('claude', [...claudeArgs, '-p', inlineHeader + prompt], {
|
|
63
|
+
cwd: projectDir,
|
|
64
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
65
|
+
env: claudeEnv,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let buffer = '';
|
|
69
|
+
proc.stdout.on('data', (chunk) => {
|
|
70
|
+
buffer += chunk.toString();
|
|
71
|
+
const lines = buffer.split('\n');
|
|
72
|
+
buffer = lines.pop();
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
if (!line.trim()) continue;
|
|
75
|
+
let msg;
|
|
76
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
77
|
+
|
|
78
|
+
if (msg.type === 'assistant') {
|
|
79
|
+
for (const block of msg.message?.content ?? []) {
|
|
80
|
+
if (block.type === 'text' && block.text && verbose) console.log(block.text);
|
|
81
|
+
if (block.type === 'tool_use') {
|
|
82
|
+
if (verbose) console.log(`→ ${describeToolUse(block)}`);
|
|
83
|
+
else if (block.name === 'Skill') console.log(`→ Skill: ${block.input?.skill ?? ''}`);
|
|
84
|
+
else console.log(`→ ${block.name}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else if (msg.type === 'result') {
|
|
88
|
+
console.log(`done (${msg.duration_ms}ms${msg.total_cost_usd ? `, $${msg.total_cost_usd.toFixed(4)}` : ''})`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`Claude exited ${code}`))));
|
|
94
|
+
proc.on('error', reject);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
startRalph({
|
|
99
|
+
kitDir,
|
|
100
|
+
projectDir,
|
|
101
|
+
onTask: runClaude,
|
|
102
|
+
onPlannedSlice: runClaude,
|
|
103
|
+
localOnly,
|
|
104
|
+
}).catch((err) => {
|
|
105
|
+
console.error('[ralph] Fatal:', err);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ralph loop + board poller using a local Ollama model as the executor.
|
|
3
|
+
// Run `ollama serve` first.
|
|
4
|
+
// Usage: node ralph-ollama.js [project_dir]
|
|
5
|
+
// OLLAMA_MODEL=qwen3.5:9b node ralph-ollama.js
|
|
6
|
+
// OLLAMA_URL=http://host:11434 node ralph-ollama.js
|
|
7
|
+
|
|
8
|
+
import { startRalph } from './lib/ralph.js';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import { dirname, join, resolve } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const kitDir = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
|
|
15
|
+
const model = process.env.OLLAMA_MODEL || 'qwen3.5:9b';
|
|
16
|
+
|
|
17
|
+
console.log(`[ralph-ollama] model=${model}`);
|
|
18
|
+
|
|
19
|
+
function runOllama() {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const proc = spawn('node', [join(kitDir, 'lib', 'ollama-agent.js'), model], {
|
|
22
|
+
cwd: projectDir,
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
env: process.env,
|
|
25
|
+
});
|
|
26
|
+
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`ollama-agent exited ${code}`))));
|
|
27
|
+
proc.on('error', reject);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
startRalph({
|
|
32
|
+
kitDir,
|
|
33
|
+
projectDir,
|
|
34
|
+
onTask: runOllama,
|
|
35
|
+
// onPlannedSlice omitted — ollama-agent manages its own task queue
|
|
36
|
+
localOnly: process.env.RALPH_LOCAL === '1',
|
|
37
|
+
}).catch((err) => {
|
|
38
|
+
console.error('[ralph] Fatal:', err);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
@@ -57,6 +57,9 @@ Follow the **build-state-change** skill to create:
|
|
|
57
57
|
|
|
58
58
|
**Do NOT create a `routes.ts`** for automations — the command is fired internally by the processor, not via HTTP.
|
|
59
59
|
|
|
60
|
+
**No `routes.ts` also means no OpenAPI block** — an automation has no HTTP surface, so it contributes nothing to `/api-docs` or `/swagger.json`. If the slice also defines a todo-list read model that is queried over HTTP, that endpoint belongs to **build-state-view**, and its `@openapi` annotation is required there (see that skill's Step 6a).
|
|
61
|
+
|
|
62
|
+
|
|
60
63
|
Refer to the build-state-change skill for the full command handler structure.
|
|
61
64
|
|
|
62
65
|
> **Storyline-derived tests**: if slice.json has a `storylines[]` array, build-state-change's Step 4b applies here too — treat the trigger EVENT beat as the "given" and the fired command's resulting EVENT beat(s) as "then", exactly as it would for an ordinary command-change slice. Skip silently if there's nothing relevant.
|
|
@@ -267,4 +270,5 @@ src/common/
|
|
|
267
270
|
- [ ] Every processor in `processors[]` has a corresponding `processor.ts` implementation
|
|
268
271
|
- [ ] Command data fields map exclusively from fields available on the trigger event per slice.json — no invented mappings
|
|
269
272
|
- [ ] No filtering conditions were invented — all conditions come from slice.json `description` or `comments`
|
|
270
|
-
- [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
|
|
273
|
+
- [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
|
|
274
|
+
- [ ] No `routes.ts`, therefore no `@openapi` block — any todo-list read-model query endpoint is documented by build-state-view instead
|