@eventmodelers/cli 1.0.85 → 1.0.87

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 CHANGED
@@ -125,8 +125,8 @@ npx @eventmodelers/cli init --stack <name> # scaffold a stack + install
125
125
  npx @eventmodelers/cli init --stack <name> --demo # same, plus a ready-made demo model in the kit's .slices/ to build against
126
126
  npx @eventmodelers/cli re-init # refresh an already-installed kit's scripts/skills only — never touches the root scaffold
127
127
  npx @eventmodelers/cli run # start the agent loop (ralph-claude.js) from the installed kit dir
128
- npx @eventmodelers/cli run --local-ai [target] # same, via a local/self-hosted model (ralph-local-ai.js)
129
- npx @eventmodelers/cli run --exec "<command>" # same, via an external agent harness (ralph-exec.js)
128
+ npx @eventmodelers/cli run --local-ai [target] # board work only via a local/self-hosted model (ralph-local-ai.js) — does NOT build slices; build locally with --exec
129
+ npx @eventmodelers/cli run --exec "<command>" # same, via an external agent harness (ralph-exec.js) — agent tracing needs its JSON flag, e.g. "codex exec --json --full-auto"
130
130
  npx @eventmodelers/cli run --bash # bash-only loop, no realtime (ralph.sh)
131
131
  npx @eventmodelers/cli run --local # skip platform config/credential lookup entirely — local-only, no board sync
