@eventmodelers/cli 1.0.85 → 1.0.86
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/cli.js +107 -9
- package/package.json +1 -1
- package/shared/build-kit/lib/ralph.js +40 -11
- package/shared/build-kit/lib/tracing.js +92 -0
- package/shared/build-kit/ralph-claude.js +23 -3
- package/shared/build-kit/ralph-exec.js +3 -1
- package/shared/skills/learn-eventmodelers-api/SKILL.md +0 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +2 -1
- package/stacks/modeling-kit/templates/.claude/skills/html-screen/SKILL.md +1 -0
- package/stacks/react/templates/build-kit/README.md +0 -134
- package/stacks/react/templates/build-kit/lib/ralph.js +0 -517
- package/stacks/react/templates/build-kit/package.json +0 -9
- package/stacks/react/templates/build-kit/ralph-claude.js +0 -107
- package/stacks/react/templates/build-kit/ralph-local-ai.js +0 -40
package/cli.js
CHANGED
|
@@ -32,13 +32,14 @@ const __dirname = dirname(__filename);
|
|
|
32
32
|
|
|
33
33
|
// Each stack is a template set under stacks/<key>/templates/{.claude,root,<kitSubdir>}.
|
|
34
34
|
// Stacks with useShared:true also get shared/build-kit/* copied into their kit dir
|
|
35
|
-
//
|
|
35
|
+
// (ralph.js, ralph-claude.js, ralph-local-ai.js, ralph-exec.js, ralph.sh, realtime-agent.js,
|
|
36
36
|
// code-export.mjs, lib/agent.sh, lib/local-ai-agent.js, package.json, README.md) —
|
|
37
37
|
// those files have no per-stack content, so they live once instead of being
|
|
38
38
|
// copy-pasted into every stack (that copy-pasting is exactly how they drifted out
|
|
39
39
|
// of sync before: a bugfix or default landing in one stack's copy but not another's).
|
|
40
|
-
// Each stack's own templates/<kitSubdir>/*
|
|
41
|
-
//
|
|
40
|
+
// Each stack's own templates/<kitSubdir>/* supplies the genuine per-stack files
|
|
41
|
+
// (lib/prompt.md, lib/backend-prompt.md, lib/AGENT.md, checks, etc.); shared/build-kit/*
|
|
42
|
+
// is copied after it, so the shared runtime always wins over any copy a stack still ships.
|
|
42
43
|
// modeling-kit (below) is the one kit that opts out of all of this (useShared:false)
|
|
43
44
|
// — it has no cold-spawn/tasks.json runtime at all, so none of shared/build-kit/*
|
|
44
45
|
// applies to it; see its own templates/kit for its (much smaller) self-contained set.
|
|
@@ -1011,10 +1012,13 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
1011
1012
|
mkdirSync(kitDir, { recursive: true });
|
|
1012
1013
|
console.log(`📦 Installing agent kit into ${stackCfg.kitDirName}/...`);
|
|
1013
1014
|
|
|
1015
|
+
copyDirContents(join(templatesSource, stackCfg.kitSubdir), kitDir, { skip: ['.eventmodelers'] });
|
|
1016
|
+
// Shared runtime goes on LAST so it always wins: a stack (built-in or --git) that still
|
|
1017
|
+
// ships its own ralph.js / ralph-claude.js / ... is shipping a stale copy, and letting it
|
|
1018
|
+
// overlay the shared one silently drops runtime fixes (e.g. agent_id on the heartbeat).
|
|
1014
1019
|
if (stackCfg.useShared) {
|
|
1015
1020
|
copyDirContents(sharedBuildKit, kitDir);
|
|
1016
1021
|
}
|
|
1017
|
-
copyDirContents(join(templatesSource, stackCfg.kitSubdir), kitDir, { skip: ['.eventmodelers'] });
|
|
1018
1022
|
|
|
1019
1023
|
// An outdated community/--git stack that still ships root/CLAUDE.md (the pre-fix
|
|
1020
1024
|
// layout every built-in stack used to follow too) gets it relocated here instead
|
|
@@ -1122,6 +1126,8 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
1122
1126
|
// placeholder, never the literal secret (see connect/SKILL.md's Security notes).
|
|
1123
1127
|
ensureMcpRegistered(targetDir, config.baseUrl || DEFAULT_BASE_URL);
|
|
1124
1128
|
ensureEnvToken(targetDir, config.token);
|
|
1129
|
+
// Only build stacks trace (slice build cost); they are the ones running the shared runtime.
|
|
1130
|
+
if (stackCfg.useShared) await configureAgentTracing(configPath, options.print);
|
|
1125
1131
|
}
|
|
1126
1132
|
|
|
1127
1133
|
// --- 6. Install manifest (drives precise `uninstall` later) ---
|
|
@@ -1154,6 +1160,23 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
1154
1160
|
console.log(` npx @eventmodelers/cli init-agents\n`);
|
|
1155
1161
|
}
|
|
1156
1162
|
|
|
1163
|
+
// `agentTracing` in config.json: whether a build agent reports what each slice build cost to the
|
|
1164
|
+
// platform (see agent-tracking.md). Asked once per project; off unless the user opts in — unset
|
|
1165
|
+
// (e.g. --print installs) is off, and off means the runner sends nothing.
|
|
1166
|
+
// `reask` (a bare `init-config`) asks again even when it is already set, defaulting to the
|
|
1167
|
+
// current answer.
|
|
1168
|
+
async function configureAgentTracing(configPath, print, reask = false) {
|
|
1169
|
+
const current = readJsonSafe(configPath);
|
|
1170
|
+
if (print || (typeof current.agentTracing === 'boolean' && !reask)) return;
|
|
1171
|
+
const agentTracing = await selectPrompt('Should EM-Studio track the agent cost of each slice?', [
|
|
1172
|
+
{ label: 'Yes — track the cost per slice', value: true },
|
|
1173
|
+
{ label: 'No — send nothing', value: false },
|
|
1174
|
+
], current.agentTracing === true ? 0 : 1);
|
|
1175
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
1176
|
+
writeFileSync(configPath, JSON.stringify({ ...current, agentTracing }, null, 2));
|
|
1177
|
+
console.log(` ✓ Agent tracing ${agentTracing ? 'on' : 'off'} — change it with \`eventmodelers config --agent-tracing on|off\``);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1157
1180
|
// Extracted from installStack so `init-config` can reuse the exact same
|
|
1158
1181
|
// paste/manual/instructions/skip flow without also scaffolding a stack.
|
|
1159
1182
|
// `overrides` are values passed directly on the command line (--token, --board-id,
|
|
@@ -1254,6 +1277,43 @@ async function configureCredentials({ config, configPath, targetDir, requiredFie
|
|
|
1254
1277
|
return config;
|
|
1255
1278
|
}
|
|
1256
1279
|
|
|
1280
|
+
// `re-init` for a build kit: re-copy shared/build-kit (ralph.js, the runners, tracing, adapters)
|
|
1281
|
+
// over the installed kit, then re-run the config steps. Stack templates and skills are untouched.
|
|
1282
|
+
async function refreshSharedRuntime({ targetDir, kitDir, globalOpts, opts }) {
|
|
1283
|
+
console.log(`📦 Refreshing the shared runtime in ${relative(targetDir, kitDir)}/ (stack files untouched)...`);
|
|
1284
|
+
copyDirContents(join(__dirname, 'shared', 'build-kit'), kitDir);
|
|
1285
|
+
for (const script of ['ralph.sh', 'lib/agent.sh', 'ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
|
|
1286
|
+
const p = join(kitDir, script);
|
|
1287
|
+
if (existsSync(p)) {
|
|
1288
|
+
try { execSync(`chmod +x "${p}"`); } catch {}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
console.log('📦 Installing kit dependencies...');
|
|
1292
|
+
try {
|
|
1293
|
+
execSync('npm install', { cwd: kitDir, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
1294
|
+
} catch {
|
|
1295
|
+
console.error(' ⚠️ npm install failed in kit — run it manually');
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
console.log('🔐 Configuring...');
|
|
1299
|
+
const configPath = globalOpts.config
|
|
1300
|
+
? resolve(targetDir, globalOpts.config)
|
|
1301
|
+
: join(targetDir, '.eventmodelers', 'config.json');
|
|
1302
|
+
const overrides = { ...credentialOverridesFromOpts(opts), ...identityOverridesFromOpts(opts) };
|
|
1303
|
+
const cfg = await configureCredentials({
|
|
1304
|
+
config: loadEffectiveConfig(targetDir, kitDir, globalOpts.config).config,
|
|
1305
|
+
configPath,
|
|
1306
|
+
targetDir,
|
|
1307
|
+
requiredFields: ['organizationId', 'boardId', 'token'],
|
|
1308
|
+
overrides,
|
|
1309
|
+
print: globalOpts.print,
|
|
1310
|
+
force: opts.force,
|
|
1311
|
+
});
|
|
1312
|
+
ensureMcpRegistered(targetDir, cfg.baseUrl || DEFAULT_BASE_URL);
|
|
1313
|
+
ensureEnvToken(targetDir, cfg.token);
|
|
1314
|
+
await configureAgentTracing(configPath, globalOpts.print, true);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1257
1317
|
// Configure the MCP server registration for a project — split out of `init` into
|
|
1258
1318
|
// its own command (`init-mcp`) since not every harness/workflow wants an
|
|
1259
1319
|
// automatic .claude/settings.json edit or an interactive "connect elsewhere?"
|
|
@@ -1594,16 +1654,22 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print,
|
|
|
1594
1654
|
// common case to one keystroke. Skipped when there is no one to ask (--print, or a
|
|
1595
1655
|
// non-interactive stdin such as CI or a process supervisor), where the resolved value
|
|
1596
1656
|
// stands on its own exactly as before.
|
|
1657
|
+
//
|
|
1658
|
+
// The answer may also be the whole credentials blob from the account page — it names the
|
|
1659
|
+
// board too, so it answers this question and the "where do its credentials come from" one
|
|
1660
|
+
// below in a single paste.
|
|
1597
1661
|
let boardChosen = !!(explicit.boardId || process.env.EVENTMODELERS_BOARD_ID);
|
|
1662
|
+
let pasted = null;
|
|
1598
1663
|
if (!boardChosen && interactive) {
|
|
1599
1664
|
const answer = await prompt(boardId ? `\n Board ID [${boardId}]: ` : '\n Board ID: ');
|
|
1600
1665
|
if (answer) {
|
|
1601
|
-
|
|
1666
|
+
pasted = parseCredentialsPaste(answer, ['organizationId', 'token', 'boardId']);
|
|
1667
|
+
boardId = pasted ? pasted.boardId : answer;
|
|
1602
1668
|
boardChosen = true;
|
|
1603
1669
|
}
|
|
1604
1670
|
}
|
|
1605
1671
|
|
|
1606
|
-
let stored = boardId ? readJsonSafe(boardCredentialsPath(boardId)) : {};
|
|
1672
|
+
let stored = pasted ?? (boardId ? readJsonSafe(boardCredentialsPath(boardId)) : {});
|
|
1607
1673
|
|
|
1608
1674
|
// Follow a pointer left by an earlier answer: this directory (or the account config) keeps
|
|
1609
1675
|
// resolving to one board, but the credentials pasted for it named another. Without this the
|
|
@@ -1625,7 +1691,7 @@ async function resolveModelingCredentials(cwd, flags, explicitConfigPath, print,
|
|
|
1625
1691
|
// implied — explicit credentials on the command line — or when there is no one to ask:
|
|
1626
1692
|
// --print, or a non-interactive stdin such as CI or a supervisor that would otherwise
|
|
1627
1693
|
// hang here forever (those keep using whatever is on file, silently).
|
|
1628
|
-
if (interactive && !credentialsNamed) {
|
|
1694
|
+
if (!pasted && interactive && !credentialsNamed) {
|
|
1629
1695
|
const hasAccountWide = !!(walked.token && walked.organizationId);
|
|
1630
1696
|
|
|
1631
1697
|
// What "keep" would keep. A pointer entry is deliberately not offered: it says the
|
|
@@ -2836,9 +2902,9 @@ const REINITIABLE_STACKS = { ...STACKS, [MODELING_KIT.key]: MODELING_KIT, [BLANK
|
|
|
2836
2902
|
|
|
2837
2903
|
credentialFlags(program
|
|
2838
2904
|
.command('re-init')
|
|
2839
|
-
.description(
|
|
2905
|
+
.description("Refresh an installed kit from the current CLI version. For a build kit: re-copies this CLI's shared runtime (ralph.js, ralph-claude.js, tracing, adapters) into .build-kit and re-runs the config (credentials unless complete, agent tracing) — the stack's own files and skills are left alone, so this works for --git stacks too. --stack <name> instead refreshes everything from that built-in stack; --modeling refreshes .agent-modeling-kit. Never touches the project root scaffold or the root CLAUDE.md router.")
|
|
2840
2906
|
.option('--modeling', 'Refresh the modeling kit (.agent-modeling-kit) instead of a build kit')
|
|
2841
|
-
.option('--stack <name>', `
|
|
2907
|
+
.option('--stack <name>', `Also refresh the stack's own files and skills from this built-in stack (${Object.keys(REINITIABLE_STACKS).join(', ')}) — or switch a .build-kit install to a different stack. Without it, only the shared runtime and the config are refreshed`)
|
|
2842
2908
|
.option('--hooks', 'Install the slice commit-scope guard (.githooks/pre-commit) and wire it up via `git config core.hooksPath .githooks` — same as `init --hooks`, for turning it on after the fact without a full re-scaffold. Off by default.')
|
|
2843
2909
|
.option('--global', 'Re-install skills into ~/.claude/skills/ instead of the project — defaults to however they were originally installed')
|
|
2844
2910
|
.option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json')
|
|
@@ -2866,6 +2932,14 @@ credentialFlags(program
|
|
|
2866
2932
|
}
|
|
2867
2933
|
|
|
2868
2934
|
const manifest = readJsonSafe(join(kitDir, '.eventmodelers', 'install-manifest.json'));
|
|
2935
|
+
|
|
2936
|
+
// A build kit without --stack: refresh only what this CLI owns — the shared runtime and the
|
|
2937
|
+
// config — and never the stack's own files. Works for any stack, --git ones included.
|
|
2938
|
+
if (!opts.modeling && !opts.stack) {
|
|
2939
|
+
await refreshSharedRuntime({ targetDir, kitDir, globalOpts, opts });
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2869
2943
|
const stackKey = opts.modeling ? MODELING_KIT.key : (opts.stack || manifest.stack);
|
|
2870
2944
|
const stackCfg = stackKey ? REINITIABLE_STACKS[stackKey] : null;
|
|
2871
2945
|
|
|
@@ -3110,6 +3184,9 @@ credentialFlags(program
|
|
|
3110
3184
|
// (see the beta-api protected-resource-metadata incident this fixed).
|
|
3111
3185
|
ensureMcpRegistered(targetDir, cfg.baseUrl || DEFAULT_BASE_URL);
|
|
3112
3186
|
ensureEnvToken(targetDir, cfg.token);
|
|
3187
|
+
if (existsSync(join(targetDir, STACKS.node.kitDirName))) {
|
|
3188
|
+
await configureAgentTracing(configPath, globalOpts.print, !Object.values(overrides).some(Boolean));
|
|
3189
|
+
}
|
|
3113
3190
|
}
|
|
3114
3191
|
});
|
|
3115
3192
|
|
|
@@ -3205,6 +3282,12 @@ credentialFlags(program
|
|
|
3205
3282
|
// credentials resolved from flags/env/~/.eventmodelers — so the current directory is
|
|
3206
3283
|
// neither read nor written, and the command works from anywhere.
|
|
3207
3284
|
let kitDir = opts.global ? null : modelingKitDir;
|
|
3285
|
+
// A pre-config.js install (the old ralph-based kit) shares the dir name but can't
|
|
3286
|
+
// drive this loop — fall through to the global install rather than refusing to run.
|
|
3287
|
+
if (kitDir && !existsSync(join(kitDir, 'lib', 'config.js'))) {
|
|
3288
|
+
console.log(`ℹ️ Ignoring ${relative(cwd, kitDir)} — an outdated kit without lib/config.js (run \`init --modeling\` here to replace it). Using the global install.\n`);
|
|
3289
|
+
kitDir = null;
|
|
3290
|
+
}
|
|
3208
3291
|
let projectDir = kitDir ? resolve(kitDir, '..') : null;
|
|
3209
3292
|
let overrides = null;
|
|
3210
3293
|
if (!kitDir) {
|
|
@@ -3824,10 +3907,25 @@ program
|
|
|
3824
3907
|
program
|
|
3825
3908
|
.command('config')
|
|
3826
3909
|
.description('Print the fully resolved config (merged across the directory hierarchy + EVENTMODELERS_* env vars), with the token masked')
|
|
3910
|
+
.option('--agent-tracing <on|off>', "Turn agent tracing on or off for this project (saved as `agentTracing` in the project's .eventmodelers/config.json). Off: the build agent sends no slice cost data to the platform at all.")
|
|
3827
3911
|
.action((opts, command) => {
|
|
3828
3912
|
const cwd = process.cwd();
|
|
3829
3913
|
const kitDir = findInstalledKitDir(cwd);
|
|
3830
3914
|
const explicitConfig = command.optsWithGlobals().config;
|
|
3915
|
+
|
|
3916
|
+
if (opts.agentTracing !== undefined) {
|
|
3917
|
+
const value = String(opts.agentTracing).toLowerCase();
|
|
3918
|
+
if (!['on', 'off'].includes(value)) {
|
|
3919
|
+
console.error('❌ --agent-tracing takes "on" or "off"');
|
|
3920
|
+
process.exit(1);
|
|
3921
|
+
}
|
|
3922
|
+
const configPath = explicitConfig
|
|
3923
|
+
? resolve(explicitConfig)
|
|
3924
|
+
: join(kitDir ? dirname(kitDir) : cwd, '.eventmodelers', 'config.json');
|
|
3925
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
3926
|
+
writeFileSync(configPath, JSON.stringify({ ...readJsonSafe(configPath), agentTracing: value === 'on' }, null, 2));
|
|
3927
|
+
console.log(`✓ Agent tracing ${value} — saved to ${relative(cwd, configPath) || configPath}\n`);
|
|
3928
|
+
}
|
|
3831
3929
|
const { sources, config } = loadEffectiveConfig(cwd, kitDir, explicitConfig);
|
|
3832
3930
|
|
|
3833
3931
|
const resolved = { ...config };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.86",
|
|
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": {
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
//
|
|
4
4
|
// startRalph({ kitDir, projectDir, onTask, onPlannedSlice })
|
|
5
5
|
// onTask(prompt) — called when tasks.json has entries
|
|
6
|
-
// onPlannedSlice(prompt) — called when .slices/ has a "Planned" entry (omit to skip)
|
|
6
|
+
// onPlannedSlice(prompt, slice) — called when .slices/ has a "Planned" entry (omit to skip);
|
|
7
|
+
// `slice` ({sliceId, sliceTitle, context, ticketNumber, boardId, attempt}) lets a runner record what building it cost.
|
|
7
8
|
|
|
8
9
|
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
|
9
10
|
import { join, dirname } from 'path';
|
|
@@ -250,7 +251,9 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
250
251
|
mkdirSync(sliceDir, { recursive: true });
|
|
251
252
|
const sliceJsonPath = join(sliceDir, 'slice.json');
|
|
252
253
|
const existingSlice = existsSync(sliceJsonPath) ? JSON.parse(readFileSync(sliceJsonPath, 'utf-8')) : {};
|
|
253
|
-
|
|
254
|
+
// The summary omits ticketNumber when the slice has none, so a cleared ticket must be cleared
|
|
255
|
+
// here too — otherwise the old one survives the merge and lands on the next build's trace.
|
|
256
|
+
writeFileSync(sliceJsonPath, JSON.stringify({ ...existingSlice, ...slice, ticketNumber: slice.ticketNumber ?? null }, null, 2), 'utf-8');
|
|
254
257
|
}
|
|
255
258
|
}
|
|
256
259
|
|
|
@@ -417,6 +420,17 @@ function readCurrentContext(kitDir) {
|
|
|
417
420
|
try { return JSON.parse(readFileSync(ctxPath, 'utf-8')).name || null; } catch { return null; }
|
|
418
421
|
}
|
|
419
422
|
|
|
423
|
+
// The slice's ticket as of the last fetch, from its slice.json — a trace carries the ticket the
|
|
424
|
+
// slice is built under, since it can be reassigned between builds.
|
|
425
|
+
function readTicketNumber(kitDir, ctx, folder) {
|
|
426
|
+
if (!folder) return null;
|
|
427
|
+
try {
|
|
428
|
+
return JSON.parse(readFileSync(join(kitDir, '.slices', ctx, folder, 'slice.json'), 'utf-8')).ticketNumber ?? null;
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
420
434
|
// Returns the first Planned slice IN THE CURRENT CONTEXT ONLY. If the current
|
|
421
435
|
// context has no planned work, returns null so the loop waits — it must NEVER
|
|
422
436
|
// cross into another context to find something to build.
|
|
@@ -428,7 +442,7 @@ function getFirstPlannedSlice(kitDir) {
|
|
|
428
442
|
try {
|
|
429
443
|
const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
430
444
|
const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === 'planned');
|
|
431
|
-
if (planned) return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx: currentCtx };
|
|
445
|
+
if (planned) return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx: currentCtx, ticketNumber: readTicketNumber(kitDir, currentCtx, planned.folder) };
|
|
432
446
|
} catch {}
|
|
433
447
|
return null;
|
|
434
448
|
}
|
|
@@ -566,7 +580,14 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
566
580
|
}
|
|
567
581
|
|
|
568
582
|
const prompt = readFileSync(backendPromptFile, 'utf-8');
|
|
569
|
-
await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt
|
|
583
|
+
await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt, {
|
|
584
|
+
sliceId: planned.id,
|
|
585
|
+
sliceTitle: planned.title,
|
|
586
|
+
context: planned.ctx,
|
|
587
|
+
ticketNumber: planned.ticketNumber,
|
|
588
|
+
boardId: cfg.boardId ?? null,
|
|
589
|
+
attempt: stuckSlice.count,
|
|
590
|
+
}));
|
|
570
591
|
console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
571
592
|
if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
572
593
|
didWork = true;
|
|
@@ -590,15 +611,23 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
|
|
|
590
611
|
|
|
591
612
|
export { loadLocalConfig, fetchPlatformConfig, retryOn401, startRealtimeAgent };
|
|
592
613
|
|
|
614
|
+
// Who this process is. RALPH_AGENT_ID/RALPH_AGENT_NAME are `eventmodelers run --id/--name`,
|
|
615
|
+
// passed down as env (see cli.js's run dispatcher): a per-run identity override so a second
|
|
616
|
+
// agent of the same type can run side by side without the two overwriting each other's
|
|
617
|
+
// heartbeat row, and so the board can show a name instead of a bare uuid. An override skips
|
|
618
|
+
// ensureAgentId rather than overwriting it — the project's stable id stays on disk for the next
|
|
619
|
+
// plain run. Exported so a runner can resolve it at load time, before startRalph runs — the env
|
|
620
|
+
// it hands its `claude` is built then. Idempotent: ensureAgentId returns the id it persisted.
|
|
621
|
+
export function resolveAgentIdentity(kitDir, agentType = 'BUILD', cfg = loadLocalConfig(kitDir)) {
|
|
622
|
+
const agentId = process.env.RALPH_AGENT_ID || ensureAgentId(kitDir, agentType);
|
|
623
|
+
const agentName = process.env.RALPH_AGENT_NAME || cfg.agentName || '';
|
|
624
|
+
process.env.EVENTMODELERS_AGENT_ID = agentId;
|
|
625
|
+
return { agentId, agentName, agentType };
|
|
626
|
+
}
|
|
627
|
+
|
|
593
628
|
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
|
|
594
629
|
const local = loadLocalConfig(kitDir);
|
|
595
|
-
|
|
596
|
-
// (see cli.js's run dispatcher): a per-run identity override so a second agent of the same
|
|
597
|
-
// type can run side by side without the two overwriting each other's heartbeat row, and so
|
|
598
|
-
// the board can show a name instead of a bare uuid. An override skips ensureAgentId rather
|
|
599
|
-
// than overwriting it — the project's stable id stays on disk for the next plain run.
|
|
600
|
-
local.agentId = process.env.RALPH_AGENT_ID || ensureAgentId(kitDir, agentType);
|
|
601
|
-
if (process.env.RALPH_AGENT_NAME) local.agentName = process.env.RALPH_AGENT_NAME;
|
|
630
|
+
Object.assign(local, resolveAgentIdentity(kitDir, agentType, local));
|
|
602
631
|
|
|
603
632
|
console.log(`Ralph — kit: ${kitDir}`);
|
|
604
633
|
console.log(` project: ${projectDir}`);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// What a slice costs: one trace per build turn against one slice, posted to the platform's
|
|
2
|
+
// /api/org/:orgId/boards/:boardId/agent-traces. Only the runner can see what a turn cost, so it reports it.
|
|
3
|
+
//
|
|
4
|
+
// Best effort: each trace is appended to a local JSONL file, then POSTed once. No retries, no
|
|
5
|
+
// buffering. A failed upload is logged and forgotten; the file is the record.
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { dirname } from 'path';
|
|
10
|
+
|
|
11
|
+
export const newId = () => randomUUID();
|
|
12
|
+
|
|
13
|
+
const n = (v) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
14
|
+
|
|
15
|
+
/** Claude Code's `result` message — the only one whose numbers are final. The `assistant` events
|
|
16
|
+
* repeat one call's usage per content block AND report it mid-stream, so they cannot be used for
|
|
17
|
+
* accounting. `modelUsage` names the dominant model, which matters when subagents ran on a
|
|
18
|
+
* cheaper one than the lead. Cost is list price, even on a subscription. */
|
|
19
|
+
export function usageFromClaudeResult(msg) {
|
|
20
|
+
const u = msg?.usage ?? {};
|
|
21
|
+
const [dominant] = Object.entries(msg?.modelUsage ?? {})
|
|
22
|
+
.sort((a, b) => (b[1]?.outputTokens ?? 0) - (a[1]?.outputTokens ?? 0))[0] ?? [];
|
|
23
|
+
return {
|
|
24
|
+
inputTokens: n(u.input_tokens),
|
|
25
|
+
outputTokens: n(u.output_tokens),
|
|
26
|
+
cacheReadTokens: n(u.cache_read_input_tokens),
|
|
27
|
+
cacheWriteTokens: n(u.cache_creation_input_tokens),
|
|
28
|
+
costUsd: Number.isFinite(msg?.total_cost_usd) ? msg.total_cost_usd : null,
|
|
29
|
+
durationMs: Number.isFinite(msg?.duration_ms) ? msg.duration_ms : null,
|
|
30
|
+
model: dominant ?? null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A local model's per-round usage. Ollama reports it at the top level of /api/chat,
|
|
35
|
+
* OpenAI-compatible servers under `usage`. Cost 0: a local model is genuinely free. */
|
|
36
|
+
export function usageFromLocalAi(raw, durationMs) {
|
|
37
|
+
const openai = raw?.usage;
|
|
38
|
+
if (!openai && !Number.isFinite(raw?.eval_count) && !Number.isFinite(raw?.prompt_eval_count)) return null;
|
|
39
|
+
return {
|
|
40
|
+
inputTokens: n(openai ? openai.prompt_tokens : raw.prompt_eval_count),
|
|
41
|
+
outputTokens: n(openai ? openai.completion_tokens : raw.eval_count),
|
|
42
|
+
costUsd: 0,
|
|
43
|
+
durationMs: Number.isFinite(durationMs) ? durationMs : null,
|
|
44
|
+
model: typeof raw?.model === 'string' ? raw.model : null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Records slice build turns. The agent id is the session: the platform requires one, and a
|
|
49
|
+
* restart of the same build agent is still the same agent building the same slices. */
|
|
50
|
+
export function createSliceTracer({ baseUrl, token, organizationId, agentId, traceFile, log = () => {}, enabled = true } = {}) {
|
|
51
|
+
const canUpload = !!(enabled && baseUrl && token && organizationId && agentId);
|
|
52
|
+
|
|
53
|
+
async function post(trace) {
|
|
54
|
+
// The platform files a trace under the board in its path — a turn without a board has nowhere to go.
|
|
55
|
+
if (!canUpload || !trace.boardId) return;
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`${baseUrl}/api/org/${organizationId}/boards/${trace.boardId}/agent-traces`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json', 'x-token': token, 'x-agent-id': agentId },
|
|
60
|
+
body: JSON.stringify({ traces: [trace] }),
|
|
61
|
+
signal: AbortSignal.timeout(15_000),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) log(`trace: upload failed (HTTP ${res.status}${await res.text().then((t) => `: ${t}`).catch(() => '')}) — kept locally only`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log(`trace: upload error (${err.message}) — kept locally only`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function write(trace) {
|
|
70
|
+
if (!enabled || !traceFile) return;
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(dirname(traceFile), { recursive: true });
|
|
73
|
+
appendFileSync(traceFile, `${JSON.stringify(trace)}\n`);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (!write.warned) { write.warned = true; log(`trace: cannot write ${traceFile} (${err.message})`); }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
/** One build turn: `{sliceId, sliceTitle, context, ticketNumber, boardId, attempt, status}` plus its usage.
|
|
81
|
+
* ticketNumber is the slice's ticket at build time — it can change, so it travels with each turn. */
|
|
82
|
+
record({ sliceId, sliceTitle = null, context = null, ticketNumber = null, boardId = null, attempt = null, status = 'ok' }, usage) {
|
|
83
|
+
if (!sliceId || !usage) return;
|
|
84
|
+
const trace = {
|
|
85
|
+
id: newId(), occurredAt: new Date().toISOString(),
|
|
86
|
+
boardId, sliceId, sliceTitle, context, ticketNumber, agentId, sessionId: agentId, attempt, status, ...usage,
|
|
87
|
+
};
|
|
88
|
+
write(trace);
|
|
89
|
+
post(trace).catch(() => {});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// Ralph loop + realtime agent using Claude Code as the executor.
|
|
3
3
|
// Usage: node ralph-claude.js [project_dir]
|
|
4
4
|
|
|
5
|
-
import { startRalph, loadLocalConfig } from './lib/ralph.js';
|
|
5
|
+
import { startRalph, loadLocalConfig, resolveAgentIdentity } from './lib/ralph.js';
|
|
6
|
+
import { createSliceTracer, usageFromClaudeResult } from './lib/tracing.js';
|
|
6
7
|
import { spawn } from 'child_process';
|
|
7
|
-
import { dirname, resolve } from 'path';
|
|
8
|
+
import { dirname, join, resolve } from 'path';
|
|
8
9
|
import { fileURLToPath } from 'url';
|
|
9
10
|
|
|
10
11
|
const kitDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -12,6 +13,23 @@ const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir,
|
|
|
12
13
|
|
|
13
14
|
const cfg = loadLocalConfig(kitDir);
|
|
14
15
|
const localOnly = process.env.RALPH_LOCAL === '1';
|
|
16
|
+
// Resolved here, not left to startRalph: claudeEnv below is built at load time, and without it
|
|
17
|
+
// the agent id never reached `claude`, so its MCP calls went unattributed.
|
|
18
|
+
Object.assign(cfg, resolveAgentIdentity(kitDir, 'BUILD', cfg));
|
|
19
|
+
|
|
20
|
+
// What each slice build turn cost — see lib/tracing.js. Off unless the project opted in
|
|
21
|
+
// (`agentTracing: true`, see `eventmodelers config`); nothing is ever sent with --local.
|
|
22
|
+
const tracingOn = !localOnly && cfg.agentTracing === true;
|
|
23
|
+
if (!tracingOn && !localOnly) console.log('[ralph] agent tracing off — no slice cost data is sent (turn on: eventmodelers config --agent-tracing on)');
|
|
24
|
+
const tracer = createSliceTracer({
|
|
25
|
+
baseUrl: cfg.baseUrl,
|
|
26
|
+
token: cfg.token,
|
|
27
|
+
organizationId: cfg.organizationId,
|
|
28
|
+
agentId: cfg.agentId,
|
|
29
|
+
traceFile: join(projectDir, '.eventmodelers', 'trace', 'slices.jsonl'),
|
|
30
|
+
log: (line) => console.log(`[ralph] ${line}`),
|
|
31
|
+
enabled: tracingOn,
|
|
32
|
+
});
|
|
15
33
|
// --local must mean zero board contact — never hand Claude live board
|
|
16
34
|
// credentials via the inline header, even if config.json has them, or it'll
|
|
17
35
|
// treat them as already-connected and skip straight to board sync.
|
|
@@ -60,7 +78,8 @@ function describeToolUse(block) {
|
|
|
60
78
|
}
|
|
61
79
|
}
|
|
62
80
|
|
|
63
|
-
|
|
81
|
+
// `slice` is set for a planned-slice build (see startRalph) — the only turns that are traced.
|
|
82
|
+
function runClaude(prompt, slice = null) {
|
|
64
83
|
return new Promise((resolve, reject) => {
|
|
65
84
|
const proc = spawn('claude', [...claudeArgs, '-p', inlineHeader + prompt], {
|
|
66
85
|
cwd: projectDir,
|
|
@@ -89,6 +108,7 @@ function runClaude(prompt) {
|
|
|
89
108
|
}
|
|
90
109
|
} else if (msg.type === 'result') {
|
|
91
110
|
console.log(`done (${msg.duration_ms}ms${msg.total_cost_usd ? `, $${msg.total_cost_usd.toFixed(4)}` : ''})`);
|
|
111
|
+
if (slice) tracer.record({...slice, status: msg.is_error ? 'error' : 'ok'}, usageFromClaudeResult(msg));
|
|
92
112
|
}
|
|
93
113
|
}
|
|
94
114
|
});
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// RALPH_EXEC_CMD="opencode run" node ralph-exec.js
|
|
18
18
|
// Or persist it as localAi.exec in .eventmodelers/config.json.
|
|
19
19
|
|
|
20
|
-
import { startRalph, loadLocalConfig } from './lib/ralph.js';
|
|
20
|
+
import { startRalph, loadLocalConfig, resolveAgentIdentity } from './lib/ralph.js';
|
|
21
21
|
import { spawn } from 'child_process';
|
|
22
22
|
import { writeFileSync, mkdtempSync } from 'fs';
|
|
23
23
|
import { tmpdir } from 'os';
|
|
@@ -29,6 +29,8 @@ const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir,
|
|
|
29
29
|
|
|
30
30
|
const cfg = loadLocalConfig(kitDir);
|
|
31
31
|
const localOnly = process.env.RALPH_LOCAL === '1';
|
|
32
|
+
// Same as ralph-claude.js: childEnv is built at load time, before startRalph resolves identity.
|
|
33
|
+
Object.assign(cfg, resolveAgentIdentity(kitDir, 'BUILD', cfg));
|
|
32
34
|
const execCmd = process.env.RALPH_EXEC_CMD || cfg.localAi?.exec;
|
|
33
35
|
|
|
34
36
|
if (!execCmd) {
|
|
@@ -63,7 +63,6 @@ Write tools take their items as an array (`nodeIds[]`, `elements[]`, `connection
|
|
|
63
63
|
| `render_screen` | `boardId`, `nodeId`, `elements[]?` (SCREEN) or `pages[]?`+`backgroundColor?` (HTML_SCREEN), `description?` | Update an existing screen's content — exactly one of `elements`/`pages` | §4 `POST .../images/:id/sketch` + `image-nodes` |
|
|
64
64
|
| `add_field_examples` | `boardId`, `nodeId?`, `name?`, `cellName?`, `timelineId?` | Fill empty field examples using linked-node context | — (MCP-only convenience) |
|
|
65
65
|
| `get_attribute_chain` | `boardId`, `timelineId`, `targetCellName`, `sourceCellName` | Resolve every node between two cells, ordered target→source. Both cell names must come from a node's reported `cellName` (`get_board_outline`, `get_nodes`, `get_node`) — a guessed address that happens to exist silently resolves the wrong chain | — (MCP-only convenience) |
|
|
66
|
-
| `verify_screen` | `boardId`, `nodeId` | Check a screen node exists and has rendered content — works for both SCREEN and HTML_SCREEN, dispatching on the node's actual type | — (MCP-only convenience) |
|
|
67
66
|
| `get_image_snapshot_description` | `boardId`, `nodeId` | Load the `{elements:[...]}` sketch description from storage | — (reads what §4 sketch endpoints write) |
|
|
68
67
|
| `validate_slice_data` | `sliceData` | Offline validation of a `SliceDataOutput` payload — no board access | — (MCP-only, pure function) |
|
|
69
68
|
| `commit_board_to_git` | `boardId` | Force a git-extension commit/push, bypassing the autoCommit gate | — (MCP-only; git extension) |
|
package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md
CHANGED
|
@@ -231,6 +231,7 @@ mcp__eventmodelers__create_screen {
|
|
|
231
231
|
"nodeId": "<node-uuid>",
|
|
232
232
|
"chapterId": "<CHAPTER_ID>",
|
|
233
233
|
"cellId": "<actorRowId>-<columnId>",
|
|
234
|
+
"title": "<Screen Title>",
|
|
234
235
|
"pages": ["<div>...</div>"],
|
|
235
236
|
"description": "<concise description of what this screen shows>",
|
|
236
237
|
"fields": [ /* per "Mandatory Field Definitions" below — set in this same call */ ]
|
|
@@ -240,7 +241,7 @@ mcp__eventmodelers__create_screen {
|
|
|
240
241
|
|
|
241
242
|
**Fallback (no MCP):** see `references/api-fallback.md` — "Mandatory Screen Rendering — Step B: Create the HTML_SCREEN node".
|
|
242
243
|
|
|
243
|
-
The MCP `create_screen` call above already sets `meta.fields` (per "Mandatory Field Definitions" below) in the same call — no separate `node:changed` follow-up needed when using MCP.
|
|
244
|
+
The MCP `create_screen` call above already sets the node's `title` and `meta.fields` (per "Mandatory Field Definitions" below) in the same call — no separate `node:changed` follow-up needed when using MCP.
|
|
244
245
|
|
|
245
246
|
A storyboard screen is placed at a *provisional* position — Steps 4 and 5 wire it to its COMMAND / READMODEL once those exist, and may move it first. Pass `autoConnect: false` on `create_screen` here so the placement doesn't pre-wire the screen to whatever happens to sit in the adjacent column; the real `SCREEN → COMMAND` and `READMODEL → SCREEN` edges are created deliberately in Steps 4 and 5. When creating several screens whose HTML is already authored, put them all in one `create_screen` call's `screens` array with `autoConnect: false`.
|
|
246
247
|
|
|
@@ -140,6 +140,7 @@ mcp__eventmodelers__create_screen {
|
|
|
140
140
|
"nodeId": "<generated-uuid>",
|
|
141
141
|
"chapterId": "<CHAPTER_ID>",
|
|
142
142
|
"cellName": "<CELL_NAME>",
|
|
143
|
+
"title": "<Screen Title>",
|
|
143
144
|
"pages": ["<div>...</div>"],
|
|
144
145
|
"fields": [ /* optional — set meta.fields here too (see Step 5) instead of a separate call */ ]
|
|
145
146
|
}]
|