@eventmodelers/cli 1.0.45 → 1.0.46

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 (102) hide show
  1. package/README.md +4 -1
  2. package/cli.js +53 -1
  3. package/package.json +2 -2
  4. package/shared/build-kit/lib/checks/README.md +59 -0
  5. package/shared/build-kit/lib/ralph.js +110 -28
  6. package/shared/build-kit/lib/util/find-slice.cjs +59 -0
  7. package/shared/build-kit/ralph-claude.js +6 -2
  8. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
  9. package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
  10. package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
  11. package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  12. package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
  13. package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
  14. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
  15. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
  16. package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
  17. package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
  18. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
  19. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
  20. package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
  21. package/stacks/kurrent/templates/root/README.md +46 -0
  22. package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
  23. package/stacks/kurrent/templates/root/mvnw +259 -0
  24. package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
  25. package/stacks/kurrent/templates/root/pom.xml +152 -0
  26. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  27. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
  28. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
  29. package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
  30. package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
  31. package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
  32. package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
  33. package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  34. package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
  35. package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
  36. package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  37. package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  38. package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  39. package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  40. package/stacks/node/templates/root/.githooks/pre-commit +11 -0
  41. package/stacks/node/templates/root/package.json +2 -1
  42. package/stacks/node/templates/root/setup-env.sh +7 -1
  43. package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
  44. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  45. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
  46. package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
  47. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
  48. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
  49. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
  50. package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
  51. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
  52. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
  53. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
  54. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
  55. package/stacks/opencqrs/templates/root/README.md +42 -0
  56. package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
  57. package/stacks/opencqrs/templates/root/mvnw +259 -0
  58. package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
  59. package/stacks/opencqrs/templates/root/pom.xml +139 -0
  60. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  61. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
  62. package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
  63. package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
  64. package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
  65. package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
  66. package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
  67. package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  68. package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
  69. package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
  70. package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  71. package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  72. package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  73. package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  74. package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
  75. package/stacks/supabase/templates/root/package.json +2 -1
  76. package/stacks/supabase/templates/root/setup-env.sh +7 -1
  77. package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
  78. package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
  79. package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
  80. package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
  81. package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
  82. package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
  83. package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
  84. package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
  85. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
  86. package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
  87. package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
  88. package/stacks/umadb/templates/root/README.md +48 -0
  89. package/stacks/umadb/templates/root/docker-compose.yml +29 -0
  90. package/stacks/umadb/templates/root/mvnw +259 -0
  91. package/stacks/umadb/templates/root/mvnw.cmd +149 -0
  92. package/stacks/umadb/templates/root/pom.xml +151 -0
  93. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
  94. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
  95. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
  96. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
  97. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
  98. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
  99. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
  100. package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
  101. package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
  102. package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
package/README.md CHANGED
@@ -19,6 +19,9 @@ npx @eventmodelers/cli init --stack node # Node.js / TypeScript
19
19
  npx @eventmodelers/cli init --stack supabase # Supabase
20
20
  npx @eventmodelers/cli init --stack axon # Axon Framework (Java/Kotlin)
21
21
  npx @eventmodelers/cli init --stack cratis-csharp # Cratis (.NET/C#)
22
+ npx @eventmodelers/cli init --stack opencqrs # OpenCQRS (Java, EventSourcingDB)
23
+ npx @eventmodelers/cli init --stack umadb # UmaDB (Java)
24
+ npx @eventmodelers/cli init --stack kurrent # Kurrent (Java, KurrentDB)
22
25
  ```
23
26
 
24
27
  The installer prompts for your API token, Organization ID, and Board ID from [app.eventmodelers.ai/account](https://app.eventmodelers.ai/account), scaffolds the stack into your project, and writes `.eventmodelers/config.json` with your credentials.
@@ -73,7 +76,7 @@ your-project/
73
76
  └── CLAUDE.md ← agent instructions
74
77
  ```