132
132
  npx @eventmodelers/cli run --modeling # modeling-kit: warm Claude process driven by the board's prompt queue
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
- // first (ralph.js, ralph-claude.js, ralph-local-ai.js, ralph-exec.js, ralph.sh, realtime-agent.js,
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>/* is then overlaid on top for genuine
41
- // per-stack differences (ralph-claude.js's build tooling, lib/prompt.md, etc.).
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
- boardId = answer;
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('Refresh an already-installed kit from the current CLI version — re-copies skills and the kit dir (.build-kit or .agent-modeling-kit) so you pick up script/skill updates after upgrading. Unlike `init`, never touches the project root scaffold or the root CLAUDE.md router, and leaves existing credentials alone unless --force is passed.')
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>', `Override which stack to refresh from (${Object.keys(REINITIABLE_STACKS).join(', ')}) instead of the one recorded in install-manifest.json — use this when the manifest is missing/stale, or to switch a .build-kit install to a different stack`)
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,14 +3184,17 @@ 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
 
3116
3193
  credentialFlags(program
3117
3194
  .command('run')
3118
3195
  .description('Start the agent loop from the installed kit dir — build-kit stacks: ralph-claude.js (default); modeling-kit: --modeling, or --standalone, which needs no install at all')
3119
- .option('--local-ai [target]', `Drive the loop with a local (or self-hosted) model instead of the default Claude runner: a build kit runs it via ralph-local-ai.js, and --modeling/--standalone via lib/modeling-local-ai.js (board tools over MCP, but no skills and no subagent fan-out — those are Claude Code features). Optional target preset picks the URL and wire dialect: ${LOCAL_AI_TARGETS.join(', ')} — bare --local-ai means ollama. Anything OpenAI-compatible (vLLM, LM Studio, llama.cpp, TGI) works by pointing LOCAL_AI_URL at it; see LOCAL_AI_* in the docs. Claude remains the default when this flag is absent.`)
3120
- .option('--exec [command]', 'Hand each prompt to an external agent command instead of the default Claude runner, via ralph-exec.js (build-kit stacks only) — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto" or "opencode run". The prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent.')
3196
+ .option('--local-ai [target]', `Drive the loop with a local (or self-hosted) model instead of the default Claude runner: a build kit runs it via ralph-local-ai.js, and --modeling/--standalone via lib/modeling-local-ai.js (board tools over MCP, but no skills and no subagent fan-out — those are Claude Code features). Optional target preset picks the URL and wire dialect: ${LOCAL_AI_TARGETS.join(', ')} — bare --local-ai means ollama. Anything OpenAI-compatible (vLLM, LM Studio, llama.cpp, TGI) works by pointing LOCAL_AI_URL at it; see LOCAL_AI_* in the docs. Board work only: --local-ai never builds a Planned slice (no file, shell or git tools). To build code with a local model, use --exec with a coding harness pointed at it, e.g. "codex exec --oss --full-auto". Claude remains the default when this flag is absent.`)
3197
+ .option('--exec [command]', 'Hand each prompt to an external agent command instead of the default Claude runner, via ralph-exec.js (build-kit stacks only) — for agentic harnesses that bring their own tool loop, e.g. "codex exec --full-auto" or "opencode run". The prompt is appended as a quoted argument and also written to the file named by RALPH_PROMPT_FILE. Bare --exec uses localAi.exec from .eventmodelers/config.json. Claude remains the default when this flag is absent. With agent tracing on, include the harness\'s JSON output flag (codex exec --json, opencode run --format json, gemini --output-format stream-json) — without it no slice cost is recorded.')
3121
3198
  .option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
3122
3199
  .option('--modeling', 'Keep one Claude process warm across prompts instead of spawning a fresh one per task, for low-latency voice/live use. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
3123
3200
  .option('--non-interactive', 'Never ask anything: take the board and credentials that resolve from flags, EVENTMODELERS_* env vars and the config files, and fail with the reason if they are incomplete instead of interviewing for them. A TTY was previously the only signal — right for CI or a supervisor, wrong for a loop started from a terminal, where stdin is a TTY nobody is watching and the run stops on a question. Only affects the modeling loop (--modeling/--standalone/--global).')
@@ -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. A non-Claude agent via run --exec is only traced when its command includes the harness's JSON output flag, e.g. \"codex exec --json --full-auto\".")
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.85",
3
+ "version": "1.0.87",
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": {
@@ -51,7 +51,7 @@ node .build-kit/ralph-claude.js /path/to/project
51
51
  **Phase 2** — triggered when any file in `.slices/` contains `"status": "Planned"`:
52
52
  - The loop runs Claude with `backend-prompt.md`
53
53
  - Claude implements the slice in the project
54
- - Phase 2 is Claude-only; local-AI mode skips it (local-ai-agent handles its own queue)
54
+ - Phase 2 (building slices) never runs with `--local-ai` — see *`--local-ai` does not build code*. Build with `--exec` instead
55
55
 
56
56
  Both phases run in a continuous loop with a 3-second idle sleep. The realtime agent runs concurrently in the same process.
57
57
 
@@ -72,6 +72,34 @@ node .build-kit/realtime-agent.js
72
72
  `ralph-local-ai.js` drives any local or self-hosted model that can do tool calling.
73
73
  Claude (`ralph-claude.js`) stays the default runner — this is opt-in.
74
74
 
75
+ ### `--local-ai` does not build code
76
+
77
+ **`--local-ai` works on the board only. It never builds a slice.** Planned slices stay
78
+ Planned. That is by design, not a bug:
79
+
80
+ - **Planned slices are never handed to it.** `ralph-local-ai.js` gives `startRalph` no
81
+ `onPlannedSlice`, so the loop only feeds it `tasks.json` prompts.
82
+ - **It has no coding tools.** `lib/local-ai-agent.js` exposes only the board MCP tools:
83
+ no file read/write, no shell, no git, no test run.
84
+ - **Its system prompt forbids file and shell access.** Such a request is answered with
85
+ `Blocked: …`.
86
+ - **A prompt stops after 12 tool-call rounds.** That is enough for board edits, but far
87
+ short of a slice build.
88
+
89
+ Here the kit is the agent loop and the model only picks tools. Building would mean
90
+ rewriting a full coding agent inside the kit, so it is left out on purpose.
91
+
92
+ **To build with a local model, use `--exec`** with a coding harness pointed at that model
93
+ (see *External agent commands* below):
94
+
95
+ ```bash
96
+ npx @eventmodelers/cli run --exec "codex exec --oss --json --full-auto" # Codex on local Ollama
97
+ npx @eventmodelers/cli run --exec "opencode run --auto --format json" # OpenCode, with an Ollama/vLLM/LM Studio provider in its config
98
+ ```
99
+
100
+ Or keep Claude Code as the builder and point `anthropicBaseUrl` at a local endpoint that
101
+ speaks the Anthropic API.
102
+
75
103
  ```bash
76
104
  LOCAL_AI_TARGET=ollama # preset: ollama | vllm | lmstudio | llamacpp
77
105
  LOCAL_AI_URL=http://host:8000/v1 # any OpenAI-compatible server (overrides the preset URL)
@@ -109,6 +137,20 @@ child runs with the project dir as its cwd and inherits stdio — a harness owns
109
137
  output format, so there is no condensed per-step logging here the way `ralph-claude.js`
110
138
  has it.
111
139
 
140
+ **Agent tracing needs the harness's JSON output flag.** With agent tracing on
141
+ (`eventmodelers config --agent-tracing on`), slice builds are costed just like with Claude —
142
+ but only if the harness runs in its JSON output mode. Its normal output has no token counts,
143
+ so without the flag a turn is not traced (the runner logs a hint). Add the flag yourself:
144
+
145
+ ```bash
146
+ npx @eventmodelers/cli run --exec "codex exec --json --full-auto"
147
+ npx @eventmodelers/cli run --exec "opencode run --auto --format json"
148
+ npx @eventmodelers/cli run --exec "gemini --yolo --output-format stream-json -p"
149
+ ```
150
+
151
+ The terminal then shows the raw JSON events. Only OpenCode reports a dollar cost; Codex and
152
+ Gemini turns record tokens and duration with no cost. See `agent-tracking.md`.
153
+
112
154
  Persist a default alongside the local-AI settings:
113
155
 
114
156
  ```json
@@ -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
- writeFileSync(sliceJsonPath, JSON.stringify({ ...existingSlice, ...slice }, null, 2), 'utf-8');
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
- // RALPH_AGENT_ID/RALPH_AGENT_NAME are `eventmodelers run --id/--name`, passed down as env
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,155 @@
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
+ /** One event from an external harness's JSON output (ralph-exec.js), or null if it carries no usage.
49
+ * Matched on shape, not on the command name, so a wrapper script around a harness still counts:
50
+ * - Codex `codex exec --json`: `turn.completed` with `usage` (input_tokens includes the cached ones)
51
+ * - OpenCode `opencode run --format json`: `step_finish` per model step, with tokens and cost
52
+ * - Gemini CLI `--output-format stream-json`: the final `result` with `stats`;
53
+ * `--output-format json`: one object whose `stats.models` holds tokens per model
54
+ * Only OpenCode reports a cost — the others leave costUsd null rather than guessing a price. */
55
+ export function usageFromHarnessEvent(msg) {
56
+ if (msg?.type === 'turn.completed' && msg.usage) {
57
+ const u = msg.usage;
58
+ const cached = n(u.cached_input_tokens);
59
+ return { inputTokens: Math.max(0, n(u.input_tokens) - cached), outputTokens: n(u.output_tokens), cacheReadTokens: cached };
60
+ }
61
+ if (msg?.type === 'step_finish' && msg.part?.tokens) {
62
+ const t = msg.part.tokens;
63
+ return {
64
+ inputTokens: n(t.input),
65
+ outputTokens: n(t.output) + n(t.reasoning),
66
+ cacheReadTokens: n(t.cache?.read),
67
+ cacheWriteTokens: n(t.cache?.write),
68
+ costUsd: Number.isFinite(msg.part.cost) ? msg.part.cost : null,
69
+ };
70
+ }
71
+ if (msg?.type === 'result' && msg.stats && !('total_cost_usd' in msg)) {
72
+ const s = msg.stats;
73
+ return {
74
+ inputTokens: n(s.input_tokens),
75
+ outputTokens: n(s.output_tokens),
76
+ durationMs: Number.isFinite(s.duration_ms) ? s.duration_ms : null,
77
+ };
78
+ }
79
+ if (msg?.stats?.models && typeof msg.stats.models === 'object') {
80
+ const models = Object.entries(msg.stats.models);
81
+ const tok = (key) => models.reduce((sum, [, m]) => sum + n(m?.tokens?.[key]), 0);
82
+ const cached = tok('cached');
83
+ const [dominant] = models.sort((a, b) => n(b[1]?.tokens?.candidates) - n(a[1]?.tokens?.candidates))[0] ?? [];
84
+ return {
85
+ inputTokens: Math.max(0, tok('prompt') - cached),
86
+ outputTokens: tok('candidates') + tok('thoughts'),
87
+ cacheReadTokens: cached,
88
+ model: dominant ?? null,
89
+ };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** Sums the usage of every event in one turn. A field no event reported stays null (cost, model)
95
+ * or 0 (tokens), so a harness without a cost does not show up as a free turn. */
96
+ export function sumUsage(parts) {
97
+ if (!parts.length) return null;
98
+ const add = (key) => parts.reduce((sum, p) => sum + n(p[key]), 0);
99
+ const costs = parts.map((p) => p.costUsd).filter(Number.isFinite);
100
+ return {
101
+ inputTokens: add('inputTokens'),
102
+ outputTokens: add('outputTokens'),
103
+ cacheReadTokens: add('cacheReadTokens'),
104
+ cacheWriteTokens: add('cacheWriteTokens'),
105
+ costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
106
+ durationMs: parts.findLast((p) => Number.isFinite(p.durationMs))?.durationMs ?? null,
107
+ model: parts.findLast((p) => p.model)?.model ?? null,
108
+ };
109
+ }
110
+
111
+ /** Records slice build turns. The agent id is the session: the platform requires one, and a
112
+ * restart of the same build agent is still the same agent building the same slices. */
113
+ export function createSliceTracer({ baseUrl, token, organizationId, agentId, traceFile, log = () => {}, enabled = true } = {}) {
114
+ const canUpload = !!(enabled && baseUrl && token && organizationId && agentId);
115
+
116
+ async function post(trace) {
117
+ // The platform files a trace under the board in its path — a turn without a board has nowhere to go.
118
+ if (!canUpload || !trace.boardId) return;
119
+ try {
120
+ const res = await fetch(`${baseUrl}/api/org/${organizationId}/boards/${trace.boardId}/agent-traces`, {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json', 'x-token': token, 'x-agent-id': agentId },
123
+ body: JSON.stringify({ traces: [trace] }),
124
+ signal: AbortSignal.timeout(15_000),
125
+ });
126
+ if (!res.ok) log(`trace: upload failed (HTTP ${res.status}${await res.text().then((t) => `: ${t}`).catch(() => '')}) — kept locally only`);
127
+ } catch (err) {
128
+ log(`trace: upload error (${err.message}) — kept locally only`);
129
+ }
130
+ }
131
+
132
+ function write(trace) {
133
+ if (!enabled || !traceFile) return;
134
+ try {
135
+ mkdirSync(dirname(traceFile), { recursive: true });
136
+ appendFileSync(traceFile, `${JSON.stringify(trace)}\n`);
137
+ } catch (err) {
138
+ if (!write.warned) { write.warned = true; log(`trace: cannot write ${traceFile} (${err.message})`); }
139
+ }
140
+ }
141
+
142
+ return {
143
+ /** One build turn: `{sliceId, sliceTitle, context, ticketNumber, boardId, attempt, status}` plus its usage.
144
+ * ticketNumber is the slice's ticket at build time — it can change, so it travels with each turn. */
145
+ record({ sliceId, sliceTitle = null, context = null, ticketNumber = null, boardId = null, attempt = null, status = 'ok' }, usage) {
146
+ if (!sliceId || !usage) return;
147
+ const trace = {
148
+ id: newId(), occurredAt: new Date().toISOString(),
149
+ boardId, sliceId, sliceTitle, context, ticketNumber, agentId, sessionId: agentId, attempt, status, ...usage,
150
+ };
151
+ write(trace);
152
+ post(trace).catch(() => {});
153
+ },
154
+ };
155
+ }
@@ -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
- function runClaude(prompt) {
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
  });