@eventmodelers/cli 1.0.54 → 1.0.56

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 (60) hide show
  1. package/README.md +6 -0
  2. package/cli.js +24 -0
  3. package/package.json +1 -1
  4. package/shared/build-kit/lib/ralph.js +103 -5
  5. package/shared/skills/learn-eventmodelers-api/SKILL.md +3 -0
  6. package/stacks/node/templates/.claude/skills/build-automation/SKILL.md +5 -1
  7. package/stacks/node/templates/.claude/skills/build-state-change/SKILL.md +90 -2
  8. package/stacks/node/templates/.claude/skills/build-state-view/SKILL.md +81 -3
  9. package/stacks/node/templates/build-kit/CLAUDE.md +6 -1
  10. package/stacks/node/templates/build-kit/lib/checks/60-openapi-annotation.cjs +72 -0
  11. package/stacks/react/templates/.claude/skills/build-automation/SKILL.md +42 -0
  12. package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +43 -0
  13. package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +42 -0
  14. package/stacks/react/templates/build-kit/CLAUDE.md +62 -0
  15. package/stacks/react/templates/build-kit/README.md +79 -0
  16. package/stacks/react/templates/build-kit/lib/AGENT.md +47 -0
  17. package/stacks/react/templates/build-kit/lib/backend-prompt.md +135 -0
  18. package/stacks/react/templates/build-kit/lib/prompt.md +139 -0
  19. package/stacks/react/templates/build-kit/lib/ralph.js +508 -0
  20. package/stacks/react/templates/build-kit/package.json +9 -0
  21. package/stacks/react/templates/build-kit/ralph-claude.js +107 -0
  22. package/stacks/react/templates/build-kit/ralph-ollama.js +40 -0
  23. package/stacks/supabase/templates/.claude/skills/build-automation/SKILL.md +5 -1
  24. package/stacks/supabase/templates/.claude/skills/build-state-change/SKILL.md +90 -2
  25. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +80 -2
  26. package/stacks/supabase/templates/.claude/skills/build-webhook/SKILL.md +73 -0
  27. package/stacks/supabase/templates/build-kit/CLAUDE.md +6 -1
  28. package/stacks/supabase/templates/build-kit/lib/checks/60-openapi-annotation.cjs +72 -0
  29. package/stacks/supabase/templates/root/src/swagger.ts +3 -1
  30. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +305 -0
  31. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +238 -0
  32. package/stacks/supabase-react/templates/.claude/skills/init-style-guide/SKILL.md +60 -0
  33. package/stacks/supabase-react/templates/.claude/skills/learn-styleguide/SKILL.md +28 -0
  34. package/stacks/supabase-react/templates/.claude/skills/learn-styleguide/references/README.md +5 -0
  35. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +149 -0
  36. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +47 -0
  37. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +139 -0
  38. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +145 -0
  39. package/stacks/supabase-react/templates/root/.env.example +12 -0
  40. package/stacks/supabase-react/templates/root/.oxlintrc.json +9 -0
  41. package/stacks/supabase-react/templates/root/README.md +49 -0
  42. package/stacks/supabase-react/templates/root/index.html +13 -0
  43. package/stacks/supabase-react/templates/root/package.json +26 -0
  44. package/stacks/supabase-react/templates/root/public/favicon.svg +1 -0
  45. package/stacks/supabase-react/templates/root/public/icons.svg +24 -0
  46. package/stacks/supabase-react/templates/root/src/App.css +184 -0
  47. package/stacks/supabase-react/templates/root/src/App.tsx +122 -0
  48. package/stacks/supabase-react/templates/root/src/assets/hero.png +0 -0
  49. package/stacks/supabase-react/templates/root/src/assets/react.svg +1 -0
  50. package/stacks/supabase-react/templates/root/src/assets/vite.svg +1 -0
  51. package/stacks/supabase-react/templates/root/src/index.css +111 -0
  52. package/stacks/supabase-react/templates/root/src/lib/api.ts +127 -0
  53. package/stacks/supabase-react/templates/root/src/lib/supabase.ts +10 -0
  54. package/stacks/supabase-react/templates/root/src/main.tsx +10 -0
  55. package/stacks/supabase-react/templates/root/src/slices/.gitkeep +0 -0
  56. package/stacks/supabase-react/templates/root/src/vite-env.d.ts +13 -0
  57. package/stacks/supabase-react/templates/root/tsconfig.app.json +26 -0
  58. package/stacks/supabase-react/templates/root/tsconfig.json +7 -0
  59. package/stacks/supabase-react/templates/root/tsconfig.node.json +23 -0
  60. package/stacks/supabase-react/templates/root/vite.config.ts +7 -0
package/README.md CHANGED
@@ -22,6 +22,8 @@ npx @eventmodelers/cli init --stack cratis-csharp # Cratis (.NET/C#)
22
22
  npx @eventmodelers/cli init --stack opencqrs # OpenCQRS (Java, EventSourcingDB)
23
23
  npx @eventmodelers/cli init --stack umadb # UmaDB (Java)