75
78
 
76
- The four backend stacks (`node`, `supabase`, `axon`, `cratis-csharp`) also scaffold a real project skeleton into your project root (`templates/root/`) — source layout, build files, migrations, etc.
79
+ The seven backend stacks (`node`, `supabase`, `axon`, `cratis-csharp`, `opencqrs`, `umadb`, `kurrent`) also scaffold a real project skeleton into your project root (`templates/root/`) — source layout, build files, migrations, etc.
77
80
 
78
81
  ## Skills
79
82
 
package/cli.js CHANGED
@@ -70,6 +70,27 @@ const STACKS = {
70
70
  useShared: true,
71
71
  needsBoardId: true,
72
72
  },
73
+ opencqrs: {
74
+ label: 'OpenCQRS (Java, EventSourcingDB)',
75
+ kitSubdir: 'build-kit',
76
+ kitDirName: '.build-kit',
77
+ useShared: true,
78
+ needsBoardId: true,
79
+ },
80
+ umadb: {
81
+ label: 'UmaDB (Java)',
82
+ kitSubdir: 'build-kit',
83
+ kitDirName: '.build-kit',
84
+ useShared: true,
85
+ needsBoardId: true,
86
+ },
87
+ kurrent: {
88
+ label: 'Kurrent (Java, KurrentDB)',
89
+ kitSubdir: 'build-kit',
90
+ kitDirName: '.build-kit',
91
+ useShared: true,
92
+ needsBoardId: true,
93
+ },
73
94
  };
74
95
 
75
96
  // Not a stack — no backend scaffold, just skills + the agent loop. Installed via
