@eventmodelers/cli 1.0.90 → 1.0.91

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.
Files changed (59) hide show
  1. package/README.md +2 -0
  2. package/cli.js +26 -2
  3. package/lib/update-check.js +67 -0
  4. package/package.json +4 -1
  5. package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +11 -3
  6. package/shared/build-kit/lib/adapters/realtime-adapter.js +3 -1
  7. package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +14 -3
  8. package/shared/build-kit/lib/ralph.js +136 -59
  9. package/shared/build-kit/lib/redact.js +36 -0
  10. package/shared/build-kit/lib/turn.js +56 -20
  11. package/shared/build-kit/ralph-claude.js +7 -19
  12. package/shared/build-kit/ralph-exec.js +5 -9
  13. package/shared/build-kit/realtime-agent.js +4 -2
  14. package/shared/skills/connect/SKILL.md +14 -9
  15. package/shared/skills/learn-eventmodelers-api/SKILL.md +60 -21
  16. package/shared/skills/load-slice/SKILL.md +3 -1
  17. package/stacks/modeling-kit/templates/.claude/skills/add-next-slice/SKILL.md +4 -4
  18. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +18 -9
  19. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/references/api-fallback.md +31 -11
  20. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +16 -13
  21. package/stacks/modeling-kit/templates/.claude/skills/attributes/references/api-fallback.md +3 -1
  22. package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/SKILL.md +6 -6
  23. package/stacks/modeling-kit/templates/.claude/skills/discover-storyboard/references/api-fallback.md +3 -1
  24. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/SKILL.md +4 -4
  25. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-brainstorming-events/references/api-fallback.md +4 -4
  26. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +8 -12
  27. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/references/api-fallback.md +16 -6
  28. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +3 -3
  29. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/references/api-fallback.md +1 -1
  30. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/SKILL.md +2 -2
  31. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-inputs/references/api-fallback.md +4 -4
  32. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +4 -4
  33. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/references/api-fallback.md +4 -4
  34. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +3 -3
  35. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/references/api-fallback.md +3 -1
  36. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/SKILL.md +2 -2
  37. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-plotting-events/references/api-fallback.md +1 -1
  38. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +3 -3
  39. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/references/api-fallback.md +12 -4
  40. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +7 -6
  41. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/references/api-fallback.md +4 -3
  42. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +22 -6
  43. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/references/api-fallback.md +12 -3
  44. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/SKILL.md +18 -6
  45. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models-checklist/references/api-fallback.md +12 -3
  46. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +2 -2
  47. package/stacks/modeling-kit/templates/.claude/skills/examples/references/api-fallback.md +20 -14
  48. package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +12 -12
  49. package/stacks/modeling-kit/templates/.claude/skills/place-element/references/api-fallback.md +6 -4
  50. package/stacks/modeling-kit/templates/.claude/skills/storyboard/SKILL.md +4 -4
  51. package/stacks/modeling-kit/templates/.claude/skills/storyboard/references/api-fallback.md +2 -2
  52. package/stacks/modeling-kit/templates/.claude/skills/timeline/SKILL.md +10 -10
  53. package/stacks/modeling-kit/templates/.claude/skills/timeline/references/api-fallback.md +4 -4
  54. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +11 -24
  55. package/stacks/modeling-kit/templates/kit/AGENTS.md +3 -2
  56. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +6 -4
  57. package/stacks/modeling-kit/templates/kit/CLAUDE.md +9 -4
  58. package/stacks/react/templates/build-kit/lib/backend-prompt.md +106 -102
  59. package/stacks/react/templates/build-kit/lib/prompt.md +102 -106
package/README.md CHANGED
@@ -635,6 +635,8 @@ npx @eventmodelers/cli re-init --stack supabase # override which stack to r
635
635
 
636
636
  `re-init` re-runs `init` against whichever stack `install-manifest.json` says was installed (no need to pass `--stack` again), but skips step 2 of `init` entirely — the root project scaffold (`package.json`, `src/`, `server.ts`, `docker-compose.yml`, etc.) and the root `CLAUDE.md` router are never touched. Use it after upgrading the CLI to pick up fixes to `ralph.js`/`ralph.sh`/skills without re-scaffolding a project you've since built on top of.
637
637
 
638
+ You don't have to remember to: on startup every command prints a 💡 hint (on stderr) when a newer CLI is on npm, or when a kit in the current directory was installed by an older CLI than the one running — naming the exact `re-init` command to run. The npm lookup happens at most once a day (cached in `~/.eventmodelers/update-check.json`, 1.5 s timeout, silent when offline). Set `EVENTMODELERS_NO_UPDATE_CHECK=1` to turn it off; it is also off when `CI` is set.
639
+
638
640
  Pass `--stack <name>` to override the stack instead of relying on the manifest — useful if the manifest is missing/stale, or you want to point a `.build-kit` install at a different built-in stack's templates. It's mutually exclusive with `--modeling`.
639
641
 