24
24
  npx @eventmodelers/cli init --stack kurrent # Kurrent (Java, KurrentDB)
25
+ npx @eventmodelers/cli init --stack react # React (frontend, board-polling sync) — TODO-marked, not yet filled in
26
+ npx @eventmodelers/cli init --stack supabase-react # React + Supabase (frontend, UI-only, realtime sync)
25
27
  ```
26
28
 
27
29
  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.
@@ -78,6 +80,10 @@ your-project/
78
80
 
79
81
  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.
80
82
 
83
+ `react` and `supabase-react` are two more registered stacks (installable the same way). `supabase-react` is real, filled-in content — a Vite + React 19 + TypeScript scaffold that authenticates and issues command POSTs via a Supabase session (`src/lib/api.ts`/`src/lib/supabase.ts`), plus `init-style-guide`/`learn-styleguide` skills so generated UI stays on-brand. It's UI-only: `.build-kit/CLAUDE.md` only routes `STATE_CHANGE`/`STATE_VIEW` slices to `build-state-change`/`build-state-view` — an `AUTOMATION` slice has no UI counterpart and gets flagged via `request-feedback` instead, since it belongs to whichever backend stack is installed alongside this one. It needs no overrides at all and uses `shared/build-kit`'s realtime agent as-is.
84
+
85
+ `react` (the plain-REST/board-polling variant, no Supabase) is still in the same state as a fresh `init --build-kit` scaffold — CLAUDE.md, the `build-*` skills, and `templates/root/` are all TODO-marked placeholders, not real content, pending an equivalent reference implementation. It overrides `lib/ralph.js` (+ `ralph-claude.js`/`ralph-ollama.js`/`package.json`/`README.md`) for board-polling sync. Fill in the TODOs (and add a real `templates/root/` scaffold) against an actual project before relying on it.
86
+
81
87
  ## Skills
82
88
 
83
89
  Use skills in Claude Code with `/skill-name`:
package/cli.js CHANGED
@@ -91,6 +91,30 @@ const STACKS = {
91
91
  useShared: true,
92
92
  needsBoardId: true,
93
93
  },
94
+ // Frontend-only kits (UI-only: build STATE_CHANGE/STATE_VIEW slices, not
95
+ // AUTOMATION — those belong to whichever backend stack is installed alongside).
96
+ // react overrides lib/ralph.js (+ralph-claude.js/ralph-ollama.js/package.json/
97
+ // README.md) for board-polling instead of the realtime channel every other
98
+ // stack uses; supabase-react needs no overrides at all — it uses
99
+ // shared/build-kit's realtime agent as-is. react's CLAUDE.md/build-*
100
+ // skills/templates/root are still TODO-marked, same as a fresh `init
101
+ // --build-kit` scaffold — supabase-react's are real, filled-in content
102
+ // (Vite + React 19 + TypeScript + Supabase, plus init-style-guide/
103
+ // learn-styleguide for on-brand generated UI).
104
+ react: {
105
+ label: 'React (frontend, board-polling sync) — TODO-marked, not yet filled in',
106
+ kitSubdir: 'build-kit',
107
+ kitDirName: '.build-kit',
108
+ useShared: true,
109
+ needsBoardId: true,
110
+ },
111
+ 'supabase-react': {
112
+ label: 'React + Supabase (frontend, UI-only, realtime sync)',
113
+ kitSubdir: 'build-kit',
114
+ kitDirName: '.build-kit',
115
+ useShared: true,
116
+ needsBoardId: true,
117
+ },
94
118
  };
95
119
 