@@ -774,7 +795,10 @@ async function installStack(stackKey, stackCfg, options = {}) {
774
795
  // first-installed kit added (e.g. node_modules/.idea from a build-kit install).
775
796
  const gitignoreDest = join(targetDir, '.gitignore');
776
797
  const priorGitignore = existsSync(gitignoreDest) ? readFileSync(gitignoreDest, 'utf-8') : null;
777
- copyDirContents(rootSrc, targetDir, { skip: ['CLAUDE.md'] });
798
+ // .githooks/ (the slice commit-scope guard) is opt-in via `init --hooks` — skipped
799
+ // here and handled explicitly below so a plain `init` never silently changes the
800
+ // project's git hook wiring.
801
+ copyDirContents(rootSrc, targetDir, { skip: ['CLAUDE.md', '.githooks'] });
778
802
  if (priorGitignore !== null && existsSync(gitignoreDest)) {
779
803
  const incoming = readFileSync(gitignoreDest, 'utf-8');
780
804
  const merged = mergeGitignoreLines(priorGitignore, incoming);
@@ -888,6 +912,31 @@ async function installStack(stackKey, stackCfg, options = {}) {
888
912
  }
889
913
  }
890
914
 
915
+ // --- 3b. Opt-in slice commit-scope guard (`init --hooks`) ---
916
+ // Skipped from the generic root copy above so a plain `init` never touches git's
917
+ // hook wiring; installed explicitly here only when requested.
918
+ if (options.hooks) {
919
+ const hooksSrc = join(rootSrc, '.githooks');
920
+ if (existsSync(hooksSrc)) {
921
+ copyDirContents(hooksSrc, join(targetDir, '.githooks'));
922
+ const preCommitHook = join(targetDir, '.githooks', 'pre-commit');
923
+ if (existsSync(preCommitHook)) {
924
+ // cpSync doesn't reliably carry over the executable bit across platforms,
925
+ // and git silently skips a non-executable hook.
926
+ try { execSync(`chmod +x "${preCommitHook}"`); } catch {}
927
+ }
928
+ try {
929
+ execSync('git rev-parse --git-dir', { cwd: targetDir, stdio: 'ignore' });
930
+ execSync('git config core.hooksPath .githooks', { cwd: targetDir });
931
+ console.log(' ✓ Installed .githooks/ and set core.hooksPath — commits touching src/slices/ are now scope-guarded');
932
+ } catch {
933
+ console.log(' ✓ Installed .githooks/ — run `git config core.hooksPath .githooks` once this directory is a git repo to activate it');
934
+ }
935
+ } else {
936
+ console.log(' ℹ️ --hooks was given but this stack ships no .githooks/ template — nothing to install');
937
+ }
938
+ }
939
+
891
940
  // --- 4. Install kit dependencies ---
892
941
  if (existsSync(join(kitDir, 'package.json'))) {
893
942
  console.log('📦 Installing kit dependencies...');
@@ -1539,6 +1588,7 @@ credentialFlags(program
1539
1588
  .option('--target <name>', `Bridge target framework (${Object.keys(BRIDGE_TARGETS).join(', ')}) — only meaningful with --bridge`)
1540
1589
  .option('--hook <command>', 'Persist a default shell command hook for `bridge` to run per batch of slice changes instead of Claude/Ollama (e.g. commit + push .slices/ for a CI pipeline to pick up) — only meaningful with --bridge. Can also be set per-run with `bridge --hook`.')
1541
1590
  .option('--build-kit', 'Install a blank build-kit scaffold (.build-kit/ + .claude/skills/build-*/SKILL.md placeholders, all TODO-marked) for a stack not built into this CLI yet — no fixed backend. Mutually exclusive with --stack/--modeling/--bridge.')
1591
+ .option('--hooks', 'Install the slice commit-scope guard (.githooks/pre-commit, running .build-kit/lib/check-commit-scope.cjs) and wire it up via `git config core.hooksPath .githooks` — only meaningful with --stack (build-kit stacks). Off by default.')
1542
1592
  .option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
1543
1593
  .option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json'))
1544
1594
  .action(async (opts, command) => {
@@ -1628,6 +1678,7 @@ credentialFlags(program
1628
1678
  force: opts.force,
1629
1679
  credentialOverrides: credentialOverridesFromOpts(opts),
1630
1680
  templatesSource: join(clonedDir, 'templates'),
1681
+ hooks: opts.hooks,
1631
1682
  });
1632
1683
  return;
1633
1684
  }
@@ -1639,6 +1690,7 @@ credentialFlags(program
1639
1690
  global: opts.global,
1640
1691
  force: opts.force,
1641
1692
  credentialOverrides: credentialOverridesFromOpts(opts),
1693
+ hooks: opts.hooks,
1642
1694
  });
1643
1695
  });
