@eventmodelers/cli 2.0.1 → 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 +26 -9
- package/cli.js +320 -29
- package/lib/build-kits.js +23 -0
- 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/lib/events-view.js +16 -3
- package/shared/build-kit/lib/ralph.js +19 -15
- package/shared/build-kit/lib/slice-run.js +2 -2
- package/shared/build-kit/lib/slice-state.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 +4 -4
- package/shared/skills/ui-contract/SKILL.md +6 -6
- package/shared/skills/update-slice-status/SKILL.md +1 -1
- package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/axon/templates/build-kit/lib/prompt.md +14 -0
- package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/blank/templates/build-kit/lib/prompt.md +14 -0
- package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/classic/templates/build-kit/lib/prompt.md +14 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +14 -0
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +9 -4
- package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +14 -0
- package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +7 -7
- package/stacks/react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/react/templates/build-kit/lib/prompt.md +29 -2
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +7 -7
- package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +30 -3
- package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +1 -1
- package/stacks/umadb/templates/build-kit/lib/prompt.md +14 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Builds the `model.json` the scaffold generators read, from the board's REST API.
|
|
2
|
+
// `api` is `{ get(path), post(path, body) }` rooted at /api/org/:orgId/boards/:boardId, so this
|
|
3
|
+
// stays testable without a server.
|
|
4
|
+
|
|
5
|
+
const SCREEN_BATCH = 20;
|
|
6
|
+
// Class names that only a Bulma mockup uses; any one in a screen's HTML means the screen is Bulma.
|
|
7
|
+
const BULMA_CLASS = /class="[^"]*\b(navbar|is-primary|modal-card|columns|box)\b/;
|
|
8
|
+
|
|
9
|
+
// A route is only taken when the screen's description states one ("route /orders/:orderId").
|
|
10
|
+
export function routeFrom(description) {
|
|
11
|
+
const m = /\broute\s+(\/[^\s,;)]*)/i.exec(description ?? '');
|
|
12
|
+
return m ? m[1].replace(/[.]+$/, '') : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isHomeScreen(description) {
|
|
16
|
+
return /\bhome\s*page\b|\b(?:page|screen)\s+(?:shown\s+)?after\s+sign-?\s?in\b|\bshown\s+after\s+sign-?\s?in\b/i.test(description ?? '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const descriptionOf = (node) => node.description ?? node.meta?.description ?? node.data?.description ?? '';
|
|
20
|
+
|
|
21
|
+
export async function buildModel({ api, appTitle }) {
|
|
22
|
+
const { chapters = [] } = await api.get('/chapters/bounds');
|
|
23
|
+
const timelines = chapters.filter((c) => c.title).map(({ title, x, y }) => ({ title, x, y }));
|
|
24
|
+
|
|
25
|
+
// Timelines of one context return the same slices, so slices are keyed by id.
|
|
26
|
+
const slicesById = new Map();
|
|
27
|
+
for (const t of timelines) {
|
|
28
|
+
const { slices = [] } = await api.get(`/slicedata?contextName=${encodeURIComponent(t.title)}&projection=outline`);
|
|
29
|
+
for (const s of slices) {
|
|
30
|
+
if (s.title && !slicesById.has(s.id ?? s.title)) slicesById.set(s.id ?? s.title, s);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// One screen node id per distinct screen title is enough to read route, home flag and CSS.
|
|
35
|
+
const screenIdByTitle = new Map();
|
|
36
|
+
for (const s of slicesById.values()) {
|
|
37
|
+
for (const screen of s.screens ?? []) {
|
|
38
|
+
if (screen?.title && screen.id && !screenIdByTitle.has(screen.title)) screenIdByTitle.set(screen.title, screen.id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const titleById = new Map([...screenIdByTitle].map(([title, id]) => [id, title]));
|
|
42
|
+
const ids = [...screenIdByTitle.values()];
|
|
43
|
+
const screens = [];
|
|
44
|
+
let bulma = false;
|
|
45
|
+
for (let i = 0; i < ids.length; i += SCREEN_BATCH) {
|
|
46
|
+
const nodes = await api.post('/nodes/query', { ids: ids.slice(i, i + SCREEN_BATCH) });
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
const title = titleById.get(node.id);
|
|
49
|
+
if (!title) continue;
|
|
50
|
+
const description = descriptionOf(node);
|
|
51
|
+
const route = routeFrom(description);
|
|
52
|
+
const home = isHomeScreen(description);
|
|
53
|
+
screens.push({ title, ...(route ? { route } : {}), ...(home ? { home: true } : {}) });
|
|
54
|
+
const pages = Array.isArray(node.meta?.pages) ? node.meta.pages : [];
|
|
55
|
+
if (pages.some((html) => typeof html === 'string' && BULMA_CLASS.test(html))) bulma = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
appTitle: appTitle || 'App',
|
|
61
|
+
css: bulma ? 'bulma' : 'none',
|
|
62
|
+
timelines,
|
|
63
|
+
slices: [...slicesById.values()].map((s) => ({
|
|
64
|
+
title: s.title,
|
|
65
|
+
sliceType: s.sliceType,
|
|
66
|
+
chapter: s.chapter || s.context,
|
|
67
|
+
screens: (s.screens ?? []).map((sc) => sc.title).filter(Boolean),
|
|
68
|
+
})),
|
|
69
|
+
screens,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/lib/scaffold.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// `eventmodelers scaffold`: turns a board into a runnable app skeleton the build kits then add
|
|
2
|
+
// slices to. A scaffold *type* names the kits it installs, the tools its app needs and the
|
|
3
|
+
// generator that writes the skeleton around them — adding a type (e.g. a Java one) is adding an
|
|
4
|
+
// entry to SCAFFOLD_TYPES plus a generator under shared/scaffold/<type>/.
|
|
5
|
+
//
|
|
6
|
+
// The skeleton generators are adapted from the em-scaffold skill by Adrian Mowat (MIT,
|
|
7
|
+
// shared/scaffold/LICENSE-em-scaffold).
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { createServer } from 'node:net';
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const SCAFFOLD_DIR = join(here, '..', 'shared', 'scaffold');
|
|
16
|
+
|
|
17
|
+
export const SCAFFOLD_TYPES = {
|
|
18
|
+
supabase: {
|
|
19
|
+
label: 'Supabase backend + React front end (runs supabase + supabase-react)',
|
|
20
|
+
// Installed in order; `dir` is relative to the target.
|
|
21
|
+
kits: [
|
|
22
|
+
{ stack: 'supabase', dir: '.' },
|
|
23
|
+
{ stack: 'supabase-react', dir: 'web' },
|
|
24
|
+
],
|
|
25
|
+
generator: join(SCAFFOLD_DIR, 'supabase', 'generate.mjs'),
|
|
26
|
+
tools: [
|
|
27
|
+
{ command: 'docker', name: 'Docker' },
|
|
28
|
+
{ command: 'supabase', name: 'Supabase CLI' },
|
|
29
|
+
{ command: 'flyway', name: 'Flyway' },
|
|
30
|
+
{ command: 'java', name: 'Java (Flyway runtime)' },
|
|
31
|
+
],
|
|
32
|
+
minNode: 22,
|
|
33
|
+
// Command (run in the app root) that adds a sign-in user; omit it for a type without sign-in — `scaffold add-user` is then a no-op.
|
|
34
|
+
addUser: { requires: 'scripts/create-user.mjs', command: (email, password) => [process.execPath, 'scripts/create-user.mjs', email, password] },
|
|
35
|
+
// Run in the target after the generator; the first failure stops the run.
|
|
36
|
+
installSteps: [
|
|
37
|
+
['npm', 'install'],
|
|
38
|
+
['npm', '--prefix', 'web', 'install'],
|
|
39
|
+
],
|
|
40
|
+
checkSteps: [
|
|
41
|
+
['npx', 'tsc', '--noEmit'],
|
|
42
|
+
['npx', 'tsx', '--test', 'src/common/notImplemented.test.ts'],
|
|
43
|
+
['npm', '--prefix', 'web', 'run', 'build'],
|
|
44
|
+
['npm', '--prefix', 'web', 'run', 'lint'],
|
|
45
|
+
],
|
|
46
|
+
// One entry per port the generator takes. `flag` is passed to it with the picked value, `saved` is the
|
|
47
|
+
// key it writes to skeleton.json (so a refresh keeps its ports), `occupies` lists every TCP port the value
|
|
48
|
+
// needs free (default: itself), `render` turns it into the flag's value. A Supabase prefix NNN owns
|
|
49
|
+
// NNN20..NNN29 and can't go above 654.
|
|
50
|
+
ports: [
|
|
51
|
+
{ name: 'api', flag: '--api-port', saved: 'apiPort', start: 3100 },
|
|
52
|
+
{ name: 'web', flag: '--web-port', saved: 'webPort', start: 5273 },
|
|
53
|
+
{ name: 'db', flag: '--db-port-prefix', saved: 'dbPortPrefix', start: 564, limit: 654,
|
|
54
|
+
occupies: (n) => Array.from({ length: 10 }, (_, i) => n * 100 + 20 + i) },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const walkFiles = (dir) => readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walkFiles(join(dir, n)) : [join(dir, n)]));
|
|
60
|
+
|
|
61
|
+
// The type's built-in templates as template path (the path they generate, always '/') -> source file.
|
|
62
|
+
export function builtInTemplates(type) {
|
|
63
|
+
const dir = join(dirname(type.generator), 'template');
|
|
64
|
+
return new Map(walkFiles(dir).sort().map((file) => [relative(dir, file).split(sep).join('/').replace(/\.tmpl$/, ''), file]));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Copies built-in templates into `dest` so they can be edited there; the generator then uses what is in `dest`
|
|
68
|
+
// and falls back to the built-in template for everything else. `only` limits it to paths (a file, or a folder
|
|
69
|
+
// prefix); an existing file is kept unless `force`.
|
|
70
|
+
export function initTemplates(type, dest, { only = [], force = false } = {}) {
|
|
71
|
+
const all = builtInTemplates(type);
|
|
72
|
+
const wanted = only.map((p) => p.replace(/\\/g, '/').replace(/\.tmpl$/, '').replace(/\/+$/, ''));
|
|
73
|
+
const unknown = wanted.filter((p) => ![...all.keys()].some((rel) => rel === p || rel.startsWith(`${p}/`)));
|
|
74
|
+
if (unknown.length) throw new Error(`no built-in template at ${unknown.map((p) => `"${p}"`).join(', ')} — \`init-templates --list\` shows them`);
|
|
75
|
+
const copied = [];
|
|
76
|
+
const kept = [];
|
|
77
|
+
for (const [rel, file] of all) {
|
|
78
|
+
if (wanted.length && !wanted.some((p) => rel === p || rel.startsWith(`${p}/`))) continue;
|
|
79
|
+
const out = join(dest, `${rel}.tmpl`);
|
|
80
|
+
if (existsSync(out) && !force) { kept.push(rel); continue; }
|
|
81
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
82
|
+
copyFileSync(file, out);
|
|
83
|
+
copied.push(rel);
|
|
84
|
+
}
|
|
85
|
+
return { copied, kept };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function scaffoldTypeNames() {
|
|
89
|
+
return Object.keys(SCAFFOLD_TYPES);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// How to start `file` on this platform. On Windows npm and npx are .cmd shims that only start through a
|
|
93
|
+
// shell (Node refuses to spawn them directly), so they get one; everything else is a plain executable.
|
|
94
|
+
export function invocation(file, args, platform = process.platform) {
|
|
95
|
+
const shimmed = platform === 'win32' && ['npm', 'npx'].includes(file);
|
|
96
|
+
return shimmed ? { file: [file, ...args].join(' '), args: [], shell: true } : { file, args, shell: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The command that answers "is `command` on the PATH": `where` on Windows (it honours PATHEXT), `command -v` elsewhere.
|
|
100
|
+
export function probe(command, platform = process.platform) {
|
|
101
|
+
return platform === 'win32' ? { file: 'where', args: [command] } : { file: 'sh', args: ['-c', `command -v ${command}`] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function hasCommand(command) {
|
|
105
|
+
const { file, args } = probe(command);
|
|
106
|
+
try {
|
|
107
|
+
execFileSync(file, args, { stdio: 'ignore' });
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// What the type needs that this machine lacks, as printable names. Node is checked by version.
|
|
115
|
+
export function missingPrerequisites(type, { nodeVersion = process.versions.node, has = hasCommand } = {}) {
|
|
116
|
+
const missing = [];
|
|
117
|
+
if (Number(nodeVersion.split('.')[0]) < type.minNode) missing.push(`Node ${type.minNode} or newer (found ${nodeVersion})`);
|
|
118
|
+
for (const tool of type.tools) if (!has(tool.command)) missing.push(tool.name);
|
|
119
|
+
return missing;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Free means nothing listens on any interface, which is what a Docker-published port looks like too.
|
|
123
|
+
export function isPortFree(port) {
|
|
124
|
+
return new Promise((done) => {
|
|
125
|
+
const server = createServer();
|
|
126
|
+
server.once('error', () => done(false));
|
|
127
|
+
server.listen(port, () => server.close(() => done(true)));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The first value from the spec's start whose ports are all free, one spec at a time. Specs picked in the
|
|
132
|
+
// same run don't share ports as long as their ranges differ, which they do for every type so far.
|
|
133
|
+
export async function pickPorts(specs, { free = isPortFree } = {}) {
|
|
134
|
+
const picked = {};
|
|
135
|
+
for (const spec of specs) {
|
|
136
|
+
const occupies = spec.occupies ?? ((n) => [n]);
|
|
137
|
+
const limit = spec.limit ?? spec.start + 100;
|
|
138
|
+
let value = spec.start;
|
|
139
|
+
while (value <= limit && !(await Promise.all(occupies(value).map(free))).every(Boolean)) value++;
|
|
140
|
+
if (value > limit) throw new Error(`no free "${spec.name}" port from ${spec.start} up to ${limit}`);
|
|
141
|
+
picked[spec.name] = String(value);
|
|
142
|
+
}
|
|
143
|
+
return picked;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// An empty/new directory or an earlier skeleton (has skeleton.json) is fair game; anything else is not ours.
|
|
147
|
+
export function targetState(target) {
|
|
148
|
+
if (!existsSync(target)) return 'new';
|
|
149
|
+
if (existsSync(join(target, 'skeleton.json'))) return 'skeleton';
|
|
150
|
+
return readdirSync(target).length === 0 ? 'new' : 'foreign';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function run(file, args, { cwd, env, quiet = false } = {}) {
|
|
154
|
+
const inv = invocation(file, args);
|
|
155
|
+
execFileSync(inv.file, inv.args, { cwd, env: { ...process.env, ...env }, stdio: quiet ? 'ignore' : 'inherit', shell: inv.shell });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// cfg: { token, organizationId, boardId, baseUrl }. Credentials reach the child installers through
|
|
159
|
+
// EVENTMODELERS_* env vars so the token never shows up in a process list.
|
|
160
|
+
// templates: a folder of *.tmpl files overriding the type's built-in templates; when omitted, `<target>/templates`
|
|
161
|
+
// is used if it exists, so a project's customisations survive every refresh.
|
|
162
|
+
export async function runScaffold({ typeName, target, cfg, model, cliPath, install = true, check = true, templates, ports: portOverrides = {}, log = console.log }) {
|
|
163
|
+
const type = SCAFFOLD_TYPES[typeName];
|
|
164
|
+
const root = resolve(target);
|
|
165
|
+
const state = targetState(root);
|
|
166
|
+
if (state === 'foreign') {
|
|
167
|
+
throw new Error(`${root} is not empty and is not a scaffolded skeleton — pass an empty or new directory`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const unknown = Object.keys(portOverrides).filter((n) => !type.ports.some((p) => p.name === n));
|
|
171
|
+
if (unknown.length) {
|
|
172
|
+
throw new Error(`unknown port ${unknown.map((n) => `"${n}"`).join(', ')} for type ${typeName} (it has: ${type.ports.map((p) => p.name).join(', ')})`);
|
|
173
|
+
}
|
|
174
|
+
// A refresh keeps the ports the skeleton already runs on; only a new one picks free ports.
|
|
175
|
+
const previous = state === 'skeleton' ? JSON.parse(readFileSync(join(root, 'skeleton.json'), 'utf8')) : {};
|
|
176
|
+
const pending = type.ports.filter((p) => previous[p.saved] === undefined && portOverrides[p.name] === undefined);
|
|
177
|
+
const picked = await pickPorts(pending);
|
|
178
|
+
const ports = Object.fromEntries(type.ports.map((p) => [p.name, String(portOverrides[p.name] ?? previous[p.saved] ?? picked[p.name])]));
|
|
179
|
+
|
|
180
|
+
for (const kit of type.kits) mkdirSync(join(root, kit.dir), { recursive: true });
|
|
181
|
+
if (!existsSync(join(root, '.git'))) run('git', ['init', '-q'], { cwd: root });
|
|
182
|
+
|
|
183
|
+
const env = {
|
|
184
|
+
EVENTMODELERS_TOKEN: cfg.token,
|
|
185
|
+
EVENTMODELERS_ORGANIZATION_ID: cfg.organizationId,
|
|
186
|
+
EVENTMODELERS_BOARD_ID: cfg.boardId,
|
|
187
|
+
...(cfg.baseUrl ? { EVENTMODELERS_BASE_URL: cfg.baseUrl } : {}),
|
|
188
|
+
};
|
|
189
|
+
for (const kit of type.kits) {
|
|
190
|
+
const cwd = join(root, kit.dir);
|
|
191
|
+
if (existsSync(join(cwd, '.build-kit'))) {
|
|
192
|
+
log(`▶ ${kit.stack}: already installed in ${kit.dir}, skipping`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
log(`▶ Installing the ${kit.stack} kit in ${kit.dir}`);
|
|
196
|
+
run(process.execPath, [cliPath, 'init', '--stack', kit.stack, '--print'], { cwd, env });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Next to the target, not in it: the model is an input of the skeleton, not a project file.
|
|
200
|
+
const modelFile = `${root}.model.json`;
|
|
201
|
+
writeFileSync(modelFile, JSON.stringify(model, null, 2) + '\n');
|
|
202
|
+
const templatesDir = templates ? resolve(templates) : existsSync(join(root, 'templates')) ? join(root, 'templates') : null;
|
|
203
|
+
if (templates && !existsSync(templatesDir)) throw new Error(`--templates ${templatesDir} does not exist`);
|
|
204
|
+
log(`▶ Generating the skeleton (model: ${modelFile}${templatesDir ? `, templates: ${templatesDir}` : ''})`);
|
|
205
|
+
run(process.execPath, [
|
|
206
|
+
type.generator, '--model', modelFile, '--target', root,
|
|
207
|
+
...type.ports.flatMap((p) => [p.flag, ports[p.name]]),
|
|
208
|
+
...(templatesDir ? ['--templates', templatesDir] : []),
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
if (install) {
|
|
212
|
+
for (const [file, ...args] of type.installSteps) run(file, args, { cwd: root });
|
|
213
|
+
if (check) {
|
|
214
|
+
for (const [file, ...args] of type.checkSteps) run(file, args, { cwd: root });
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
run('git', ['add', '-A'], { cwd: root });
|
|
218
|
+
run('git', ['commit', '-q', '-m', 'chore: app skeleton from the event model'], { cwd: root });
|
|
219
|
+
} catch {
|
|
220
|
+
log('ℹ️ Nothing to commit (or git refused) — commit the skeleton yourself.');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { root, ports, modelFile };
|
|
224
|
+
}
|
package/lib/supervise.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Runs several long-lived processes side by side in one terminal: output prefixed with the process name,
|
|
2
|
+
// Ctrl+c (or any one of them exiting) stops all of them, children included.
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { invocation } from './scaffold.js';
|
|
5
|
+
|
|
6
|
+
// Stops a process and everything it started. POSIX: the child leads its own process group, so one signal
|
|
7
|
+
// reaches the tree. Windows has no groups, taskkill walks the tree instead.
|
|
8
|
+
export function killTree(pid, signal = 'SIGINT', platform = process.platform) {
|
|
9
|
+
if (platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
10
|
+
else try { process.kill(-pid, signal); } catch {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// specs: [{ name, file, args, cwd }]. Resolves with the exit code once everything has stopped.
|
|
14
|
+
export function supervise(specs, { log = console.log, graceMs = 3000, handleSignals = true } = {}) {
|
|
15
|
+
const width = Math.max(...specs.map((s) => s.name.length));
|
|
16
|
+
return new Promise((done) => {
|
|
17
|
+
let stopping = false;
|
|
18
|
+
let exitCode = 0;
|
|
19
|
+
let running = specs.length;
|
|
20
|
+
const children = [];
|
|
21
|
+
|
|
22
|
+
const finish = () => done(exitCode);
|
|
23
|
+
const stop = (code = 0) => {
|
|
24
|
+
if (stopping) return;
|
|
25
|
+
stopping = true;
|
|
26
|
+
exitCode = code;
|
|
27
|
+
for (const child of children) if (child.exitCode === null) killTree(child.pid);
|
|
28
|
+
setTimeout(finish, graceMs).unref();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const spec of specs) {
|
|
32
|
+
const inv = invocation(spec.file, spec.args);
|
|
33
|
+
// POSIX: detached makes the child its own group leader (see killTree). Windows: it would open a console window.
|
|
34
|
+
const child = spawn(inv.file, inv.args, { cwd: spec.cwd, shell: inv.shell, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
35
|
+
children.push(child);
|
|
36
|
+
for (const out of [child.stdout, child.stderr]) {
|
|
37
|
+
let rest = '';
|
|
38
|
+
out.on('data', (chunk) => {
|
|
39
|
+
const lines = (rest + chunk).split('\n');
|
|
40
|
+
rest = lines.pop();
|
|
41
|
+
for (const line of lines) log(`${spec.name.padEnd(width)} | ${line}`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
child.on('error', (err) => {
|
|
45
|
+
log(`${spec.name.padEnd(width)} | failed to start: ${err.message}`);
|
|
46
|
+
stop(1);
|
|
47
|
+
});
|
|
48
|
+
child.on('exit', (code) => {
|
|
49
|
+
if (!stopping) log(`${spec.name.padEnd(width)} | exited (${code}), stopping the rest`);
|
|
50
|
+
stop(code ?? 0);
|
|
51
|
+
if (--running === 0) finish();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (handleSignals) {
|
|
56
|
+
process.once('SIGINT', () => stop());
|
|
57
|
+
process.once('SIGTERM', () => stop());
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Decides whether a slice's UI needs work: a hash of what the board says about the UI (the screen nodes'
|
|
2
|
+
// node hash, the user's `codeGen.uiPrompts`) is recorded after each
|
|
3
|
+
// successful UI build, and compared on the next one. Only a difference sends the agent back to the UI.
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
export const BASELINE_FILE = 'ui-baseline.json';
|
|
9
|
+
|
|
10
|
+
const sha = (value) => createHash('sha256').update(stable(value)).digest('hex').slice(0, 16);
|
|
11
|
+
|
|
12
|
+
// JSON with sorted keys, so key order on the board never reads as a change.
|
|
13
|
+
function stable(value) {
|
|
14
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
15
|
+
if (value && typeof value === 'object') {
|
|
16
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(',')}}`;
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(value ?? null);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const readJson = (path) => {
|
|
22
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The slice's folder under `.slices/`, found by id or (case-insensitive) title.
|
|
26
|
+
export function findSliceDir(kitDir, idOrTitle) {
|
|
27
|
+
const slicesDir = join(kitDir, '.slices');
|
|
28
|
+
if (!existsSync(slicesDir)) return null;
|
|
29
|
+
const wanted = String(idOrTitle).trim().toLowerCase();
|
|
30
|
+
for (const ctx of readdirSync(slicesDir, { withFileTypes: true })) {
|
|
31
|
+
if (!ctx.isDirectory()) continue;
|
|
32
|
+
for (const dir of readdirSync(join(slicesDir, ctx.name), { withFileTypes: true })) {
|
|
33
|
+
if (!dir.isDirectory()) continue;
|
|
34
|
+
const path = join(slicesDir, ctx.name, dir.name);
|
|
35
|
+
const slice = readJson(join(path, 'slice.json'));
|
|
36
|
+
if (slice && (slice.id === idOrTitle || slice.title?.toLowerCase() === wanted)) return { path, slice };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Lists of the slice that are not board elements (or are hashed on their own).
|
|
43
|
+
const NOT_ELEMENTS = new Set(['screens', 'comments', 'screenImages']);
|
|
44
|
+
const BATCH = 50;
|
|
45
|
+
|
|
46
|
+
// `fetchNodes(ids)` -> the board's nodes for those ids (POST /nodes/query).
|
|
47
|
+
// The board stamps every node with a `hash`; use it as is. Content hash only as a fallback, for a board
|
|
48
|
+
// (or a /nodes/query) that does not return it.
|
|
49
|
+
// scope 'backend' (a kit that builds no UI) leaves out the screens and the UI prompts: a screen edit must not
|
|
50
|
+
// make it rebuild.
|
|
51
|
+
export async function fingerprint({ slice, fetchNodes, scope = 'ui' }) {
|
|
52
|
+
const ui = scope !== 'backend';
|
|
53
|
+
const prompts = ui ? slice.codeGen?.uiPrompts ?? [] : [];
|
|
54
|
+
const screens = ui ? (slice.screens ?? []).filter((s) => s?.id) : [];
|
|
55
|
+
// Every element the slice carries — commands, events, read models, processors, tables, actors, aggregates,
|
|
56
|
+
// specifications, ... — so a new list the export grows is covered without touching this.
|
|
57
|
+
const elements = Object.entries(slice)
|
|
58
|
+
.filter(([k, v]) => !NOT_ELEMENTS.has(k) && Array.isArray(v))
|
|
59
|
+
.flatMap(([, v]) => v)
|
|
60
|
+
.filter((e) => e && typeof e === 'object' && e.id);
|
|
61
|
+
const ids = [...new Set([...screens, ...elements].map((e) => e.id))];
|
|
62
|
+
const byId = new Map();
|
|
63
|
+
for (let i = 0; i < ids.length; i += BATCH) {
|
|
64
|
+
for (const node of await fetchNodes(ids.slice(i, i + BATCH))) byId.set(node.id, node);
|
|
65
|
+
}
|
|
66
|
+
const missing = screens.filter((s) => !byId.has(s.id)).map((s) => s.title ?? s.id);
|
|
67
|
+
if (missing.length) throw new Error(`screen node(s) not found on the board: ${missing.join(', ')}`);
|
|
68
|
+
// One entry per node, so a later check can say *which* ones moved, not just that something did.
|
|
69
|
+
const entry = (kind) => (item) => {
|
|
70
|
+
const node = byId.get(item.id);
|
|
71
|
+
const base = { id: item.id, kind, title: item.title ?? node?.node?.data?.title ?? node?.data?.title ?? item.id };
|
|
72
|
+
if (!node) return { ...base, hash: 'missing' };
|
|
73
|
+
if (node.hash) return { ...base, hash: node.hash };
|
|
74
|
+
return { ...base, hash: sha({ content: node.meta ?? node.data ?? null, fields: item.fields ?? [] }) };
|
|
75
|
+
};
|
|
76
|
+
const screenNodes = screens.map(entry('screen'));
|
|
77
|
+
const elementNodes = Object.entries(slice)
|
|
78
|
+
.filter(([k, v]) => !NOT_ELEMENTS.has(k) && Array.isArray(v))
|
|
79
|
+
.flatMap(([k, v]) => v.filter((e) => e && typeof e === 'object' && e.id).map(entry(k)));
|
|
80
|
+
return {
|
|
81
|
+
screen: screenNodes.length ? sha(screenNodes.map((n) => [n.id, n.hash])) : null,
|
|
82
|
+
elements: elementNodes.length ? sha(elementNodes.map((n) => [n.id, n.hash])) : null,
|
|
83
|
+
prompts: sha(prompts),
|
|
84
|
+
promptList: prompts,
|
|
85
|
+
nodes: [...screenNodes, ...elementNodes],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readBaseline(sliceDir) {
|
|
90
|
+
return readJson(join(sliceDir, BASELINE_FILE));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function writeBaseline(sliceDir, fp, scope = 'ui') {
|
|
94
|
+
const baseline = { scope, screenHash: fp.screen, elementsHash: fp.elements, uiPromptsHash: fp.prompts, uiPrompts: fp.promptList, nodes: fp.nodes, recordedAt: new Date().toISOString() };
|
|
95
|
+
writeFileSync(join(sliceDir, BASELINE_FILE), JSON.stringify(baseline, null, 2) + '\n');
|
|
96
|
+
return baseline;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// What differs node by node, against a baseline that recorded its nodes ({id, kind, title, hash}).
|
|
100
|
+
function diffNodes(before, now) {
|
|
101
|
+
const was = new Map(before.map((n) => [n.id, n]));
|
|
102
|
+
const is = new Map(now.map((n) => [n.id, n]));
|
|
103
|
+
return {
|
|
104
|
+
changedNodes: now.filter((n) => was.has(n.id) && was.get(n.id).hash !== n.hash),
|
|
105
|
+
addedNodes: now.filter((n) => !was.has(n.id)),
|
|
106
|
+
removedNodes: before.filter((n) => !is.has(n.id)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Verdict: NO_BASELINE | UNCHANGED | CHANGED, with which parts changed, which nodes, and the prompts that are new.
|
|
111
|
+
// A baseline from before elements/nodes were tracked has no elementsHash/nodes — that is not a change.
|
|
112
|
+
export function compare(baseline, fp, scope = 'ui') {
|
|
113
|
+
const none = { changedNodes: [], addedNodes: [], removedNodes: [] };
|
|
114
|
+
// A baseline recorded under another scope says nothing about this one.
|
|
115
|
+
if (!baseline || (baseline.scope ?? 'ui') !== scope) return { verdict: 'NO_BASELINE', screenChanged: true, elementsChanged: true, promptsChanged: fp.promptList.length > 0, newPrompts: fp.promptList, ...none };
|
|
116
|
+
const screenChanged = baseline.screenHash !== fp.screen;
|
|
117
|
+
const elementsChanged = baseline.elementsHash !== undefined && baseline.elementsHash !== fp.elements;
|
|
118
|
+
const promptsChanged = baseline.uiPromptsHash !== fp.prompts;
|
|
119
|
+
const seen = new Set(baseline.uiPrompts ?? []);
|
|
120
|
+
const newPrompts = promptsChanged ? fp.promptList.filter((p) => !seen.has(p)) : [];
|
|
121
|
+
const nodes = Array.isArray(baseline.nodes) ? diffNodes(baseline.nodes, fp.nodes) : none;
|
|
122
|
+
return { verdict: screenChanged || elementsChanged || promptsChanged ? 'CHANGED' : 'UNCHANGED', screenChanged, elementsChanged, promptsChanged, newPrompts, ...nodes };
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -85,10 +85,23 @@ export function formatPayload(e) {
|
|
|
85
85
|
return JSON.stringify(rest, null, 2).split('\n').map((l) => ` ${l}`).join('\n');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// The kit's first look at each slice: its first `board-observed` event. A fresh kit records every slice already on
|
|
89
|
+
// the board this way, which says nothing happened — the ids to leave out of the default view of the log.
|
|
90
|
+
export function baselineIds(events) {
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const ids = new Set();
|
|
93
|
+
for (const e of events) {
|
|
94
|
+
if (e.type !== 'board-observed' || !e.slice || seen.has(e.slice)) continue;
|
|
95
|
+
seen.add(e.slice);
|
|
96
|
+
ids.add(e.id);
|
|
97
|
+
}
|
|
98
|
+
return ids;
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
// types / slice (an id prefix or part of a title) / tag / board (an id prefix — a global kit's log mixes boards) narrow
|
|
89
|
-
// the log; last keeps the final N that remain.
|
|
90
|
-
export function filterEvents(events, { types, slice, tag, board, last } = {}, titles = {}) {
|
|
91
|
-
let out = events;
|
|
102
|
+
// the log; omit is a set of event ids to leave out (see baselineIds); last keeps the final N that remain.
|
|
103
|
+
export function filterEvents(events, { types, slice, tag, board, last, omit } = {}, titles = {}) {
|
|
104
|
+
let out = omit?.size ? events.filter((e) => !omit.has(e.id)) : events;
|
|
92
105
|
if (board) out = out.filter((e) => String(e.meta?.board_id ?? '').toLowerCase().startsWith(board.toLowerCase()));
|
|
93
106
|
if (types?.length) out = out.filter((e) => types.includes(e.type));
|
|
94
107
|
if (slice) {
|
|
@@ -15,7 +15,7 @@ import { randomUUID } from 'crypto';
|
|
|
15
15
|
import { createRealtimeAdapter, keepSubscribed } from './adapters/realtime-adapter.js';
|
|
16
16
|
import { backoffMs, sleep } from './backoff.js';
|
|
17
17
|
import { adoptAgentEdits, observeBoard, projectStatus, statusOf } from './slice-projection.js';
|
|
18
|
-
import { fold } from './slice-state.js';
|
|
18
|
+
import { fold, statusKey } from './slice-state.js';
|
|
19
19
|
import { readEvents } from './slice-events.js';
|
|
20
20
|
import { claimSlice, recordEvent, settleSlice, settleLeftoverClaims } from './slice-run.js';
|
|
21
21
|
import { isTreeClean } from './slice-git.js';
|
|
@@ -273,8 +273,8 @@ export async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
273
273
|
if (!activeCtx || !contexts[activeCtx]) {
|
|
274
274
|
// First run (or the current context disappeared): seed with a context that
|
|
275
275
|
// has planned work, else the first one. This is the ONLY place we choose it.
|
|
276
|
-
const pickup = readPickupStatus(kitDir)
|
|
277
|
-
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status
|
|
276
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
277
|
+
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => statusKey(s.status) === pickup));
|
|
278
278
|
activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
|
|
279
279
|
writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
|
|
280
280
|
}
|
|
@@ -377,9 +377,9 @@ async function handleSliceChanged(payload, cfg, kitDir) {
|
|
|
377
377
|
await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
378
378
|
console.error('[agent] Slice persist error:', err),
|
|
379
379
|
);
|
|
380
|
-
// Slices in the kit's pickup status (Planned, or
|
|
380
|
+
// Slices in the kit's pickup status (Planned, or Waiting for UI kits) are handled by
|
|
381
381
|
// onPlannedSlice directly — no task needed.
|
|
382
|
-
if ((payload.sliceStatus
|
|
382
|
+
if (statusKey(payload.sliceStatus) !== statusKey(readPickupStatus(kitDir))) {
|
|
383
383
|
try {
|
|
384
384
|
queueTask({ kitDir, payload, meta: eventMeta(cfg) });
|
|
385
385
|
console.log(`[agent] Task queued — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
@@ -490,15 +490,19 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', createAdap
|
|
|
490
490
|
// ── Ralph loop ────────────────────────────────────────────────────────────────
|
|
491
491
|
|
|
492
492
|
// The slice status this kit claims work from. A stack declares it in `lib/kit.json`
|
|
493
|
-
// ({ "pickupStatus": "
|
|
493
|
+
// ({ "pickupStatus": "Waiting" } for the UI kits, which build what a backend kit handed over);
|
|
494
494
|
// absent means "Planned", the backend default. `run --status-trigger <status>` (RALPH_STATUS_TRIGGER)
|
|
495
495
|
// overrides either for that run.
|
|
496
|
+
// `Handover` was renamed `Waiting`; a kit.json or --status-trigger written before that still says it,
|
|
497
|
+
// and a board served by an older platform still reports it.
|
|
498
|
+
const canonicalStatus = (s) => (statusKey(s) === 'waiting' ? 'Waiting' : s);
|
|
499
|
+
|
|
496
500
|
export function readPickupStatus(kitDir) {
|
|
497
501
|
const override = (process.env.RALPH_STATUS_TRIGGER || '').trim();
|
|
498
|
-
if (override) return override;
|
|
502
|
+
if (override) return canonicalStatus(override);
|
|
499
503
|
try {
|
|
500
504
|
const s = JSON.parse(readFileSync(join(kitDir, 'lib', 'kit.json'), 'utf-8')).pickupStatus;
|
|
501
|
-
if (typeof s === 'string' && s.trim()) return s.trim();
|
|
505
|
+
if (typeof s === 'string' && s.trim()) return canonicalStatus(s.trim());
|
|
502
506
|
} catch {}
|
|
503
507
|
return 'Planned';
|
|
504
508
|
}
|
|
@@ -533,14 +537,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
|
|
|
533
537
|
try {
|
|
534
538
|
others = readdirSync(slicesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
535
539
|
} catch {}
|
|
536
|
-
const pickup = readPickupStatus(kitDir)
|
|
540
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
537
541
|
const candidates = [...(currentCtx ? [currentCtx] : []), ...others.filter((c) => c !== currentCtx)];
|
|
538
542
|
for (const ctx of candidates) {
|
|
539
543
|
const indexPath = join(slicesDir, ctx, 'index.json');
|
|
540
544
|
if (!existsSync(indexPath)) continue;
|
|
541
545
|
try {
|
|
542
546
|
const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
543
|
-
const planned = slices && slices.find((s) => (s.status
|
|
547
|
+
const planned = slices && slices.find((s) => statusKey(s.status) === pickup && !skipIds.has(s.id));
|
|
544
548
|
if (!planned) continue;
|
|
545
549
|
if (ctx !== currentCtx) {
|
|
546
550
|
writeFileSync(join(slicesDir, 'current_context.json'), JSON.stringify({ name: ctx }, null, 2), 'utf-8');
|
|
@@ -555,14 +559,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
|
|
|
555
559
|
// Ids of every slice currently waiting for pickup, across all contexts.
|
|
556
560
|
function listPlannedIds(kitDir) {
|
|
557
561
|
const slicesDir = join(kitDir, '.slices');
|
|
558
|
-
const pickup = readPickupStatus(kitDir)
|
|
562
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
559
563
|
const ids = new Set();
|
|
560
564
|
try {
|
|
561
565
|
for (const d of readdirSync(slicesDir, { withFileTypes: true })) {
|
|
562
566
|
if (!d.isDirectory()) continue;
|
|
563
567
|
try {
|
|
564
568
|
const { slices } = JSON.parse(readFileSync(join(slicesDir, d.name, 'index.json'), 'utf-8'));
|
|
565
|
-
for (const s of slices ?? []) if ((s.status
|
|
569
|
+
for (const s of slices ?? []) if (statusKey(s.status) === pickup) ids.add(s.id);
|
|
566
570
|
} catch {}
|
|
567
571
|
}
|
|
568
572
|
} catch {}
|
|
@@ -666,7 +670,7 @@ async function runWithRetry(label, fn) {
|
|
|
666
670
|
// alone, the agent read it as background, answered "what would you like me to help with?" with
|
|
667
671
|
// no tool calls, and a Planned slice stayed Planned until the stuck-slice guard blocked it. So
|
|
668
672
|
// every turn ends with the concrete request — here, once, for every runner.
|
|
669
|
-
// Whether a backend kit finishes UI-bearing slices at `
|
|
673
|
+
// Whether a backend kit finishes UI-bearing slices at `Waiting` instead of `Done`: this run's
|
|
670
674
|
// `run --handover` (RALPH_HANDOVER) over the project's `handover` setting in config.json.
|
|
671
675
|
export function resolveHandover(cfg) {
|
|
672
676
|
return process.env.RALPH_HANDOVER === '1' || cfg?.handover === true;
|
|
@@ -720,8 +724,8 @@ function taskRequest(planned = null, cfg = null, pickupStatus = 'Planned', queue
|
|
|
720
724
|
? `build the ${pickupStatus} slice "${planned.title}" (id=${planned.id}, context="${planned.ctx}")`
|
|
721
725
|
: `process this queued task (id=${queued?.id}) — the slice change that queued it: ${JSON.stringify(queued?.payload ?? {})}`;
|
|
722
726
|
// Stated explicitly so the agent never has to work it out from config.json: this run's effective
|
|
723
|
-
// value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup
|
|
724
|
-
const handover = planned && pickupStatus
|
|
727
|
+
// value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup Waiting) ignore it.
|
|
728
|
+
const handover = planned && statusKey(pickupStatus) !== 'waiting' ? `\nHandover: ${resolveHandover(cfg) ? 'on' : 'off'}\n` : '';
|
|
725
729
|
return `\n\n---\n\nYour task now: ${task}, following the instructions above. Start immediately.\n${handover}`;
|
|
726
730
|
}
|
|
727
731
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// no board (--local): nothing is pushed or read, the log alone decides.
|
|
7
7
|
|
|
8
8
|
import { appendEvent, readEvents } from './slice-events.js';
|
|
9
|
-
import { fold, decideSettle, decideRecovery } from './slice-state.js';
|
|
9
|
+
import { fold, decideSettle, decideRecovery, statusKey } from './slice-state.js';
|
|
10
10
|
import { adoptAgentEdits, projectStatus } from './slice-projection.js';
|
|
11
11
|
import { discardUnfinishedWork, gitBase } from './slice-git.js';
|
|
12
12
|
|
|
@@ -71,7 +71,7 @@ export async function claimSlice({ kitDir, planned, board, meta, cleanTree, pid
|
|
|
71
71
|
console.error(`[ralph] Could not read the board before claiming "${planned.title}":`, err.message);
|
|
72
72
|
return 'lost';
|
|
73
73
|
}
|
|
74
|
-
if (current && current
|
|
74
|
+
if (current && statusKey(current) !== statusKey(pickupStatus)) {
|
|
75
75
|
console.log(`[ralph] "${planned.title}" is ${current} on the board, not ${pickupStatus} — someone else got there first.`);
|
|
76
76
|
recordEvent(kitDir, { type: 'board-observed', slice: planned.id, status: current }, meta);
|
|
77
77
|
return 'lost';
|