96
120
  // Not a stack — no backend scaffold, just skills + the agent loop. Installed via
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/cli",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
4
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": {
@@ -407,7 +407,7 @@ function readCurrentContext(kitDir) {
407
407
  // Returns the first Planned slice IN THE CURRENT CONTEXT ONLY. If the current
408
408
  // context has no planned work, returns null so the loop waits — it must NEVER
409
409
  // cross into another context to find something to build.
410
- function getFirstPlannedSliceTitle(kitDir) {
410
+ function getFirstPlannedSlice(kitDir) {
411
411
  const currentCtx = readCurrentContext(kitDir);
412
412
  if (!currentCtx) return null;
413
413
  const indexPath = join(kitDir, '.slices', currentCtx, 'index.json');
@@ -415,11 +415,95 @@ function getFirstPlannedSliceTitle(kitDir) {
415
415
  try {
416
416
  const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
417
417
  const planned = slices && slices.find((s) => (s.status || '').toLowerCase() === 'planned');
418
- if (planned) return planned.slice || planned.id || null;
418
+ if (planned) return { id: planned.id ?? null, title: planned.slice || planned.id || null, ctx: currentCtx };
419
419
  } catch {}
420
420
  return null;
421
421
  }
422
422
 
423
+ // If the exact same Planned slice (by id) comes back up this many times in a
424
+ // row without its status ever leaving "Planned", onPlannedSlice is stuck on
425
+ // it — declining to build it, or building it but its own status change keeps
426
+ // getting reverted (e.g. a failed check). Rather than retry it forever (or
427
+ // crash the whole loop, which would take down every other slice with it),
428
+ // mark it Blocked with a note explaining why and move on to other work.
429
+ // Critical for unsupervised/CI runs, which have no human watching to notice
430
+ // a stall. Configurable for teams that want more slack.
431
+ const MAX_PLANNED_ATTEMPTS = Number(process.env.RALPH_MAX_PLANNED_ATTEMPTS) || 2;
432
+
433
+ // Marks a stuck slice Blocked (locally, and on the board if credentialed) and
434
+ // records why, so the loop can move on instead of looping or exiting.
435
+ async function blockStuckSlice(kitDir, cfg, credentialed, planned, attempts) {
436
+ const now = new Date().toISOString();
437
+ const reason = `Ralph loop picked up this slice ${attempts} times in a row without its status ever leaving ` +
438
+ `"Planned" — the build agent kept declining to build it, or kept building it but its own status change kept ` +
439
+ `getting reverted (e.g. a failed check). Auto-blocked to stop the loop from retrying it forever.`;
440
+
441
+ const indexPath = join(kitDir, '.slices', planned.ctx, 'index.json');
442
+ let folder;
443
+ try {
444
+ const indexData = JSON.parse(readFileSync(indexPath, 'utf-8'));
445
+ const entry = (indexData.slices ?? []).find((s) => s.id === planned.id);
446
+ if (entry) {
447
+ entry.status = 'Blocked';
448
+ entry.blockedReason = reason;
449
+ entry.blockedAt = now;
450
+ folder = entry.folder;
451
+ writeFileSync(indexPath, JSON.stringify(indexData, null, 2), 'utf-8');
452
+ }
453
+ } catch (err) {
454
+ console.error(`[ralph] Failed to write Blocked status to ${indexPath}:`, err.message);
455
+ }
456
+
457
+ if (folder) {
458
+ const sliceJsonPath = join(kitDir, '.slices', planned.ctx, folder, 'slice.json');
459
+ try {
460
+ if (existsSync(sliceJsonPath)) {
461
+ const sliceData = JSON.parse(readFileSync(sliceJsonPath, 'utf-8'));
462
+ sliceData.status = 'Blocked';
463
+ sliceData.blockedReason = reason;
464
+ sliceData.blockedAt = now;
465
+ writeFileSync(sliceJsonPath, JSON.stringify(sliceData, null, 2), 'utf-8');
466
+ }
467
+ } catch (err) {
468
+ console.error(`[ralph] Failed to write Blocked status to ${sliceJsonPath}:`, err.message);
469
+ }
470
+ }
471
+
472
+ try {
473
+ const progressPath = join(dirname(kitDir), 'progress.txt');
474
+ const existing = existsSync(progressPath) ? readFileSync(progressPath, 'utf-8') : '';
475
+ const note = `\n## ${now} — Slice auto-blocked\n\nSlice: ${planned.title} (id=${planned.id}, context=${planned.ctx})\n\n- ${reason}\n---\n`;
476
+ writeFileSync(progressPath, existing + note, 'utf-8');
477
+ } catch (err) {
478
+ console.error('[ralph] Failed to append progress.txt note:', err.message);
479
+ }
480
+
481
+ // Best-effort: also reflect Blocked on the board itself so a synced fetch
482
+ // doesn't just pull "Planned" back down over our local fix. Never fatal —
483
+ // this loop must keep going locally even if the board call fails.
484
+ if (credentialed) {
485
+ try {
486
+ await fetchJSON(`${cfg.baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
487
+ method: 'POST',
488
+ headers: { 'Content-Type': 'application/json', 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'ralph-loop' },
489
+ body: JSON.stringify([{
490
+ id: randomUUID(),
491
+ eventType: 'node:changed',
492
+ nodeId: planned.id,
493
+ boardId: cfg.boardId,
494
+ timestamp: Date.now(),
495
+ changedAttributes: ['sliceStatus'],
496
+ meta: { sliceStatus: 'Blocked' },
497
+ }]),
498
+ });
499
+ } catch (err) {
500
+ console.error(`[ralph] Failed to sync Blocked status to the board:`, err.message);
501
+ }
502
+ }
503
+
504
+ console.error(`[ralph] ${reason} Marked "${planned.title}" (id=${planned.id}) as Blocked — moving on.`);
505
+ }
506
+
423
507
  async function runWithRetry(label, fn) {
424
508
  while (true) {
425
509
  try {
@@ -441,6 +525,9 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
441
525
  // this back on.
442
526
  const credentialed = !localOnly && hasCredentials(cfg);
443
527
  let lastIdleCtx;
528
+ // Tracks consecutive sightings of the same Planned slice id — see
529
+ // MAX_PLANNED_ATTEMPTS above.
530
+ let stuckSlice = { id: null, count: 0 };
444
531
 
445
532
  while (true) {
446
533
  let didWork = false;
@@ -452,10 +539,21 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false)
452
539
  didWork = true;
453
540
  }
454
541
 
455
- const plannedTitle = onPlannedSlice && getFirstPlannedSliceTitle(kitDir);
456
- if (plannedTitle) {
542
+ const planned = onPlannedSlice && getFirstPlannedSlice(kitDir);
543
+ if (planned) {
544
+ stuckSlice = planned.id !== null && planned.id === stuckSlice.id
545
+ ? { id: stuckSlice.id, count: stuckSlice.count + 1 }
546
+ : { id: planned.id, count: 1 };
547
+
548
+ if (stuckSlice.count > MAX_PLANNED_ATTEMPTS) {
549
+ await blockStuckSlice(kitDir, cfg, credentialed, planned, stuckSlice.count);
550
+ stuckSlice = { id: null, count: 0 };
551
+ didWork = true;
552
+ continue;
553
+ }
554
+
457
555
  const prompt = readFileSync(backendPromptFile, 'utf-8');
458
- await runWithRetry(`onPlannedSlice: building slice "${plannedTitle}"...`, () => onPlannedSlice(prompt));
556
+ await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, () => onPlannedSlice(prompt));
459
557
  console.log(`[ralph] Slice build complete — waiting for next slice`);
460
558
  if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
461
559
  didWork = true;
@@ -114,6 +114,9 @@ SCENARIO // GWT scenario
114
114
  LANE // Timeline row
115
115
  SLICE_BORDER // Slice boundary marker
116
116
  MARKDOWN // Free-text markdown note — the content type a `feedback` lane accepts (see §2)
117
+ VIDEO // Embedded video (YouTube/Loom link in `data.url`). API/MCP-only — no palette button in
118
+ // the UI, but renders on the board like any other node. Free-floating: create it via
119
+ // `submit_node_events` with a position and no chapterId/cellId, same as a drawing.
117
120
  ```
118
121
 
119
122
  ---
@@ -57,6 +57,9 @@ Follow the **build-state-change** skill to create:
57
57
 
58
58
  **Do NOT create a `routes.ts`** for automations — the command is fired internally by the processor, not via HTTP.
59
59
 
60
+ **No `routes.ts` also means no OpenAPI block** — an automation has no HTTP surface, so it contributes nothing to `/api-docs` or `/swagger.json`. If the slice also defines a todo-list read model that is queried over HTTP, that endpoint belongs to **build-state-view**, and its `@openapi` annotation is required there (see that skill's Step 6a).
61
+
62
+
60
63
  Refer to the build-state-change skill for the full command handler structure.
61
64
 
62
65
  ### Storyline-derived tests
@@ -269,4 +272,5 @@ src/common/
269
272
  - [ ] Command data fields map exclusively from fields available on the trigger event per slice.json — no invented mappings
270
273
  - [ ] No filtering conditions were invented — all conditions come from slice.json `description` or `comments`
271
274
  - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
272
- - [ ] If `storylines[]` is present, its command-handler segment was covered via build-state-change's storyline-derived tests (no separate reactor test needed)
275
+ - [ ] If `storylines[]` is present, its command-handler segment was covered via build-state-change's storyline-derived tests (no separate reactor test needed)
276
+ - [ ] No `routes.ts`, therefore no `@openapi` block — any todo-list read-model query endpoint is documented by build-state-view instead
@@ -244,7 +244,7 @@ Put these in their own `describe` block named after the storyline (same pattern
244
244
 
245
245
  File: `src/slices/{context}/{SliceName}/routes.ts`
246
246
 
247
- > **Concrete example**: `src/slices/example/routes.ts` shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
247
+ > **Pattern reference**: the template below is the full pattern auth, error mapping, and the mandatory `@openapi` annotation (Step 5a). If the project already has slices under `src/slices/`, open one of their `routes.ts` files first and match it.
248
248
 
249
249
  ```typescript
250
250
  import {Request, Response, Router} from 'express';
@@ -254,6 +254,59 @@ import {{SliceName}Command, handle{SliceName}} from './{SliceName}Command';
254
254
 
255
255
  export const api = (): WebApiSetup => (router: Router): void => {
256
256
 
257
+ /**
258
+ * @openapi
259
+ * /api/{slicename}/{id}:
260
+ * post:
261
+ * tags: [{Context}]
262
+ * summary: {slice title from slice.json}
263
+ * description: {slice.json description — plus any comments that explain the endpoint}
264
+ * security:
265
+ * - bearerAuth: []
266
+ * parameters:
267
+ * - in: path
268
+ * name: id
269
+ * required: true
270
+ * schema:
271
+ * type: string
272
+ * description: Stream id this command is applied to
273
+ * - in: header
274
+ * name: correlation_id
275
+ * required: false
276
+ * schema:
277
+ * type: string
278
+ * requestBody:
279
+ * required: true
280
+ * content:
281
+ * application/json:
282
+ * schema:
283
+ * type: object
284
+ * required: [{command fields without optional: true}]
285
+ * properties:
286
+ * {fieldName}:
287
+ * type: string
288
+ * example: {the field's own example from slice.json, if it has one}
289
+ * responses:
290
+ * '201':
291
+ * description: Accepted — {EmittedEventName} appended
292
+ * content:
293
+ * application/json:
294
+ * schema:
295
+ * type: object
296
+ * properties:
297
+ * ok:
298
+ * type: boolean
299
+ * next_expected_stream_version:
300
+ * type: string
301
+ * last_event_global_position:
302
+ * type: string
303
+ * '401':
304
+ * description: Not authenticated
305
+ * '409':
306
+ * description: {message errorMapping returns — one line per error code}
307
+ * '500':
308
+ * description: Server error
309
+ */
257
310
  router.post('/api/{slicename}/:id', async (req: Request, res: Response) => {
258
311
  const auth = await requireUser(req, res);
259
312
  if (auth.error) return;
@@ -306,6 +359,39 @@ const errorMapping = (code: string): string | null => {
306
359
 
307
360
  ---
308
361
 
362
+ ### Step 5a — OpenAPI annotation (required)
363
+
364
+ `src/swagger.ts` builds the published OpenAPI document by scanning `./src/slices/**/routes.ts` for `@openapi` JSDoc blocks. A handler without one is **invisible** in Swagger UI (`/api-docs`) and in `/swagger.json` — the endpoint works, but nobody can find it. `src/swagger.ts` is shared infra and outside a slice's commit scope, so the block in this slice's own `routes.ts` is the only place the endpoint can be documented. The `openapi-annotation` commit check rejects a `routes.ts` whose handlers have no matching block.
365
+
366
+ Everything in the block comes from slice.json — same rule as the code: no invented fields, no guessed types.
367
+
368
+ | slice.json field `type` | OpenAPI schema |
369
+ |---|---|
370
+ | `String` | `type: string` |
371
+ | `UUID` | `type: string`, `format: uuid` |
372
+ | `Int` | `type: integer`, `format: int32` |
373
+ | `Long` | `type: integer`, `format: int64` |
374
+ | `Double` | `type: number`, `format: double` |
375
+ | `Decimal` | `type: number` |
376
+ | `Boolean` | `type: boolean` |
377
+ | `Date` | `type: string`, `format: date` |
378
+ | `DateTime` | `type: string`, `format: date-time` |
379
+ | `Custom` | `type: object` |
380
+
381
+ Mapping rules:
382
+
383
+ - **path key** — the express path with `:param` rewritten as `{param}` (`/api/foo/:id` → `/api/foo/{id}`). If the two disagree, Swagger publishes a path that does not exist.
384
+ - **tags** — `[{Context}]`, the slice's context, so every slice of one context groups under one heading.
385
+ - **summary** — the slice title. **description** — slice.json `description`, plus any `comments[]` that explain what the endpoint does.
386
+ - **requestBody properties** — exactly `commands[].fields`, minus the ones taken from the URL path or a header. Types from the table above.
387
+ - **required** — every command field not marked `optional: true`.
388
+ - **example** — only from the field's own `example` in slice.json. A field with no example gets no `example:` line; do not invent one.
389
+ - **responses** — `'201'` with the body the handler actually returns; one `'409'` per error code in `errorMapping` (i.e. per failing specification); `'401'` whenever the handler enforces auth; `'500'`.
390
+
391
+ A placeholder left unreplaced ships straight into the published spec, and `npm run build` will not catch it — open `/api-docs` and look at the rendered endpoint before marking the slice `Done`.
392
+
393
+ ---
394
+
309
395
  ## Step 6 — Wire up the route
310
396
 
311
397
  Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
@@ -353,4 +439,6 @@ Before marking this slice as `Done`, verify the implementation against slice.jso
353
439
  - [ ] Every entry in `specifications[]` maps to a test case in `{SliceName}.test.ts`
354
440
  - [ ] No business rules, defaults, or constraints were added that do not appear in slice.json `description` or `comments`
355
441
  - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
356
- - [ ] If `storylines[]` is present, a storyline-derived test was added for every COMMAND beat matching this slice's command
442
+ - [ ] If `storylines[]` is present, a storyline-derived test was added for every COMMAND beat matching this slice's command
443
+ - [ ] `routes.ts` carries an `@openapi` JSDoc block above every handler, and its path key matches the registered route with `:param` written as `{param}`
444
+ - [ ] The documented request body is exactly `commands[].fields`, and every error code in `errorMapping` has a `'409'` line — no undocumented fields, no invented ones
@@ -352,7 +352,7 @@ Skip a beat pair when a COMMAND beat sits in between (that half belongs to build
352
352
 
353
353
  File: `src/slices/{context}/{SliceName}/routes.ts`
354
354
 
355
- > **Concrete example**: `src/slices/example/routes.ts` shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
355
+ > **Pattern reference**: the template below is the full pattern auth, error mapping, and the mandatory `@openapi` annotation (Step 6a). If the project already has slices under `src/slices/`, open one of their `routes.ts` files first and match it.
356
356
 
357
357
  ```typescript
358
358
  import {Request, Response, Router} from 'express';
@@ -364,6 +364,48 @@ import createClient from '../../../supabase/api';
364
364
 
365
365
  export const api = (): WebApiSetup => (router: Router): void => {
366
366
 
367
+ /**
368
+ * @openapi
369
+ * /api/query/{slicename}-collection:
370
+ * get:
371
+ * tags: [{Context}]
372
+ * summary: {slice title from slice.json}
373
+ * description: {slice.json description — what this read model answers}
374
+ * security:
375
+ * - bearerAuth: []
376
+ * parameters:
377
+ * - in: query
378
+ * name: _id
379
+ * required: false
380
+ * schema:
381
+ * type: string
382
+ * description: When set, returns the single row with this id instead of the full collection
383
+ * responses:
384
+ * '200':
385
+ * description: The {SliceName} read model
386
+ * content:
387
+ * application/json:
388
+ * schema:
389
+ * oneOf:
390
+ * - $ref: '#/components/schemas/{SliceName}ReadModel'
391
+ * - type: array
392
+ * items:
393
+ * $ref: '#/components/schemas/{SliceName}ReadModel'
394
+ * '401':
395
+ * description: Not authenticated
396
+ * '500':
397
+ * description: Server error
398
+ * components:
399
+ * schemas:
400
+ * {SliceName}ReadModel:
401
+ * type: object
402
+ * properties:
403
+ * id:
404
+ * type: string
405
+ * {fieldName}:
406
+ * type: string
407
+ * example: {the field's own example from slice.json, if it has one}
408
+ */
367
409
  router.get('/api/query/{slicename}-collection', async (req: Request, res: Response) => {
368
410
  try {
369
411
  const principal = await requireUser(req, res, true);
@@ -394,6 +436,40 @@ export const api = (): WebApiSetup => (router: Router): void => {
394
436
 
395
437
  ---
396
438
 
439
+ ### Step 6a — OpenAPI annotation (required)
440
+
441
+ `src/swagger.ts` builds the published OpenAPI document by scanning `./src/slices/**/routes.ts` for `@openapi` JSDoc blocks. A handler without one is **invisible** in Swagger UI (`/api-docs`) and in `/swagger.json` — the endpoint works, but nobody can find it. `src/swagger.ts` is shared infra and outside a slice's commit scope, so the block in this slice's own `routes.ts` is the only place the endpoint can be documented. The `openapi-annotation` commit check rejects a `routes.ts` whose handlers have no matching block.
442
+
443
+ Everything in the block comes from slice.json — same rule as the code: no invented fields, no guessed types.
444
+
445
+ | slice.json field `type` | OpenAPI schema |
446
+ |---|---|
447
+ | `String` | `type: string` |
448
+ | `UUID` | `type: string`, `format: uuid` |
449
+ | `Int` | `type: integer`, `format: int32` |
450
+ | `Long` | `type: integer`, `format: int64` |
451
+ | `Double` | `type: number`, `format: double` |
452
+ | `Decimal` | `type: number` |
453
+ | `Boolean` | `type: boolean` |
454
+ | `Date` | `type: string`, `format: date` |
455
+ | `DateTime` | `type: string`, `format: date-time` |
456
+ | `Custom` | `type: object` |
457
+
458
+ Mapping rules:
459
+
460
+ - **path key** — the express path exactly as registered (a query read model has no path params; if you do add one, rewrite `:param` as `{param}`).
461
+ - **tags** — `[{Context}]`, the slice's context, so every slice of one context groups under one heading.
462
+ - **summary** — the slice title. **description** — slice.json `description`, plus any `comments[]` that explain what the read model answers.
463
+ - **schema properties** — exactly the read model fields from slice.json, the same set as the migration columns and the `{SliceName}ReadModel` type. Keep the JSON field spelling the route returns, not the snake_case column name, when they differ.
464
+ - **required** — omit it unless slice.json marks fields as mandatory; a projection row can legitimately be sparse.
465
+ - **example** — only from the field's own `example` in slice.json. A field with no example gets no `example:` line; do not invent one.
466
+ - **`components.schemas`** — declaring the read model once and `$ref`-ing it keeps the single-row and collection responses in sync. swagger-jsdoc merges the `components` block from every scanned file, so name the schema `{SliceName}ReadModel` to avoid colliding with another slice's.
467
+ - **responses** — `'200'` with the shape above, `'401'` whenever the handler enforces auth, `'500'`.
468
+
469
+ A placeholder left unreplaced ships straight into the published spec, and `npm run build` will not catch it — open `/api-docs` and look at the rendered endpoint before marking the slice `Done`.
470
+
471
+ ---
472
+
397
473
  ## Step 7 — Wire up the route
398
474
 
399
475
  Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
@@ -434,8 +510,10 @@ src/common/
434
510
  - [ ] No `db.destroy()` calls in `evolve()` — the projection never owns its own connection
435
511
  - [ ] Tests use `runFlywayMigrations()` to apply the real schema
436
512
  - [ ] One test scenario per specification in slice.json
437
- o- [ ] Every field in the read model definition in slice.json has a column in the migration and a field in the TypeScript type — no invented columns
513
+ - [ ] Every field in the read model definition in slice.json has a column in the migration and a field in the TypeScript type — no invented columns
438
514
  - [ ] Every event type in `events[]` is listed in the projection's `canHandle` — no assumed events
439
515
  - [ ] No extra columns or fields were added beyond what slice.json defines
440
516
  - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
441
- - [ ] If `storylines[]` is present, a storyline-derived test was added for every isolable read-model-chain transition (adjacent READMODEL beats with only EVENT beats between them)
517
+ - [ ] If `storylines[]` is present, a storyline-derived test was added for every isolable read-model-chain transition (adjacent READMODEL beats with only EVENT beats between them)
518
+ - [ ] `routes.ts` carries an `@openapi` JSDoc block above every handler, and its path key matches the registered route
519
+ - [ ] The documented response schema lists exactly the read model fields from slice.json — same set as the migration columns and the `{SliceName}ReadModel` type
@@ -20,7 +20,9 @@ Read Events in src/events to understand the global structure.
20
20
  3. Follow TypeScript best practices for type definitions and interfaces
21
21
 
22
22
  Only check src/slices/{slice}/*.ts, do not check subfolders unless explicitely tasked to.
23
- If not tasked explicitely to change routes, ignore routes*.ts
23
+ If not tasked explicitely to change routes, ignore routes*.ts — except the `routes.ts` of the slice you are
24
+ building: the build skill owns that file, and its `@openapi` block has to stay in step with the slice's
25
+ fields (the `openapi-annotation` check blocks the commit otherwise).
24
26
 
25
27
  Ignore case for files and slices in prompts. "CartItems" slice is the same as "cartitems"
26
28
 
@@ -72,6 +74,9 @@ It loads every check under `.build-kit/lib/checks/` and rejects the commit if an
72
74
  `.build-kit/.slices/{context}/{slice}/slice.json`
73
75
  - **spec-coverage** — heuristic: the test file needs at least as many `it(...)` blocks as slice.json
74
76
  has `specifications[]` entries
77
+ - **openapi-annotation** — every handler in a slice's `routes.ts` needs an `@openapi` JSDoc block
78
+ above it, keyed on the registered path (`:param` written as `{param}`); without it the endpoint
79
+ never reaches `/api-docs` or `/swagger.json`
75
80
  - **tsc-build** — `npx tsc --noEmit` must still pass
76
81
 
77
82
  If a commit is rejected, split it — commit the out-of-scope file separately from the slice work, or add
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // Every HTTP handler in a slice's routes.ts must carry an `@openapi` JSDoc block
4
+ // directly above it. src/swagger.ts builds the published OpenAPI document by
5
+ // scanning ./src/slices/**/routes.ts for those blocks, so a handler without one
6
+ // is a working endpoint that never appears in Swagger UI (/api-docs) or in
7
+ // /swagger.json — and swagger.ts itself is shared infra a slice commit may not
8
+ // touch, so the block in routes.ts is the only place the endpoint can be
9
+ // documented. See the build-state-change / build-state-view SKILL.md files for
10
+ // the block template and the slice.json -> OpenAPI field mapping.
11
+ //
12
+ // Heuristic, not an OpenAPI parser: it checks that a block exists between the
13
+ // previous handler and this one, and that the block names this handler's own
14
+ // path (express `:param` rewritten as `{param}`). An invalid schema, or a
15
+ // placeholder left unreplaced, still slips through — the rendered /api-docs
16
+ // page is the real check.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const ROUTES_FILE = /^src\/slices\/[^/]+\/[^/]+\/routes\.ts$/;
22
+ const ROUTE_CALL = /\brouter\s*\.\s*(get|post|put|patch|delete)\s*\(\s*(['"`])([^'"`]+)\2/g;
23
+ const OPENAPI_BLOCK = /\/\*\*(?:[\s\S]*?)@openapi(?:[\s\S]*?)\*\//g;
24
+
25
+ // /api/foo/:id -> /api/foo/{id}
26
+ const toOpenApiPath = (p) => p.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
27
+
28
+ module.exports = {
29
+ name: 'openapi-annotation',
30
+ run(ctx) {
31
+ const violations = [];
32
+
33
+ for (const { path: p } of ctx.changes) {
34
+ if (!ROUTES_FILE.test(p)) continue;
35
+
36
+ let content;
37
+ try {
38
+ content = fs.readFileSync(path.join(ctx.repoRoot, p), 'utf8');
39
+ } catch {
40
+ continue; // deleted — nothing to check
41
+ }
42
+
43
+ ROUTE_CALL.lastIndex = 0;
44
+ let cursor = 0; // start of the text belonging to the handler being checked
45
+ let call;
46
+ while ((call = ROUTE_CALL.exec(content))) {
47
+ const [, method, , routePath] = call;
48
+ const preceding = content.slice(cursor, call.index);
49
+ cursor = ROUTE_CALL.lastIndex;
50
+
51
+ const blocks = preceding.match(OPENAPI_BLOCK) || [];
52
+ if (blocks.length === 0) {
53
+ violations.push({
54
+ path: p,
55
+ reason: `${method.toUpperCase()} ${routePath} has no @openapi JSDoc block above it — the endpoint would be missing from /api-docs and /swagger.json`,
56
+ });
57
+ continue;
58
+ }
59
+
60
+ const documented = toOpenApiPath(routePath);
61
+ if (!blocks[blocks.length - 1].includes(documented)) {
62
+ violations.push({
63
+ path: p,
64
+ reason: `the @openapi block above ${method.toUpperCase()} ${routePath} does not document "${documented}" — the path key must match the registered route, with express ":param" written as "{param}"`,
65
+ });
66
+ }
67
+ }
68
+ }
69
+
70
+ return violations;
71
+ },
72
+ };
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: build-automation
3
+ description: TODO — one-line description of how a reactive slice (a processor/reactor that reacts to events and issues new commands) is implemented for your stack (see an existing stack's build-automation/SKILL.md under stacks/<name>/templates/.claude/skills/ for the level of detail expected)
4
+ ---
5
+
6
+ # Build Automation Slice
7
+
8
+ > **TODO — placeholder installed by `init --build-kit`.** Study an existing stack's
9
+ > build-automation/SKILL.md (stacks/node, stacks/supabase, stacks/axon, or stacks/cratis-csharp,
10
+ > under `templates/.claude/skills/`) for the level of detail expected, then rewrite
11
+ > every section below for your stack's real conventions — file layout, naming, and
12
+ > the actual framework/language idioms. Delete this callout once done.
13
+
14
+ > Before doing anything else, read the slice definition from `.slices/{Context}/{slicename}/slice.json`.
15
+ > This file is the **source of truth** for all fields, events, and metadata — never invent
16
+ > fields not defined there. Keep this line verbatim; it applies to every stack.
17
+
18
+ ---
19
+
20
+ ## What an Automation Slice is
21
+
22
+ TODO — describe a reactive slice (a processor/reactor that reacts to events and issues new commands) in terms of your stack's own primitives.
23
+
24
+ ## Step 1 — Read the slice.json
25
+
26
+ From the slice definition, extract:
27
+ - **sliceName** — the slice title
28
+ - **context** — the bounded context
29
+ - **processors[]** — the reactions this slice implements
30
+ - **specifications[]** — test scenarios (given/when/then)
31
+
32
+ > **Comments & description**: each element carries a `comments: string[]` array (board comments) and a `description` field — use them as implementation hints, and resolve consumed comments via `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve`.
33
+
34
+ ---
35
+
36
+ ## Step 2 — TODO (your stack-specific implementation steps)
37
+
38
+ TODO — file layout, naming conventions, framework idioms, how the handler/projection/reactor is wired up.
39
+
40
+ ## Quality gate
41
+
42
+ TODO — this stack's build command and how to run only this slice's tests (not the full suite).
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: build-state-change
3
+ description: TODO — one-line description of how a write-side slice (command validated against replayed events, new events emitted) is implemented for your stack (see an existing stack's build-state-change/SKILL.md under stacks/<name>/templates/.claude/skills/ for the level of detail expected)
4
+ ---
5
+
6
+ # Build State Change Slice
7
+
8
+ > **TODO — placeholder installed by `init --build-kit`.** Study an existing stack's
9
+ > build-state-change/SKILL.md (stacks/node, stacks/supabase, stacks/axon, or stacks/cratis-csharp,
10
+ > under `templates/.claude/skills/`) for the level of detail expected, then rewrite
11
+ > every section below for your stack's real conventions — file layout, naming, and
12
+ > the actual framework/language idioms. Delete this callout once done.
13
+
14
+ > Before doing anything else, read the slice definition from `.slices/{Context}/{slicename}/slice.json`.
15
+ > This file is the **source of truth** for all fields, events, and metadata — never invent
16
+ > fields not defined there. Keep this line verbatim; it applies to every stack.
17
+
18
+ ---
19
+
20
+ ## What a State Change Slice is
21
+
22
+ TODO — describe a write-side slice (command validated against replayed events, new events emitted) in terms of your stack's own primitives.
23
+
24
+ ## Step 1 — Read the slice.json
25
+
26
+ From the slice definition, extract:
27
+ - **sliceName** — the slice title
28
+ - **context** — the bounded context
29
+ - **commands[]** — list of commands with their data fields
30
+ - **events[]** — list of events emitted by each command
31
+ - **specifications[]** — test scenarios (given/when/then)
32
+
33
+ > **Comments & description**: each element carries a `comments: string[]` array (board comments) and a `description` field — use them as implementation hints, and resolve consumed comments via `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve`.
34
+
35
+ ---
36
+
37
+ ## Step 2 — TODO (your stack-specific implementation steps)
38
+
39
+ TODO — file layout, naming conventions, framework idioms, how the handler/projection/reactor is wired up.
40
+
41
+ ## Quality gate
42
+
43
+ TODO — this stack's build command and how to run only this slice's tests (not the full suite).