1644
1696
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.45",
4
- "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, or modeling-only)",
3
+ "version": "1.0.46",
4
+ "description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "eventmodelers": "cli.js"
@@ -0,0 +1,59 @@
1
+ # Commit-scope guard checks
2
+
3
+ Every `*.cjs` file in this folder is loaded and run automatically by
4
+ `../check-commit-scope.cjs` against the staged changeset of a slice commit
5
+ (a commit that touches `src/slices/{context}/{slice}/**`). Files run in
6
+ filename sort order — that's why they're numbered.
7
+
8
+ ## Adding a check
9
+
10
+ Create a new file here, e.g. `60-my-check.cjs`, exporting:
11
+
12
+ ```js
13
+ 'use strict';
14
+
15
+ module.exports = {
16
+ name: 'my-check', // short id, prefixed onto any violation it reports
17
+ skipIfAlreadyFailing: false, // optional: true = skip this check once an earlier
18
+ // one already found a violation (use for slow checks,
19
+ // e.g. a build/typecheck — no point running it if the
20
+ // commit is already going to be rejected)
21
+ run(ctx) {
22
+ // Inspect ctx and return an array of violations. No violations → return
23
+ // [] (or undefined/null).
24
+ return [
25
+ { path: 'src/slices/cart/AddItem/AddItemCommand.ts', reason: 'why this is a problem' },
26
+ ];
27
+ },
28
+ };
29
+ ```
30
+
31
+ ### `ctx` passed to every check
32
+
33
+ | field | type | meaning |
34
+ |-----------------|-----------------|-----------------------------------------------------------------------|
35
+ | `changes` | `{status, path}[]` | staged files — `status` is git's single-letter code (`A`/`M`/`D`/...) |
36
+ | `touchesSlice` | `boolean` | always `true` — the runner only loads checks once this is true |
37
+ | `repoRoot` | `string` | absolute path to the repo root (`git rev-parse --show-toplevel`) |
38
+ | `SLICE_PATTERN` | `RegExp` | matches a path inside a slice's own folder: `src/slices/{ctx}/{slice}/`|
39
+
40
+ ### Return value
41
+
42
+ An array of `{ path, reason }` objects — one per violation. `path` should be
43
+ the offending file (or a synthetic label like `'(slice.json)'` if the problem
44
+ isn't tied to one staged file). `reason` is a short, human-readable sentence
45
+ explaining what's wrong; the runner prefixes it with `[<check name>]`.
46
+
47
+ Return `[]`, `undefined`, or `null` when there's nothing to report. Throwing
48
+ is treated as a violation too (`[<check name>] threw: <message>`), so a check
49
+ doesn't need its own top-level try/catch.
50
+
51
+ ### Conventions used by the existing checks
52
+
53
+ - Prefer **not blocking** over a false positive when a check can't determine
54
+ the answer confidently (e.g. slice.json isn't found, or a file's shape isn't
55
+ recognized) — these are heuristics layered on top of the hard path-scope
56
+ rules, not a full compiler/schema validator.
57
+ - If two checks would flag the *same file*, only the first one to run reports
58
+ it (the runner dedupes by path) — so keep genuinely distinct concerns in
59
+ separate files rather than trying to avoid overlap yourself.
@@ -201,14 +201,28 @@ async function fetchAndPersistSlices(cfg, kitDir) {
201
201
  writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
202
202
  }
203
203
 
204
- // Write per-context index.json and per-slice slice.json
204
+ // Write per-context index.json and per-slice slice.json.
205
+ //
206
+ // This endpoint (`/slicedata/slices`) is the CHEAP summary one — `{ id, title, status }`
207
+ // only, no commands/events/specifications/codeGen prompts. Its whole job here is to keep
208
+ // `status` fresh so hasPendingTasks/getFirstPlannedSliceTitle see live transitions; it must
209
+ // NEVER clobber the richer slice.json a full fetch (`/load-slice`, `eventmodelers fetch`,
210
+ // `eventmodelers listen`) already wrote for the same slice. So every write below merges
211
+ // onto whatever's already on disk — spreading the existing object first, the fresh summary
212
+ // fields second — instead of replacing it wholesale.
205
213
  for (const [contextSlug, { slices: ctxSlices }] of Object.entries(contexts)) {
206
214
  const contextDir = join(slicesDir, contextSlug);
207
215
  mkdirSync(contextDir, { recursive: true });
208
216
 
217
+ const indexPath = join(contextDir, 'index.json');
218
+ const existingIndex = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, 'utf-8')) : { slices: [] };
219
+ const existingById = new Map((existingIndex.slices ?? []).map((e) => [e.id, e]));
220
+
209
221
  const indexSlices = ctxSlices.map((s, i) => {
210
222
  const folder = (s.title ?? s.id).replaceAll(' ', '').toLowerCase();
223
+ const existing = existingById.get(s.id);
211
224
  return {
225
+ ...existing,
212
226
  id: s.id,
213
227
  slice: s.title,
214
228
  index: i,
@@ -216,16 +230,18 @@ async function fetchAndPersistSlices(cfg, kitDir) {
216
230
  contextSlug,
217
231
  folder,
218
232
  status: s.status,
219
- definition: { id: s.id, title: s.title, status: s.status },
233
+ definition: { ...existing?.definition, id: s.id, title: s.title, status: s.status },
220
234
  };
221
235
  });
222
- writeFileSync(join(contextDir, 'index.json'), JSON.stringify({ slices: indexSlices }, null, 2), 'utf-8');
236
+ writeFileSync(indexPath, JSON.stringify({ slices: indexSlices }, null, 2), 'utf-8');
223
237
 
224
238
  for (const slice of ctxSlices) {
225
239
  const folder = (slice.title ?? slice.id).replaceAll(' ', '').toLowerCase();
226
240
  const sliceDir = join(contextDir, folder);
227
241
  mkdirSync(sliceDir, { recursive: true });
228
- writeFileSync(join(sliceDir, 'slice.json'), JSON.stringify(slice, null, 2), 'utf-8');
242
+ const sliceJsonPath = join(sliceDir, 'slice.json');
243
+ const existingSlice = existsSync(sliceJsonPath) ? JSON.parse(readFileSync(sliceJsonPath, 'utf-8')) : {};
244
+ writeFileSync(sliceJsonPath, JSON.stringify({ ...existingSlice, ...slice }, null, 2), 'utf-8');
229
245
  }
230
246
  }