640
642
  Credentials are left alone unless you pass `--force` — same rule `init` already follows when everything required is already configured. `--global` defaults to however skills were originally installed; pass it explicitly to move them.
package/cli.js CHANGED
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
21
21
  import { runFetch, FetchAuthError } from './lib/fetch.js';
22
22
  import { createModelingLocalAiRunner } from './lib/modeling-local-ai.js';
23
23
  import { run as runSpecKittyAdapter } from './lib/adapters/spec-kitty-adapter.js';
24
+ import { latestPublishedVersion, updateHints } from './lib/update-check.js';
24
25
  // Not a root-level adapter like spec-kitty-adapter.js above: this is the one canonical
25
26
  // copy that every useShared:true stack also gets copied into its installed kit (see
26
27
  // copyDirContents in installStack) for ralph.js to import standalone — see
@@ -1282,6 +1283,11 @@ async function configureCredentials({ config, configPath, targetDir, requiredFie
1282
1283
  async function refreshSharedRuntime({ targetDir, kitDir, globalOpts, opts }) {
1283
1284
  console.log(`📦 Refreshing the shared runtime in ${relative(targetDir, kitDir)}/ (stack files untouched)...`);
1284
1285
  copyDirContents(join(__dirname, 'shared', 'build-kit'), kitDir);
1286
+ // The kit is now this CLI's runtime — record that, so the startup "kit is outdated" hint stops.
1287
+ const manifestPath = join(kitDir, '.eventmodelers', 'install-manifest.json');
1288
+ if (existsSync(manifestPath)) {
1289
+ writeFileSync(manifestPath, JSON.stringify({ ...readJsonSafe(manifestPath), version: CLI_VERSION }, null, 2));
1290
+ }
1285
1291
  for (const script of ['ralph.sh', 'lib/agent.sh', 'ralph-claude.js', 'ralph-local-ai.js', 'ralph-exec.js']) {
1286
1292
  const p = join(kitDir, script);
1287
1293
  if (existsSync(p)) {
@@ -1943,13 +1949,16 @@ async function runModeling(kitDir, projectDir, { verbose = false, standalone = f
1943
1949
  // board-change turn can just as well be the first turn a (re)spawned process
1944
1950
  // ever sees, so both turn builders go through this rather than buildTurn owning it.
1945
1951
  function withSessionHeader(body) {
1952
+ // token= names the env var, never its value: claudeEnv already carries EVENTMODELERS_TOKEN,
1953
+ // and a literal token here would sit in every session transcript (the connect skill treats
1954
+ // the reference as an inline token — see its Step 0).
1946
1955
  // Nothing to prepend for a local model: it has no CLAUDE.md to read and no /connect to run
1947
1956
  // (its tools are already authenticated), and handing it the raw token would put credentials
1948
1957
  // in a context that has no way to use them.
1949
1958
  if (localRunner) return body;
1950
1959
  if (!firstTurn) return body;
1951
1960
  firstTurn = false;
1952
- return `MODE=modeling token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''} subagent_model=${subagentModel}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
1961
+ return `MODE=modeling token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''} subagent_model=${subagentModel}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
1953
1962
  }
1954
1963
 
1955
1964
  function buildTurn(p) {
@@ -2705,7 +2714,7 @@ const program = new Command();
2705
2714
  program
2706
2715
  .name('eventmodelers')
2707
2716
  .description('Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack')
2708
- .version('1.0.0')
2717
+ .version(CLI_VERSION)
2709
2718
  .option('--config <path>', 'Path to an explicit config.json, overriding directory-based resolution (individual fields can also be set via EVENTMODELERS_* env vars, which always win)')
2710
2719
  .option('--print', 'Print follow-up commands (e.g. claude mcp add) instead of prompting to run them');
2711
2720
 
@@ -2725,6 +2734,21 @@ program
2725
2734
  // than this generic gate can.
2726
2735
  const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status', 'release-notes', 'run']);
2727
2736
 
2737
+ // Commands that install or refresh the kit themselves — a "your kit is outdated" hint in
2738
+ // front of them would only be noise.
2739
+ const NO_KIT_UPDATE_HINT = new Set(['init', 're-init', 'uninstall']);
2740
+
2741
+ // Startup hint when this CLI is behind npm, or an installed kit here is behind this CLI.
2742
+ // Best effort and on stderr, so it never breaks piped output; EVENTMODELERS_NO_UPDATE_CHECK
2743
+ // (or CI) turns it off.
2744
+ program.hook('preAction', async (_thisCommand, actionCommand) => {
2745
+ if (process.env.EVENTMODELERS_NO_UPDATE_CHECK || process.env.CI) return;
2746
+ const latestVersion = await latestPublishedVersion({ cachePath: join(GLOBAL_DIR, 'update-check.json') });
2747
+ const kitDirs = NO_KIT_UPDATE_HINT.has(actionCommand.name()) ? [] : findAllInstalledKitDirs(process.cwd());
2748
+ const hints = updateHints({ currentVersion: CLI_VERSION, latestVersion, kitDirs });
2749
+ if (hints.length) console.error(`${hints.join('\n')}\n`);
2750
+ });
2751
+
2728
2752
  program.hook('preAction', (_thisCommand, actionCommand) => {
2729
2753
  if (NO_INIT_REQUIRED.has(actionCommand.name())) return;
2730
2754
  if (findInstalledKitDir(process.cwd())) return;
@@ -0,0 +1,67 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { basename, dirname, join, relative } from 'path';
3
+
4
+ const REGISTRY_URL = 'https://registry.npmjs.org/@eventmodelers/cli/latest';
5
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
6
+ const FETCH_TIMEOUT_MS = 1500;
7
+
8
+ // Kit dirs `re-init` can refresh, and how to ask it to.
9
+ const REINIT_COMMANDS = {
10
+ '.build-kit': 'eventmodelers re-init',
11
+ '.agent-modeling-kit': 'eventmodelers re-init --modeling',
12
+ };
13
+
14
+ // Numeric compare of x.y.z versions — negative when a < b. Pre-release suffixes are ignored.
15
+ export function compareVersions(a, b) {
16
+ const pa = String(a).split('-')[0].split('.').map(Number);
17
+ const pb = String(b).split('-')[0].split('.').map(Number);
18
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
19
+ const d = (pa[i] || 0) - (pb[i] || 0);
20
+ if (d) return d;
21
+ }
22
+ return 0;
23
+ }
24
+
25
+ function readJson(path) {
26
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; }
27
+ }
28
+
29
+ // The latest published version, asked of the npm registry at most once a day — in between,
30
+ // the answer cached at cachePath is reused. Best effort: offline, slow or broken registry
31
+ // responses just yield whatever was cached last (or null).
32
+ export async function latestPublishedVersion({ cachePath, now = Date.now(), fetchImpl = fetch } = {}) {
33
+ const cache = readJson(cachePath);
34
+ if (cache.latest && now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS) return cache.latest;
35
+
36
+ try {
37
+ const res = await fetchImpl(REGISTRY_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
38
+ if (!res.ok) return cache.latest || null;
39
+ const { version } = await res.json();
40
+ if (!version) return cache.latest || null;
41
+ try {
42
+ mkdirSync(dirname(cachePath), { recursive: true });
43
+ writeFileSync(cachePath, JSON.stringify({ latest: version, checkedAt: now }));
44
+ } catch {}
45
+ return version;
46
+ } catch {
47
+ return cache.latest || null;
48
+ }
49
+ }
50
+
51
+ // Hint lines for everything that's behind: this CLI against npm, and each installed kit
52
+ // against the CLI running now (its install manifest records the version that wrote it).
53
+ export function updateHints({ currentVersion, latestVersion, kitDirs = [], cwd = process.cwd() }) {
54
+ const hints = [];
55
+ if (latestVersion && compareVersions(currentVersion, latestVersion) < 0) {
56
+ hints.push(`💡 A new eventmodelers CLI is available: ${currentVersion} → ${latestVersion}. Update with: npm i -g @eventmodelers/cli@latest (or use npx @eventmodelers/cli@latest)`);
57
+ }
58
+ for (const kitDir of kitDirs) {
59
+ const reinit = REINIT_COMMANDS[basename(kitDir)];
60
+ const manifestPath = join(kitDir, '.eventmodelers', 'install-manifest.json');
61
+ if (!reinit || !existsSync(manifestPath)) continue;
62
+ const kitVersion = readJson(manifestPath).version;
63
+ if (kitVersion && compareVersions(kitVersion, currentVersion) >= 0) continue;
64
+ hints.push(`💡 ${relative(cwd, kitDir) || kitDir}/ was installed by CLI ${kitVersion || '(unknown version)'} — refresh it to ${currentVersion} with: ${reinit}`);
65
+ }
66
+ return hints;
67
+ }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.90",
3
+ "version": "1.0.91",
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": {
7
7
  "eventmodelers": "cli.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js"
11
+ },
9
12
  "files": [
10
13
  "cli.js",
11
14
  "lib",
@@ -16,18 +16,26 @@ const REALTIME_EVENTS_COLLECTION = 'realtime_events';
16
16
  // ourselves.
17
17
  const RECONNECT_INTERVALS_MS = [200, 300, 500, 1000, 1200, 1500, 2000];
18
18
 
19
- export async function createPocketBaseRealtimeAdapter(cfg, initialToken) {
19
+ // deps.PocketBase is injectable for tests; by default it is the pocketbase SDK's client.
20
+ export async function createPocketBaseRealtimeAdapter(cfg, initialToken, deps = {}) {
20
21
  const { EventSource } = await import('eventsource');
21
22
  if (!globalThis.EventSource) globalThis.EventSource = EventSource; // PocketBase's SDK assumes a browser-style global
22
- const { default: PocketBase } = await import('pocketbase');
23
+ const PocketBase = deps.PocketBase ?? (await import('pocketbase')).default;
23
24
  const pb = new PocketBase(cfg.pocketbaseUrl);
24
25
  pb.authStore.save(initialToken, null);
25
26
 
27
+ // subscribe() replaces the previous listener (see realtime-adapter.js).
28
+ let unsubscribe = null;
29
+
26
30
  return {
27
31
  async subscribe(topic, handlers, onStatus) {
32
+ if (unsubscribe) {
33
+ await unsubscribe().catch(() => {});
34
+ unsubscribe = null;
35
+ }
28
36
  for (let attempt = 0; ; attempt++) {
29
37
  try {
30
- await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
38
+ unsubscribe = await pb.collection(REALTIME_EVENTS_COLLECTION).subscribe('*', (e) => {
31
39
  if (e.action !== 'create' || e.record.topic !== topic) return;
32
40
  handlers[e.record.event]?.(e.record.payload);
33
41
  });
@@ -3,7 +3,9 @@
3
3
  // record-change events for self-hosted/on-prem) and hands back one common interface:
4
4
  //
5
5
  // subscribe(topic, handlers, onStatus?) — handlers is a { [eventName]: (payload) => void }
6
- // map; onStatus?.(status) fires once the subscription is live ('SUBSCRIBED').
6
+ // map; onStatus?.(status) fires once the subscription is live ('SUBSCRIBED'). Calling it
7
+ // again replaces the previous subscription — never adds to it — and a replaced one no
8
+ // longer reports status.
7
9
  // setAuth(token) — refresh the short-lived realtime token on the live connection.
8
10
  //
9
11
  // cfg.realtimeProvider absent (older servers, predating PocketBase support) means
@@ -2,20 +2,31 @@
2
2
  // broadcast channel and dispatches named broadcast events to caller-supplied handlers.
3
3
  // See realtime-adapter.js for the interface both this and the PocketBase adapter implement.
4
4
 
5
- export async function createSupabaseRealtimeAdapter(cfg, initialToken) {
6
- const { createClient } = await import('@supabase/supabase-js');
5
+ // deps.createClient is injectable for tests; by default it is supabase-js's own.
6
+ export async function createSupabaseRealtimeAdapter(cfg, initialToken, deps = {}) {
7
+ const createClient = deps.createClient ?? (await import('@supabase/supabase-js')).createClient;
7
8
  const supabase = createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, {
8
9
  realtime: { params: { apikey: cfg.supabaseAnonKey } },
9
10
  });
10
11
  await supabase.realtime.setAuth(initialToken);
11
12
 
13
+ // supabase.channel(topic) hands back the existing channel for a topic it already knows, so
14
+ // re-subscribing on it would stack another set of handlers — the old channel is removed first.
15
+ let current = null;
16
+
12
17
  return {
13
18
  async subscribe(topic, handlers, onStatus) {
19
+ // Detach before removing: the removal itself makes the old channel report CLOSED, and a
20
+ // late status from a replaced channel must not count as this subscription's.
21
+ const previous = current;
22
+ current = null;
23
+ if (previous) await supabase.removeChannel(previous).catch(() => {});
14
24
  let channel = supabase.channel(topic, { config: { private: true } });
15
25
  for (const [event, handler] of Object.entries(handlers)) {
16
26
  channel = channel.on('broadcast', { event }, (msg) => handler(msg.payload));
17
27
  }
18
- channel.subscribe((status) => onStatus?.(status));
28
+ current = channel;
29
+ channel.subscribe((status) => { if (channel === current) onStatus?.(status); });
19
30
  },
20
31
  setAuth(token) {
21
32
  return supabase.realtime.setAuth(token);
@@ -6,12 +6,16 @@
6
6
  // onPlannedSlice(prompt, slice) — called when .slices/ has a "Planned" entry (omit to skip);
7
7
  // `slice` ({sliceId, sliceTitle, context, ticketNumber, boardId, attempt}) lets a runner record what building it cost.
8
8
 
9
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs';
9
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, renameSync } from 'fs';
10
10
  import { join, dirname } from 'path';
11
11
  import { homedir } from 'os';
12
12
  import { randomUUID } from 'crypto';
13
13
  import { createRealtimeAdapter } from './adapters/realtime-adapter.js';
14
14
  import { backoffMs, sleep } from './backoff.js';
15
+ import { redactConsole } from './redact.js';
16
+
17
+ // Backoff for calls to the platform that failed for a reason on its side (startup, the channel).
18
+ const PLATFORM_BACKOFF = { baseMs: 2_000, capMs: 5 * 60_000 };
15
19
 
16
20
  // ── HTTP helpers ──────────────────────────────────────────────────────────────
17
21
 
@@ -22,25 +26,42 @@ class HttpError extends Error {
22
26
  }
23
27
  }
24
28
 
29
+ // Every fetchJSON call authenticates with the platform API token (x-token), and a 401 for it
30
+ // always means that token is missing, invalid or revoked (see requireApiToken on the
31
+ // platform) — no retry or wait can fix that, so the first one ends the process with a message
32
+ // saying what to fix. (The alive-ping, on the short-lived realtime token, doesn't come
33
+ // through here: a 401 there just means that token needs refreshing.)
25
34
  async function fetchJSON(url, options) {
26
35
  const res = await fetch(url, options);
36
+ if (res.status === 401) {
37
+ console.error(`[agent] 401 from ${new URL(url).pathname} — the token was rejected: ${await res.text()}`);
38
+ console.error('[agent] Check the token in .eventmodelers/config.json (or EVENTMODELERS_TOKEN) — shutting down');
39
+ process.exit(1);
40
+ }
27
41
  if (!res.ok) throw new HttpError(res.status, await res.text());
28
42
  return res.json();
29
43
  }
30
44
 
31
- // A 401 from the platform always means the token itself is missing, invalid or revoked (see
32
- // requireApiToken on the platform) — retrying the same token can't fix that, and neither can
33
- // waiting, so the first one ends the process with a message saying what to fix.
34
- async function exitOn401(label, fn) {
35
- try {
36
- return await fn();
37
- } catch (err) {
38
- if (err instanceof HttpError && err.status === 401) {
39
- console.error(`[agent] ${label} — 401, the token was rejected: ${err.message}`);
40
- console.error('[agent] Check the token in .eventmodelers/config.json (or EVENTMODELERS_TOKEN) — shutting down');
41
- process.exit(1);
45
+ // Startup can't wait for a person to notice a platform blip: a 429, a 5xx or a request that
46
+ // never got an answer is retried with backoff until it goes through. Any other 4xx is a real
47
+ // answer and goes straight back (a 401 has already ended the process in fetchJSON).
48
+ function isTransient(err) {
49
+ if (err instanceof HttpError) return err.status === 429 || err.status >= 500;
50
+ // fetch() rejects with a TypeError when there's no response at all (DNS, refused, reset),
51
+ // and with a TimeoutError/AbortError from an AbortSignal.timeout.
52
+ return err instanceof TypeError || err?.name === 'TimeoutError' || err?.name === 'AbortError';
53
+ }
54
+
55
+ async function retryTransient(label, fn, { sleepFn = sleep } = {}) {
56
+ for (let attempt = 1; ; attempt++) {
57
+ try {
58
+ return await fn();
59
+ } catch (err) {
60
+ if (!isTransient(err)) throw err;
61
+ const delay = backoffMs(attempt, PLATFORM_BACKOFF);
62
+ console.warn(`[agent] ${label} failed (attempt ${attempt}: ${err.message}) — retrying in ${Math.round(delay / 1000)}s`);
63
+ await sleepFn(delay);
42
64
  }
43
- throw err;
44
65
  }
45
66
  }
46
67
 
@@ -259,30 +280,79 @@ async function fetchAndPersistSlices(cfg, kitDir) {
259
280
  console.log(`[agent] Persisted ${slices.length} slice(s)`);
260
281
  }
261
282
 
262
- async function writeTask(payload, kitDir) {
283
+ // tasks.json has two writers: this process, queueing slice changes, and the agent, removing
284
+ // the task it just handled (as each stack's prompt.md tells it to). An unlocked read-modify-
285
+ // write from both sides at once lost whichever update landed first. So the two never overlap:
286
+ // while an onTask turn runs (see inTurn), a change is held here instead — latest per slice —
287
+ // and written the moment the turn ends. Every write goes through a temp file and a rename,
288
+ // so a reader never sees a half-written file either.
289
+ let heldTasks = null; // sliceId → payload while an onTask turn runs, null otherwise
290
+
291
+ function writeTasksAtomically(tasksPath, tasks) {
292
+ const tmp = `${tasksPath}.${process.pid}.tmp`;
293
+ writeFileSync(tmp, JSON.stringify(tasks, null, 2), 'utf-8');
294
+ renameSync(tmp, tasksPath);
295
+ }
296
+
297
+ function readTasks(tasksPath) {
298
+ if (!existsSync(tasksPath)) return [];
299
+ try {
300
+ const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
301
+ return Array.isArray(tasks) ? tasks : [];
302
+ } catch {
303
+ return []; // the agent left it unparseable — start over rather than wedge the queue
304
+ }
305
+ }
306
+
307
+ function appendTasks(kitDir, payloads) {
263
308
  const tasksPath = join(kitDir, 'tasks.json');
264
- const existing = existsSync(tasksPath) ? JSON.parse(readFileSync(tasksPath, 'utf-8')) : [];
265
- const filtered = existing.filter(t => t.payload?.sliceId !== payload.sliceId);
266
- const task = { id: randomUUID(), createdAt: new Date().toISOString(), payload };
267
- filtered.push(task);
268
- writeFileSync(tasksPath, JSON.stringify(filtered, null, 2), 'utf-8');
309
+ const replaced = new Set(payloads.map((p) => p.sliceId));
310
+ const tasks = readTasks(tasksPath).filter((t) => !replaced.has(t.payload?.sliceId));
311
+ for (const payload of payloads) tasks.push({ id: randomUUID(), createdAt: new Date().toISOString(), payload });
312
+ writeTasksAtomically(tasksPath, tasks);
313
+ }
314
+
315
+ async function writeTask(payload, kitDir) {
316
+ if (heldTasks) {
317
+ heldTasks.set(payload.sliceId, payload);
318
+ console.log(`[agent] Task held until the current turn ends — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
319
+ return;
320
+ }
321
+ appendTasks(kitDir, [payload]);
269
322
  console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
270
323
  }
271
324
 
325
+ // Runs one agent turn that may rewrite tasks.json, keeping this process's own writes out of
326
+ // its way; whatever arrived meanwhile is written as soon as it's over, success or not.
327
+ async function inTurn(kitDir, fn) {
328
+ heldTasks = new Map();
329
+ try {
330
+ return await fn();
331
+ } finally {
332
+ const payloads = [...heldTasks.values()];
333
+ heldTasks = null;
334
+ if (payloads.length > 0) {
335
+ appendTasks(kitDir, payloads);
336
+ console.log(`[agent] Wrote ${payloads.length} task(s) held during the turn`);
337
+ }
338
+ }
339
+ }
340
+
272
341
  async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
273
342
  console.log(`[agent] slice:changed — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
274
- await exitOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
275
- console.error('[agent] Slice persist error:', err),
276
- );
277
343
  // This agent's own status writes (claim → InProgress, → Done, → Blocked) come straight back
278
344
  // as slice:changed. Queuing them only buys a turn in which the agent reads its own echo and
279
- // skips it. Attribution decides, never a guess: only an agentId equal to ours is dropped —
280
- // a person's edit or another agent's (different id), or an event with no attribution at all,
281
- // is still queued, so a missing field degrades to the old behaviour rather than losing work.
345
+ // skips it, and re-reading the slices for them is wasted too: ralphLoop re-reads them right
346
+ // after every turn anyway. Attribution decides, never a guess: only an agentId equal to ours
347
+ // is dropped — a person's edit or another agent's, or an event with no attribution at all,
348
+ // is still handled, so a missing field degrades to queuing rather than losing work.
282
349
  if (payload?.agentId && cfg.agentId && payload.agentId === cfg.agentId) {
283
350
  console.log(`[agent] Own write — not queued (slice="${payload.sliceTitle}" status="${payload.sliceStatus}")`);
284
351
  return;
285
352
  }
353
+ await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
354
+ console.error('[agent] Slice persist error:', err),
355
+ );
286
356
  // Planned slices are handled by onPlannedSlice directly — no task needed.
287
357
  // queueAllStatuses opts out of that split entirely (e.g. bridge has no
288
358
  // onPlannedSlice consumer, so a lingering Planned slice would otherwise
@@ -292,15 +362,17 @@ async function handleSliceChanged(payload, cfg, kitDir, queueAllStatuses) {
292
362
  }
293
363
  }
294
364
 
295
- async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllStatuses = false } = {}) {
296
- let realtimeToken = await exitOn401('getRealtimeToken', () => getRealtimeToken(cfg));
365
+ // createAdapter is the realtime transport factory — injectable so the channel handling can be
366
+ // tested against a fake transport.
367
+ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllStatuses = false, createAdapter = createRealtimeAdapter } = {}) {
368
+ let realtimeToken = await retryTransient('getRealtimeToken', () => getRealtimeToken(cfg));
297
369
 
298
- await exitOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
370
+ await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
299
371
  console.error('[agent] Initial slice fetch error:', err),
300
372
  );
301
373
 
302
374
  const channelName = `board:${cfg.boardId}-slicechanged`;
303
- const realtime = await createRealtimeAdapter(cfg, realtimeToken);
375
+ const realtime = await createAdapter(cfg, realtimeToken);
304
376
 
305
377
  // Shared by the scheduled timer, a CHANNEL_ERROR/TIMED_OUT subscribe status, and a
306
378
  // 401 from the alive-ping — whichever notices the token is bad first wins; the rest
@@ -313,7 +385,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
313
385
  console.log(`[agent] ${ts()} Refreshing realtime token (reason: ${reason})...`);
314
386
  refreshing = (async () => {
315
387
  try {
316
- realtimeToken = await exitOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
388
+ realtimeToken = await getRealtimeToken(cfg);
317
389
  await realtime.setAuth(realtimeToken);
318
390
  console.log(`[agent] ${ts()} Token refreshed (reason: ${reason}, took ${Date.now() - startedAt}ms)`);
319
391
  } catch (err) {
@@ -328,34 +400,33 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
328
400
  };
329
401
 
330
402
  // A subscribe that errors on a stale token needs a fresh token AND a new join
331
- // attempt — setAuth alone doesn't re-join a channel that already errored out.
332
- // Retried with exponential backoff (2s → 5min) rather than a fixed delay and a cap:
333
- // the cap used to leave the channel dead for good — the scheduled refresh only calls
334
- // setAuth, it never re-joins — while the backoff alone keeps a genuinely revoked
335
- // access from hammering the platform. A revoked token ends the process anyway, via
336
- // exitOn401 inside refreshToken.
403
+ // attempt — setAuth alone doesn't re-join a channel that already errored out, and nothing
404
+ // else ever re-joins it, so this retries for as long as it takes, with backoff so a
405
+ // lasting failure doesn't hammer the platform. (A revoked token ends the process anyway:
406
+ // refreshToken's fetch gets the 401.)
337
407
  let channelErrorStreak = 0;
338
- let resubscribeTimer = null;
408
+ let resubscribePending = false;
339
409
  const onChannelFailure = (reason) => {
340
410
  // One failure can be reported more than once (a channel erroring while its retry is
341
411
  // already scheduled); a second resubscribe on top would join the channel twice.
342
- if (resubscribeTimer) return;
412
+ if (resubscribePending) return;
413
+ resubscribePending = true;
343
414
  channelErrorStreak += 1;
344
- const delay = backoffMs(channelErrorStreak, { baseMs: 2_000, capMs: 5 * 60_000 });
415
+ const delay = backoffMs(channelErrorStreak, PLATFORM_BACKOFF);
345
416
  console.warn(`[agent] ${ts()} Channel "${channelName}": ${reason} (attempt ${channelErrorStreak}) — resubscribing in ${Math.round(delay / 1000)}s`);
346
- resubscribeTimer = setTimeout(async () => {
417
+ setTimeout(async () => {
347
418
  try {
348
419
  await refreshToken(`channel ${reason}`);
349
420
  } catch {
350
421
  // Logged by refreshToken. Rejoining on the old token is still worth a try —
351
422
  // if it fails too, that's just the next failure, with a longer delay.
352
423
  }
353
- resubscribeTimer = null;
424
+ resubscribePending = false;
354
425
  subscribeChannel();
355
426
  }, delay);
356
427
  };
357
428
  const subscribeChannel = () => {
358
- Promise.resolve(realtime.subscribe(
429
+ realtime.subscribe(
359
430
  channelName,
360
431
  {
361
432
  // A kill names exactly one agent: {type: 'kill', id: '<agentId>', instruction: 'exit'}.
@@ -381,11 +452,11 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
381
452
  // Broadcasts sent while the channel was down are gone. Re-read the slices once so a
382
453
  // slice set to Planned during the gap still gets built — .slices/ is what ralphLoop
383
454
  // picks Planned work from.
384
- exitOn401('fetchAndPersistSlices (after reconnect)', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
455
+ fetchAndPersistSlices(cfg, kitDir).catch((err) =>
385
456
  console.error(`[agent] ${ts()} Slice re-fetch after reconnect failed:`, err),
386
457
  );
387
458
  },
388
- )).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
459
+ ).catch((err) => onChannelFailure(`subscribe failed: ${err.message}`));
389
460
  };
390
461
  subscribeChannel();
391
462
 
@@ -424,14 +495,7 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
424
495
  // ── Ralph loop ────────────────────────────────────────────────────────────────
425
496
 
426
497
  function hasPendingTasks(kitDir) {
427
- const tasksPath = join(kitDir, 'tasks.json');
428
- if (!existsSync(tasksPath)) return false;
429
- try {
430
- const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
431
- return Array.isArray(tasks) && tasks.length > 0;
432
- } catch {
433
- return false;
434
- }
498
+ return readTasks(join(kitDir, 'tasks.json')).length > 0;
435
499
  }
436
500
 
437
501
  function readCurrentContext(kitDir) {
@@ -554,9 +618,8 @@ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
554
618
  // Returns true once fn succeeds; false for a turn that timed out (see lib/turn.js). A timeout
555
619
  // is not retried in place: re-running the same hung turn every 60s would spin forever without
556
620
  // the stuck-slice guard ever seeing it, so it goes back to ralphLoop, which recounts the slice.
557
- // Any other failure is retried in place with exponential backoff (30s → 10min): a harness
558
- // that fails fast (API outage, rate limit, broken install) used to be relaunched every 60s
559
- // for as long as it stayed broken.
621
+ // Any other failure is retried in place, with backoff (30s → 10min) so a harness that keeps
622
+ // failing fast (API outage, rate limit, broken install) isn't relaunched in a tight loop.
560
623
  async function runWithRetry(label, fn) {
561
624
  for (let attempt = 1; ; attempt++) {
562
625
  try {
@@ -592,7 +655,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
592
655
 
593
656
  if (credentialed && hasPendingTasks(kitDir)) {
594
657
  const prompt = readFileSync(promptFile, 'utf-8');
595
- await runWithRetry('onTask: loading slice from board...', () => onTask(prompt));
658
+ await runWithRetry('onTask: loading slice from board...', () => inTurn(kitDir, () => onTask(prompt)));
596
659
  await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
597
660
  didWork = true;
598
661
  }
@@ -631,7 +694,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
631
694
  console.log(`[ralph] No planned slices in current context "${ctx}" — waiting. Switch context on the board to continue.`);
632
695
  lastIdleCtx = ctx;
633
696
  }
634
- await new Promise((r) => setTimeout(r, 10_000));
697
+ await sleep(10_000);
635
698
  } else {
636
699
  lastIdleCtx = undefined;
637
700
  }
@@ -640,7 +703,20 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
640
703
 
641
704
  // ── Public API ────────────────────────────────────────────────────────────────
642
705
 
643
- export { loadLocalConfig, fetchPlatformConfig, exitOn401, startRealtimeAgent };
706
+ export { HttpError, loadLocalConfig, fetchPlatformConfig, retryTransient, startRealtimeAgent, writeTask, inTurn, hasPendingTasks };
707
+
708
+ // The connect line a runner puts in front of every prompt. --local must mean zero board
709
+ // contact, so it is empty then — handed live credentials, the agent would treat itself as
710
+ // already connected and go straight to board sync. The token is named, never included: the
711
+ // prompt reaches the harness on its command line (visible to every local user in `ps`) and
712
+ // lands in session transcripts, so it carries $EVENTMODELERS_TOKEN — the runner puts the value
713
+ // in the child's env, connect treats the reference as the inline token, and a curl header
714
+ // written as "x-token: $EVENTMODELERS_TOKEN" expands in the shell.
715
+ export function connectHeader(cfg, localOnly) {
716
+ return !localOnly && cfg.boardId
717
+ ? `board=${cfg.boardId} token=$EVENTMODELERS_TOKEN org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
718
+ : '';
719
+ }
644
720
 
645
721
  // Who this process is. RALPH_AGENT_ID/RALPH_AGENT_NAME are `eventmodelers run --id/--name`,
646
722
  // passed down as env (see cli.js's run dispatcher): a per-run identity override so a second
@@ -658,6 +734,7 @@ export function resolveAgentIdentity(kitDir, agentType = 'BUILD', cfg = loadLoca
658
734
 
659
735
  export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false, localOnly = false }) {
660
736
  const local = loadLocalConfig(kitDir);
737
+ redactConsole([local.token]);
661
738
  Object.assign(local, resolveAgentIdentity(kitDir, agentType, local));
662
739
 
663
740
  console.log(`Ralph — kit: ${kitDir}`);
@@ -674,7 +751,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
674
751
  return;
675
752
  }
676
753
 
677
- const cfg = await exitOn401('fetchPlatformConfig', () => fetchPlatformConfig(local));
754
+ const cfg = await retryTransient('fetchPlatformConfig', () => fetchPlatformConfig(local));
678
755
  console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
679
756
 
680
757
  await Promise.all([
@@ -0,0 +1,36 @@
1
+ // Keeps secrets out of the loop's own log lines. Anything this process prints can end up in a
2
+ // terminal scrollback, a CI log or a `docker logs` dump: an error body that echoes a header,
3
+ // a verbose tool input with a curl command in it, a debug line with a config object. So
4
+ // rather than trusting every call site, console output is masked on the way out:
5
+ // - the platform API token, by value (it's a plain uuid, so there's no pattern for it)
6
+ // - anything JWT-shaped (eyJ….….…) — the realtime token and Supabase keys look like that
7
+ //
8
+ // Output a child process writes straight to the terminal (stdio: 'inherit') never passes
9
+ // through console here, so it isn't covered.
10
+
11
+ import { format } from 'util';
12
+
13
+ const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
14
+
15
+ // A secret shorter than this would mask ordinary text (an empty or placeholder value most of all).
16
+ const MIN_SECRET_LENGTH = 8;
17
+
18
+ export function redactString(text, secrets = []) {
19
+ let out = String(text).replace(JWT, '***');
20
+ for (const secret of secrets) {
21
+ if (typeof secret === 'string' && secret.length >= MIN_SECRET_LENGTH) out = out.replaceAll(secret, '***');
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // Routes console.log/info/warn/error/debug through redactString. Arguments are formatted the
27
+ // way console would format them first, so an Error or an object is masked too. Returns a
28
+ // function that puts the originals back.
29
+ export function redactConsole(secrets) {
30
+ const methods = ['log', 'info', 'warn', 'error', 'debug'];
31
+ const originals = Object.fromEntries(methods.map((m) => [m, console[m]]));
32
+ for (const m of methods) {
33
+ console[m] = (...args) => originals[m].call(console, redactString(format(...args), secrets));
34
+ }
35
+ return () => { for (const m of methods) console[m] = originals[m]; };
36
+ }