@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.
- package/README.md +6 -0
- package/cli.js +24 -0
- package/package.json +1 -1
- package/shared/build-kit/lib/ralph.js +103 -5
- package/shared/skills/learn-eventmodelers-api/SKILL.md +3 -0
- package/stacks/node/templates/.claude/skills/build-automation/SKILL.md +5 -1
- package/stacks/node/templates/.claude/skills/build-state-change/SKILL.md +90 -2
- package/stacks/node/templates/.claude/skills/build-state-view/SKILL.md +81 -3
- package/stacks/node/templates/build-kit/CLAUDE.md +6 -1
- package/stacks/node/templates/build-kit/lib/checks/60-openapi-annotation.cjs +72 -0
- package/stacks/react/templates/.claude/skills/build-automation/SKILL.md +42 -0
- package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +43 -0
- package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +42 -0
- package/stacks/react/templates/build-kit/CLAUDE.md +62 -0
- package/stacks/react/templates/build-kit/README.md +79 -0
- package/stacks/react/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +135 -0
- package/stacks/react/templates/build-kit/lib/prompt.md +139 -0
- package/stacks/react/templates/build-kit/lib/ralph.js +508 -0
- package/stacks/react/templates/build-kit/package.json +9 -0
- package/stacks/react/templates/build-kit/ralph-claude.js +107 -0
- package/stacks/react/templates/build-kit/ralph-ollama.js +40 -0
- package/stacks/supabase/templates/.claude/skills/build-automation/SKILL.md +5 -1
- package/stacks/supabase/templates/.claude/skills/build-state-change/SKILL.md +90 -2
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +80 -2
- package/stacks/supabase/templates/.claude/skills/build-webhook/SKILL.md +73 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +6 -1
- package/stacks/supabase/templates/build-kit/lib/checks/60-openapi-annotation.cjs +72 -0
- package/stacks/supabase/templates/root/src/swagger.ts +3 -1
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +305 -0
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +238 -0
- package/stacks/supabase-react/templates/.claude/skills/init-style-guide/SKILL.md +60 -0
- package/stacks/supabase-react/templates/.claude/skills/learn-styleguide/SKILL.md +28 -0
- package/stacks/supabase-react/templates/.claude/skills/learn-styleguide/references/README.md +5 -0
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +149 -0
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +139 -0
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +145 -0
- package/stacks/supabase-react/templates/root/.env.example +12 -0
- package/stacks/supabase-react/templates/root/.oxlintrc.json +9 -0
- package/stacks/supabase-react/templates/root/README.md +49 -0
- package/stacks/supabase-react/templates/root/index.html +13 -0
- package/stacks/supabase-react/templates/root/package.json +26 -0
- package/stacks/supabase-react/templates/root/public/favicon.svg +1 -0
- package/stacks/supabase-react/templates/root/public/icons.svg +24 -0
- package/stacks/supabase-react/templates/root/src/App.css +184 -0
- package/stacks/supabase-react/templates/root/src/App.tsx +122 -0
- package/stacks/supabase-react/templates/root/src/assets/hero.png +0 -0
- package/stacks/supabase-react/templates/root/src/assets/react.svg +1 -0
- package/stacks/supabase-react/templates/root/src/assets/vite.svg +1 -0
- package/stacks/supabase-react/templates/root/src/index.css +111 -0
- package/stacks/supabase-react/templates/root/src/lib/api.ts +127 -0
- package/stacks/supabase-react/templates/root/src/lib/supabase.ts +10 -0
- package/stacks/supabase-react/templates/root/src/main.tsx +10 -0
- package/stacks/supabase-react/templates/root/src/slices/.gitkeep +0 -0
- package/stacks/supabase-react/templates/root/src/vite-env.d.ts +13 -0
- package/stacks/supabase-react/templates/root/tsconfig.app.json +26 -0
- package/stacks/supabase-react/templates/root/tsconfig.json +7 -0
- package/stacks/supabase-react/templates/root/tsconfig.node.json +23 -0
- 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.
|
|
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
|
|
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
|
|
456
|
-
if (
|
|
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 "${
|
|
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
|
-
> **
|
|
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
|
-
> **
|
|
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
|
-
|
|
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).
|