231
247
 
@@ -265,30 +281,83 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
265
281
 
266
282
  const channelName = `board:${cfg.boardId}-slicechanged`;
267
283
  const realtime = await createRealtimeAdapter(cfg, realtimeToken);
268
- realtime.subscribe(
269
- channelName,
270
- {
271
- message: (payload) => {
272
- if (payload === 'Exit') {
273
- console.log('[agent] Received "Exit" — shutting down');
274
- process.exit(0);
284
+
285
+ // Shared by the scheduled timer, a CHANNEL_ERROR/TIMED_OUT subscribe status, and a
286
+ // 401 from the alive-ping — whichever notices the token is bad first wins; the rest
287
+ // just await the same in-flight refresh instead of firing duplicate mint requests.
288
+ const ts = () => new Date().toISOString();
289
+ let refreshing = null;
290
+ const refreshToken = (reason) => {
291
+ if (!refreshing) {
292
+ const startedAt = Date.now();
293
+ console.log(`[agent] ${ts()} Refreshing realtime token (reason: ${reason})...`);
294
+ refreshing = (async () => {
295
+ try {
296
+ realtimeToken = await retryOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
297
+ await realtime.setAuth(realtimeToken);
298
+ console.log(`[agent] ${ts()} Token refreshed (reason: ${reason}, took ${Date.now() - startedAt}ms)`);
299
+ } catch (err) {
300
+ console.error(`[agent] ${ts()} Token refresh FAILED (reason: ${reason}):`, err);
301
+ throw err;
302
+ } finally {
303
+ refreshing = null;
275
304
  }
305
+ })();
306
+ }
307
+ return refreshing;
308
+ };
309
+
310
+ // A subscribe that errors on a stale token needs a fresh token AND a new join
311
+ // attempt — setAuth alone doesn't re-join a channel that already errored out.
312
+ // Capped so a non-expiry auth failure (e.g. genuinely revoked access) can't turn
313
+ // into a tight resubscribe loop hammering the platform forever.
314
+ let channelErrorStreak = 0;
315
+ const subscribeChannel = () => {
316
+ realtime.subscribe(
317
+ channelName,
318
+ {
319
+ message: (payload) => {
320
+ if (payload === 'Exit') {
321
+ console.log(`[agent] ${ts()} Received "Exit" — shutting down`);
322
+ process.exit(0);
323
+ }
324
+ },
325
+ 'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
276
326
  },
277
- 'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
278
- },
279
- (status) => console.log(`[agent] Channel "${channelName}": ${status}`),
280
- );
327
+ async (status) => {
328
+ if (status !== 'CHANNEL_ERROR' && status !== 'TIMED_OUT') {
329
+ if (channelErrorStreak > 0) {
330
+ console.log(`[agent] ${ts()} Channel "${channelName}": ${status} — recovered after ${channelErrorStreak} failed attempt(s)`);
331
+ } else {
332
+ console.log(`[agent] ${ts()} Channel "${channelName}": ${status}`);
333
+ }
334
+ channelErrorStreak = 0;
335
+ return;
336
+ }
337
+ channelErrorStreak += 1;
338
+ console.warn(`[agent] ${ts()} Channel "${channelName}": ${status} (attempt ${channelErrorStreak}/5)`);
339
+ if (channelErrorStreak > 5) {
340
+ console.error(`[agent] ${ts()} Channel "${channelName}" failed ${channelErrorStreak} times in a row — giving up until the next scheduled token refresh (every 10min)`);
341
+ return;
342
+ }
343
+ try {
344
+ await refreshToken(`channel ${status}`);
345
+ await new Promise((r) => setTimeout(r, 2_000));
346
+ console.log(`[agent] ${ts()} Resubscribing to channel "${channelName}" (attempt ${channelErrorStreak}/5)...`);
347
+ subscribeChannel();
348
+ } catch (err) {
349
+ console.error(`[agent] ${ts()} Token refresh after channel error failed, will not resubscribe this round:`, err);
350
+ }
351
+ },
352
+ );
353
+ };
354
+ subscribeChannel();
281
355
 
282
- setInterval(async () => {
283
- try {
284
- realtimeToken = await retryOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
285
- await realtime.setAuth(realtimeToken);
286
- console.log('[agent] Token refreshed');
287
- } catch (err) {
288
- console.error('[agent] Token refresh failed:', err);
289
- }
356
+ setInterval(() => {
357
+ refreshToken('scheduled 10min refresh').catch((err) => console.error(`[agent] ${ts()} Scheduled token refresh failed:`, err));
290
358
  }, 10 * 60 * 1000);
291
359
 
360
+ let lastPingFailed = false;
292
361
  const ping = async () => {
293
362
  try {
294
363
  const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
@@ -297,9 +366,19 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
297
366
  body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: agentType, agent_id: cfg.agentId }),
298
367
  signal: AbortSignal.timeout(10_000),
299
368
  });
300
- if (!res.ok) console.error(`[agent] Ping failed: ${res.status} ${await res.text().catch(() => '')}`);
369
+ if (!res.ok) {
370
+ console.error(`[agent] ${ts()} Ping failed: ${res.status} ${await res.text().catch(() => '')}`);
371
+ lastPingFailed = true;
372
+ if (res.status === 401) {
373
+ await refreshToken('ping-401').catch((err) => console.error(`[agent] ${ts()} Token refresh after 401 ping failed:`, err));
374
+ }
375
+ return;
376
+ }
377
+ if (lastPingFailed) console.log(`[agent] ${ts()} Ping recovered`);
378
+ lastPingFailed = false;
301
379
  } catch (err) {
302
- console.error('[agent] Ping error:', err);
380
+ console.error(`[agent] ${ts()} Ping error:`, err);
381
+ lastPingFailed = true;
303
382
  }
304
383
  };
305
384
  await ping();
@@ -354,10 +433,13 @@ async function runWithRetry(label, fn) {
354
433
  }
355
434
  }
356
435
 
357
- async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice) {
436
+ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false) {
358
437
  const promptFile = join(kitDir, 'lib', 'prompt.md');
359
438
  const backendPromptFile = join(kitDir, 'lib', 'backend-prompt.md');
360
- const credentialed = hasCredentials(cfg);
439
+ // --local must mean zero board contact even when .eventmodelers/config.json
440
+ // happens to hold valid credentials — never let a locally-present token flip
441
+ // this back on.
442
+ const credentialed = !localOnly && hasCredentials(cfg);
361
443
  let lastIdleCtx;
362
444
 
363
445
  while (true) {
@@ -410,7 +492,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
410
492
  // reaches out to the platform at all.
411
493
  if (localOnly || !hasCredentials(local)) {
412
494
  console.log(` mode: local-only (no platform sync)${localOnly ? ' — forced by --local' : ''}\n`);
413
- await ralphLoop(kitDir, local, onTask, onPlannedSlice);
495
+ await ralphLoop(kitDir, local, onTask, onPlannedSlice, localOnly);
414
496
  return;
415
497
  }
416
498
 
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // Shared by checks that need to cross-reference code against the slice's own
4
+ // definition. Not a check itself — no `run(ctx)` interface here.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ function normalize(s) {
10
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
11
+ }
12
+
13
+ // Given the {context}/{SliceName} folder names used under src/slices/, find the
14
+ // matching slice.json under .build-kit/.slices/<contextSlug>/<sliceFolder>/. The
15
+ // two sides are slugified differently (see shared/skills/load-slice/SKILL.md —
16
+ // contextSlug is hyphenated, sliceFolder strips all spaces), so this matches on
17
+ // a normalized (lowercase, alphanumeric-only) form instead of exact equality.
18
+ // Returns null — never throws — when zero or more than one candidate matches;
19
+ // callers should treat that as "can't verify" and skip rather than block.
20
+ function findSliceJson(repoRoot, context, sliceName) {
21
+ const root = path.join(repoRoot, '.build-kit', '.slices');
22
+ if (!fs.existsSync(root)) return null;
23
+
24
+ const wantSlice = normalize(sliceName);
25
+ const candidates = [];
26
+
27
+ let contextDirs;
28
+ try {
29
+ contextDirs = fs.readdirSync(root, { withFileTypes: true });
30
+ } catch {
31
+ return null;
32
+ }
33
+
34
+ for (const contextDir of contextDirs) {
35
+ if (!contextDir.isDirectory()) continue;
36
+ const contextPath = path.join(root, contextDir.name);
37
+ let sliceDirs;
38
+ try {
39
+ sliceDirs = fs.readdirSync(contextPath, { withFileTypes: true });
40
+ } catch {
41
+ continue;
42
+ }
43
+ for (const sliceDir of sliceDirs) {
44
+ if (!sliceDir.isDirectory()) continue;
45
+ if (normalize(sliceDir.name) !== wantSlice) continue;
46
+ const slicePath = path.join(contextPath, sliceDir.name, 'slice.json');
47
+ if (fs.existsSync(slicePath)) candidates.push(slicePath);
48
+ }
49
+ }
50
+
51
+ if (candidates.length !== 1) return null;
52
+ try {
53
+ return JSON.parse(fs.readFileSync(candidates[0], 'utf8'));
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ module.exports = { findSliceJson, normalize };
@@ -11,7 +11,11 @@ const kitDir = dirname(fileURLToPath(import.meta.url));
11
11
  const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
12
12
 
13
13
  const cfg = loadLocalConfig(kitDir);
14
- const inlineHeader = cfg.boardId
14
+ const localOnly = process.env.RALPH_LOCAL === '1';
15
+ // --local must mean zero board contact — never hand Claude live board
16
+ // credentials via the inline header, even if config.json has them, or it'll
17
+ // treat them as already-connected and skip straight to board sync.
18
+ const inlineHeader = !localOnly && cfg.boardId
15
19
  ? `board=${cfg.boardId} token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
16
20
  : '';
17
21
 
@@ -96,7 +100,7 @@ startRalph({
96
100
  projectDir,
97
101
  onTask: runClaude,
98
102
  onPlannedSlice: runClaude,
99
- localOnly: process.env.RALPH_LOCAL === '1',
103
+ localOnly,
100
104
  }).catch((err) => {
101
105
  console.error('[ralph] Fatal:', err);
102
106
  process.exit(1);