kataster 0.0.0-stage → 0.2.1

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 (162) hide show
  1. package/.claude/agents/acceptance-tester.md +53 -0
  2. package/.claude/agents/code-reviewer.md +56 -0
  3. package/.claude/agents/domain-architect.md +40 -0
  4. package/.claude/agents/lead-dev.md +50 -0
  5. package/.claude/agents/product-owner.md +39 -0
  6. package/.claude/agents/repo-analyst.md +78 -0
  7. package/.claude/agents/requirements-engineer.md +44 -0
  8. package/.claude/agents/ux-designer.md +64 -0
  9. package/.claude/hooks/bash-guard.ts +92 -0
  10. package/.claude/hooks/code-check.ts +37 -0
  11. package/.claude/hooks/path-guard.ts +34 -0
  12. package/.claude/hooks/path-rules.ts +66 -0
  13. package/.claude/hooks/project-check.ts +108 -0
  14. package/.claude/hooks/protect-files.ts +55 -0
  15. package/.claude/hooks/session-start.ts +213 -0
  16. package/.claude/hooks/stop-gate.ts +56 -0
  17. package/.claude/hooks/validate-docs.ts +58 -0
  18. package/.claude/lib/architecture.ts +736 -0
  19. package/.claude/lib/backlog-github.ts +454 -0
  20. package/.claude/lib/backlog-jira.ts +347 -0
  21. package/.claude/lib/backlog-sync.ts +42 -0
  22. package/.claude/lib/backlog.ts +396 -0
  23. package/.claude/lib/bazel.ts +212 -0
  24. package/.claude/lib/config.ts +122 -0
  25. package/.claude/lib/discovery.ts +821 -0
  26. package/.claude/lib/engineering.ts +406 -0
  27. package/.claude/lib/nx.ts +129 -0
  28. package/.claude/lib/repo.ts +1194 -0
  29. package/.claude/lib/toolgraph.ts +74 -0
  30. package/.claude/lib/turbo.ts +166 -0
  31. package/.claude/package-lock.json +427 -0
  32. package/.claude/package.json +21 -0
  33. package/.claude/settings.json +82 -0
  34. package/.claude/skills/adopt/SKILL.md +283 -0
  35. package/.claude/skills/adopt/scripts/boundary-check.ts +156 -0
  36. package/.claude/skills/adopt/scripts/package-doc.ts +71 -0
  37. package/.claude/skills/adopt/scripts/repo-map.ts +123 -0
  38. package/.claude/skills/adopt/scripts/rule-doc.ts +80 -0
  39. package/.claude/skills/architect/ADR-FORMAT.md +97 -0
  40. package/.claude/skills/architect/SKILL.md +64 -0
  41. package/.claude/skills/architect/TECH-STACK.md +21 -0
  42. package/.claude/skills/backlog/SKILL.md +198 -0
  43. package/.claude/skills/backlog/scripts/backlog-pull.ts +45 -0
  44. package/.claude/skills/backlog/scripts/backlog-push.ts +38 -0
  45. package/.claude/skills/backlog/scripts/backlog-status.ts +32 -0
  46. package/.claude/skills/backlog/scripts/jira-import.ts +40 -0
  47. package/.claude/skills/backlog/scripts/jira-plan.ts +26 -0
  48. package/.claude/skills/backlog/scripts/jira-record.ts +41 -0
  49. package/.claude/skills/bootstrap/SKILL.md +119 -0
  50. package/.claude/skills/codebase-design/DEEPENING.md +37 -0
  51. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  52. package/.claude/skills/codebase-design/SKILL.md +114 -0
  53. package/.claude/skills/domain-model/CONTEXT-FORMAT.md +47 -0
  54. package/.claude/skills/domain-model/SKILL.md +64 -0
  55. package/.claude/skills/domain-model/templates/data-model.md +29 -0
  56. package/.claude/skills/engineering-conventions/SKILL.md +31 -0
  57. package/.claude/skills/event-storming/SKILL.md +44 -0
  58. package/.claude/skills/event-storming/schema/events.schema.json +124 -0
  59. package/.claude/skills/event-storming/scripts/render-event-storming.ts +11 -0
  60. package/.claude/skills/event-storming/scripts/validate-events.ts +11 -0
  61. package/.claude/skills/event-storming/templates/events.example.yaml +91 -0
  62. package/.claude/skills/feature/SKILL.md +86 -0
  63. package/.claude/skills/feature/scripts/intake.ts +356 -0
  64. package/.claude/skills/grill-me/SKILL.md +7 -0
  65. package/.claude/skills/grill-with-docs/SKILL.md +7 -0
  66. package/.claude/skills/grilling/SKILL.md +30 -0
  67. package/.claude/skills/groom-backlog/SKILL.md +93 -0
  68. package/.claude/skills/groom-backlog/scripts/backlog-health.ts +238 -0
  69. package/.claude/skills/implement/SKILL.md +129 -0
  70. package/.claude/skills/implement/scripts/affected.ts +245 -0
  71. package/.claude/skills/implement/scripts/check-commands.ts +37 -0
  72. package/.claude/skills/implement/scripts/check-language.ts +15 -0
  73. package/.claude/skills/implement/scripts/check-scenarios.ts +59 -0
  74. package/.claude/skills/implement/scripts/next-story.ts +23 -0
  75. package/.claude/skills/implement/scripts/story-context.ts +135 -0
  76. package/.claude/skills/implement/scripts/story-status.ts +47 -0
  77. package/.claude/skills/implement/scripts/verify.ts +92 -0
  78. package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
  79. package/.claude/skills/improve-codebase-architecture/SKILL.md +71 -0
  80. package/.claude/skills/retro/SKILL.md +53 -0
  81. package/.claude/skills/review-stories/SKILL.md +31 -0
  82. package/.claude/skills/shape/SKILL.md +193 -0
  83. package/.claude/skills/tdd/SKILL.md +38 -0
  84. package/.claude/skills/tdd/mocking.md +59 -0
  85. package/.claude/skills/tdd/tests.md +77 -0
  86. package/.claude/skills/user-stories/SKILL.md +138 -0
  87. package/.claude/skills/user-stories/schema/story.schema.json +39 -0
  88. package/.claude/skills/user-stories/scripts/render-backlog.ts +4 -0
  89. package/.claude/skills/user-stories/scripts/validate-stories.ts +13 -0
  90. package/.claude/skills/user-stories/templates/story-template.md +38 -0
  91. package/.claude/skills/ux-design/SKILL.md +107 -0
  92. package/.claude/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
  93. package/.claude/skills/writing-for-agents/SKILL.md +81 -0
  94. package/.claude/tsconfig.json +15 -0
  95. package/.opencode/agents/acceptance-tester.md +64 -0
  96. package/.opencode/agents/code-reviewer.md +69 -0
  97. package/.opencode/agents/domain-architect.md +59 -0
  98. package/.opencode/agents/lead-dev.md +67 -0
  99. package/.opencode/agents/product-owner.md +59 -0
  100. package/.opencode/agents/repo-analyst.md +86 -0
  101. package/.opencode/agents/requirements-engineer.md +58 -0
  102. package/.opencode/agents/ux-designer.md +83 -0
  103. package/.opencode/commands/adopt.md +7 -0
  104. package/.opencode/commands/backlog-sync.md +7 -0
  105. package/.opencode/commands/bootstrap.md +7 -0
  106. package/.opencode/commands/event-storming.md +7 -0
  107. package/.opencode/commands/feature.md +7 -0
  108. package/.opencode/commands/grill-me.md +7 -0
  109. package/.opencode/commands/grill-with-docs.md +7 -0
  110. package/.opencode/commands/groom-backlog.md +7 -0
  111. package/.opencode/commands/implement.md +7 -0
  112. package/.opencode/commands/improve-codebase-architecture.md +7 -0
  113. package/.opencode/commands/retro.md +7 -0
  114. package/.opencode/commands/review-stories.md +7 -0
  115. package/.opencode/commands/shape.md +7 -0
  116. package/.opencode/opencode.jsonc +15 -0
  117. package/.opencode/plugins/kataster/index.ts +283 -0
  118. package/AGENTS.md +21 -0
  119. package/CHANGELOG.md +91 -0
  120. package/LICENSE +21 -0
  121. package/README.md +331 -2
  122. package/bin/kataster.mjs +7 -0
  123. package/extras/skills/shadcn/SKILL.md +277 -0
  124. package/extras/skills/shadcn/agents/openai.yml +5 -0
  125. package/extras/skills/shadcn/assets/shadcn-small.png +0 -0
  126. package/extras/skills/shadcn/assets/shadcn.png +0 -0
  127. package/extras/skills/shadcn/cli.md +290 -0
  128. package/extras/skills/shadcn/customization.md +209 -0
  129. package/extras/skills/shadcn/evals/evals.json +77 -0
  130. package/extras/skills/shadcn/mcp.md +105 -0
  131. package/extras/skills/shadcn/registry.md +277 -0
  132. package/extras/skills/shadcn/rules/base-vs-radix.md +306 -0
  133. package/extras/skills/shadcn/rules/chat.md +250 -0
  134. package/extras/skills/shadcn/rules/composition.md +213 -0
  135. package/extras/skills/shadcn/rules/forms.md +192 -0
  136. package/extras/skills/shadcn/rules/icons.md +101 -0
  137. package/extras/skills/shadcn/rules/styling.md +185 -0
  138. package/guide/README.md +9 -0
  139. package/guide/brownfield.md +242 -0
  140. package/guide/concepts.md +109 -0
  141. package/guide/greenfield.md +84 -0
  142. package/guide/install.md +144 -0
  143. package/guide/release.md +92 -0
  144. package/package.json +56 -4
  145. package/src/cli.mjs +302 -0
  146. package/src/detect.mjs +237 -0
  147. package/src/install.mjs +427 -0
  148. package/src/repo.mjs +129 -0
  149. package/src/util.mjs +110 -0
  150. package/templates/CONTEXT.md +21 -0
  151. package/templates/conventions.md +63 -0
  152. package/templates/docs/adr/README.md +7 -0
  153. package/templates/docs/architecture/data-model.md +31 -0
  154. package/templates/docs/domain/context-map.md +21 -0
  155. package/templates/docs/domain/events.yaml +23 -0
  156. package/templates/docs/product/initiatives/README.md +41 -0
  157. package/templates/docs/product/ux-guidelines.md +27 -0
  158. package/templates/docs/product/vision.md +36 -0
  159. package/templates/docs/reviews/.gitkeep +0 -0
  160. package/templates/docs/stories/OPEN_QUESTIONS.md +8 -0
  161. package/templates/docs/stories/README.md +26 -0
  162. package/templates/docs/workflow.md +135 -0
@@ -0,0 +1,821 @@
1
+ /**
2
+ * Shared, deterministic logic for the discovery artifacts.
3
+ *
4
+ * Used by hooks and skill scripts:
5
+ * - validation of docs/domain/events.yaml (schema + referential integrity)
6
+ * - rendering of docs/domain/event-storming.md (Mermaid)
7
+ * - validation of the stories in docs/stories/*.md
8
+ * - rendering of docs/stories/BACKLOG.md
9
+ *
10
+ * Only runtime dependency: `yaml`. Runs directly on Node >= 22.18 (type stripping),
11
+ * so only erasable TypeScript syntax is allowed (no enums, namespaces, parameter properties).
12
+ * The JSON schemas live in the skills as standard JSON Schema (for editor support)
13
+ * and are checked here with a small built-in validator.
14
+ */
15
+ import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
16
+ import { basename, join, relative, resolve, sep } from "node:path";
17
+ import { parse as parseYaml, YAMLError } from "yaml";
18
+ import { isManagedLabel, labelPrefix, loadBacklogConfig } from "./config.ts";
19
+
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ export type Obj = Record<string, any>;
22
+
23
+ export const ROOT = resolve(import.meta.dirname, "..", "..");
24
+ const SKILLS = join(ROOT, ".claude", "skills");
25
+ export const EVENTS_FILE = join(ROOT, "docs", "domain", "events.yaml");
26
+ const EVENTS_SCHEMA = join(SKILLS, "event-storming", "schema", "events.schema.json");
27
+ const EVENT_STORMING_MD = join(ROOT, "docs", "domain", "event-storming.md");
28
+ export const STORIES_DIR = join(ROOT, "docs", "stories");
29
+ const STORY_SCHEMA = join(SKILLS, "user-stories", "schema", "story.schema.json");
30
+ const BACKLOG_MD = join(STORIES_DIR, "BACKLOG.md");
31
+ const NON_STORY_FILES = new Set(["OPEN_QUESTIONS.md", "README.md", "BACKLOG.md"]);
32
+
33
+ export const LIST_KEYS = [
34
+ "actors", "external_systems", "bounded_contexts", "aggregates", "events",
35
+ "commands", "policies", "read_models", "hotspots", "flows",
36
+ ] as const;
37
+ const KIND_LABEL: Record<string, string> = {
38
+ ACT: "actor", EXT: "external system", BC: "bounded context",
39
+ AGG: "aggregate", EVT: "event", CMD: "command", POL: "policy",
40
+ RM: "read model", HS: "hotspot", FLOW: "flow", ST: "story",
41
+ };
42
+
43
+ /**
44
+ * `superseded`: never built as written – its work was done elsewhere (a direct change, another story) or a new planning
45
+ * replaces it. The file stays as the record other documents link to; it is final and counts toward no progress (user,
46
+ * 2026-10-25).
47
+ */
48
+ export const STATUSES = ["draft", "review", "ready", "in-progress", "done", "superseded"] as const;
49
+ /** Statuses that require a finished story: size set, no [OPEN] markers. */
50
+ export const COMMITTED = new Set(["ready", "in-progress", "done"]);
51
+ export const MAX_SCENARIOS = 10;
52
+ const PRIORITIES = ["must", "should", "could", "wont"];
53
+
54
+ // --------------------------------------------------------------------------
55
+ // Report
56
+ // --------------------------------------------------------------------------
57
+ export class Report {
58
+ errors: string[] = [];
59
+ warnings: string[] = [];
60
+
61
+ error(where: string, msg: string): void {
62
+ this.errors.push(`${where}: ${msg}`);
63
+ }
64
+
65
+ warn(where: string, msg: string): void {
66
+ this.warnings.push(`${where}: ${msg}`);
67
+ }
68
+
69
+ get ok(): boolean {
70
+ return this.errors.length === 0;
71
+ }
72
+
73
+ lines(): string[] {
74
+ return [...this.errors.map((e) => `ERROR ${e}`), ...this.warnings.map((w) => `WARNING ${w}`)];
75
+ }
76
+ }
77
+
78
+ // --------------------------------------------------------------------------
79
+ // Helpers
80
+ // --------------------------------------------------------------------------
81
+ const list = (v: unknown): string[] => (Array.isArray(v) ? v : []);
82
+ const items = (model: Obj, key: string): Obj[] => list(model[key]) as unknown as Obj[];
83
+ const prefix = (id: string): string => id.split("-", 1)[0];
84
+
85
+ /** Resolves symlinks where possible so paths from hooks and from ROOT compare equal. */
86
+ export function realPath(p: string): string {
87
+ try {
88
+ return realpathSync(p);
89
+ } catch {
90
+ return resolve(p);
91
+ }
92
+ }
93
+
94
+ /** Project-relative POSIX path, or the input unchanged if it lies outside the project. */
95
+ export function rel(p: string): string {
96
+ const r = relative(realPath(ROOT), realPath(p));
97
+ return r.startsWith("..") || resolve(r) === r ? p : r.split(sep).join("/");
98
+ }
99
+
100
+ // --------------------------------------------------------------------------
101
+ // Mini JSON Schema validator (the subset our schemas use)
102
+ // --------------------------------------------------------------------------
103
+ function isType(v: unknown, t: string): boolean {
104
+ switch (t) {
105
+ case "string": return typeof v === "string";
106
+ case "integer": return Number.isInteger(v);
107
+ case "number": return typeof v === "number" && Number.isFinite(v);
108
+ case "boolean": return typeof v === "boolean";
109
+ case "array": return Array.isArray(v);
110
+ case "object": return v !== null && typeof v === "object" && !Array.isArray(v);
111
+ case "null": return v === null;
112
+ default: return false;
113
+ }
114
+ }
115
+
116
+ const fmt = (v: unknown): string =>
117
+ v === null || v === undefined ? "null" : typeof v === "object" ? JSON.stringify(v) : String(v);
118
+
119
+ function check(inst: unknown, sch: Obj, root: Obj, path: (string | number)[], errs: string[]): void {
120
+ if ("$ref" in sch) {
121
+ let node: Obj = root;
122
+ for (const part of String(sch.$ref).replace(/^#\//, "").split("/")) node = node[part];
123
+ check(inst, node, root, path, errs);
124
+ const { $ref: _ref, ...siblings } = sch; // sibling keywords (2020-12)
125
+ sch = siblings;
126
+ }
127
+ const loc = path.join("/") || "(root)";
128
+ if ("const" in sch && inst !== sch.const) {
129
+ errs.push(`${loc}: must be ${JSON.stringify(sch.const)}`);
130
+ }
131
+ if ("enum" in sch && !sch.enum.includes(inst)) {
132
+ errs.push(`${loc}: '${fmt(inst)}' not allowed (allowed: ${sch.enum.map(fmt).join(", ")})`);
133
+ }
134
+ if ("type" in sch) {
135
+ const types: string[] = Array.isArray(sch.type) ? sch.type : [sch.type];
136
+ if (!types.some((t) => isType(inst, t))) {
137
+ errs.push(`${loc}: expected ${types.join("/")}, found '${fmt(inst)}'`);
138
+ return;
139
+ }
140
+ }
141
+ if (typeof inst === "string") {
142
+ const len = [...inst].length;
143
+ if (len < (sch.minLength ?? 0)) errs.push(`${loc}: too short (min. ${sch.minLength} characters)`);
144
+ if (sch.maxLength !== undefined && len > sch.maxLength) errs.push(`${loc}: too long (max. ${sch.maxLength} characters)`);
145
+ if (sch.pattern !== undefined && !new RegExp(sch.pattern, "u").test(inst)) {
146
+ errs.push(`${loc}: '${inst}' does not match ${sch.pattern}`);
147
+ }
148
+ }
149
+ if (typeof inst === "number" && sch.minimum !== undefined && inst < sch.minimum) {
150
+ errs.push(`${loc}: must be >= ${sch.minimum}`);
151
+ }
152
+ if (Array.isArray(inst)) {
153
+ if (inst.length < (sch.minItems ?? 0)) errs.push(`${loc}: at least ${sch.minItems} entries required`);
154
+ if (sch.uniqueItems) {
155
+ const seen = inst.map((i) => JSON.stringify(i));
156
+ if (new Set(seen).size !== seen.length) errs.push(`${loc}: entries must be unique`);
157
+ }
158
+ if (sch.items) inst.forEach((item, i) => check(item, sch.items, root, [...path, i], errs));
159
+ }
160
+ if (isType(inst, "object")) {
161
+ const obj = inst as Obj;
162
+ for (const req of list(sch.required)) {
163
+ if (!(req in obj)) errs.push(`${loc}: required field '${req}' is missing`);
164
+ }
165
+ const props: Obj = sch.properties ?? {};
166
+ if (sch.additionalProperties === false) {
167
+ for (const k of Object.keys(obj)) {
168
+ if (!(k in props)) errs.push(`${loc}: unknown field '${k}' (allowed: ${Object.keys(props).join(", ")})`);
169
+ }
170
+ }
171
+ for (const [k, v] of Object.entries(obj)) {
172
+ if (k in props) check(v, props[k], root, [...path, k], errs);
173
+ }
174
+ }
175
+ }
176
+
177
+ function schemaErrors(instance: unknown, schemaPath: string): string[] {
178
+ const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
179
+ const errs: string[] = [];
180
+ check(instance, schema, schema, [], errs);
181
+ return errs;
182
+ }
183
+
184
+ // --------------------------------------------------------------------------
185
+ // Event storming model
186
+ // --------------------------------------------------------------------------
187
+ /** Loads events.yaml without validation (null if missing or invalid). */
188
+ export function loadEventsModel(): Obj | null {
189
+ if (!existsSync(EVENTS_FILE)) return null;
190
+ try {
191
+ const data = parseYaml(readFileSync(EVENTS_FILE, "utf8"));
192
+ return isType(data, "object") ? data : null;
193
+ } catch (e) {
194
+ if (e instanceof YAMLError) return null;
195
+ throw e;
196
+ }
197
+ }
198
+
199
+ export type Index = Map<string, Obj>;
200
+
201
+ export function indexModel(model: Obj): Index {
202
+ const idx: Index = new Map();
203
+ for (const key of LIST_KEYS) {
204
+ for (const item of items(model, key)) {
205
+ if (isType(item, "object") && typeof item.id === "string" && !idx.has(item.id)) idx.set(item.id, item);
206
+ }
207
+ }
208
+ return idx;
209
+ }
210
+
211
+ export function validateEvents(report: Report): Obj | null {
212
+ const where = rel(EVENTS_FILE);
213
+ if (!existsSync(EVENTS_FILE)) {
214
+ report.error(where, "file is missing");
215
+ return null;
216
+ }
217
+ let model: unknown;
218
+ try {
219
+ model = parseYaml(readFileSync(EVENTS_FILE, "utf8"));
220
+ } catch (e) {
221
+ if (!(e instanceof YAMLError)) throw e;
222
+ report.error(where, `invalid YAML: ${e.message}`);
223
+ return null;
224
+ }
225
+ if (!isType(model, "object")) {
226
+ report.error(where, "root must be an object");
227
+ return null;
228
+ }
229
+ const m = model as Obj;
230
+
231
+ for (const e of schemaErrors(m, EVENTS_SCHEMA)) report.error(where, e);
232
+ if (!report.ok) return m; // check references only once the schema is valid
233
+
234
+ // IDs are unique across the whole model
235
+ const idx: Index = new Map();
236
+ for (const key of LIST_KEYS) {
237
+ for (const item of items(m, key)) {
238
+ if (idx.has(item.id)) report.error(where, `ID '${item.id}' is used more than once`);
239
+ idx.set(item.id, item);
240
+ }
241
+ }
242
+
243
+ const ref = (owner: string, field: string, value: unknown, allowed: string[]): void => {
244
+ if (value === undefined || value === null) return;
245
+ for (const v of Array.isArray(value) ? value : [value]) {
246
+ if (!idx.has(v)) {
247
+ report.error(where, `${owner}.${field}: '${v}' does not exist`);
248
+ } else if (!allowed.includes(prefix(v))) {
249
+ report.error(where, `${owner}.${field}: '${v}' is not a ${allowed.map((a) => KIND_LABEL[a]).join(" or ")}`);
250
+ }
251
+ }
252
+ };
253
+ const allButFlow = Object.keys(KIND_LABEL).filter((p) => p !== "FLOW" && p !== "ST");
254
+
255
+ for (const a of items(m, "aggregates")) ref(a.id, "context", a.context, ["BC"]);
256
+ for (const e of items(m, "events")) {
257
+ ref(e.id, "aggregate", e.aggregate, ["AGG"]);
258
+ ref(e.id, "context", e.context, ["BC"]);
259
+ const agg = idx.get(e.aggregate ?? "") ?? {};
260
+ if (e.context && agg.context && agg.context !== e.context) {
261
+ report.error(where, `${e.id}: context '${e.context}' contradicts the aggregate's context '${agg.context}'`);
262
+ }
263
+ }
264
+ for (const c of items(m, "commands")) {
265
+ ref(c.id, "actor", c.actor, ["ACT", "EXT"]);
266
+ ref(c.id, "aggregate", c.aggregate, ["AGG"]);
267
+ ref(c.id, "produces", c.produces, ["EVT"]);
268
+ }
269
+ for (const p of items(m, "policies")) {
270
+ ref(p.id, "when", p.when, ["EVT"]);
271
+ ref(p.id, "then", p.then, ["CMD"]);
272
+ }
273
+ for (const r of items(m, "read_models")) {
274
+ ref(r.id, "fed_by", r.fed_by, ["EVT"]);
275
+ ref(r.id, "used_by", r.used_by, ["ACT", "EXT"]);
276
+ }
277
+ for (const h of items(m, "hotspots")) ref(h.id, "refs", h.refs, allButFlow);
278
+ for (const f of items(m, "flows")) ref(f.id, "sequence", f.sequence, allButFlow);
279
+
280
+ // Heuristic warnings (non-blocking)
281
+ const produced = new Set(items(m, "commands").flatMap((c) => list(c.produces)));
282
+ const policyCmds = new Set(items(m, "policies").flatMap((p) => list(p.then)));
283
+ for (const e of items(m, "events")) {
284
+ if ((e.origin ?? "command") === "command" && !produced.has(e.id)) {
285
+ report.warn(where, `${e.id}: not produced by any command (missing command? otherwise set origin: external|time)`);
286
+ }
287
+ if (!e.aggregate) report.warn(where, `${e.id}: not assigned to an aggregate yet`);
288
+ if (!e.context && !e.aggregate) report.warn(where, `${e.id}: not assigned to a bounded context yet`);
289
+ }
290
+ for (const c of items(m, "commands")) {
291
+ if (!c.actor && !policyCmds.has(c.id)) report.warn(where, `${c.id}: neither an actor nor a triggering policy`);
292
+ }
293
+ const openHs = items(m, "hotspots").filter((h) => (h.status ?? "open") === "open").map((h) => h.id);
294
+ if (openHs.length) report.warn(where, `${openHs.length} open hotspots: ${openHs.join(", ")}`);
295
+ return m;
296
+ }
297
+
298
+ // --------------------------------------------------------------------------
299
+ // Mermaid rendering
300
+ // --------------------------------------------------------------------------
301
+ const CLASSDEFS = ` classDef evt fill:#a3391f,stroke:#e65100,color:#fff
302
+ classDef cmd fill:#64b5f6,stroke:#0d47a1,color:#000
303
+ classDef pol fill:#ce93d8,stroke:#4a148c,color:#000
304
+ classDef rm fill:#81c784,stroke:#1b5e20,color:#000
305
+ classDef act fill:#a3751f,stroke:#f57f17,color:#000
306
+ classDef ext fill:#f48fb1,stroke:#880e4f,color:#000
307
+ classDef agg fill:#ffe082,stroke:#ff6f00,color:#000
308
+ classDef hs fill:#e57373,stroke:#b71c1c,color:#fff`;
309
+
310
+ const SHAPES: Record<string, [string, string, string]> = {
311
+ EVT: ['["', '"]', "evt"], CMD: ['["', '"]', "cmd"], POL: ['{{"', '"}}', "pol"],
312
+ RM: ['[("', '")]', "rm"], ACT: ['(["', '"])', "act"], EXT: ['[["', '"]]', "ext"],
313
+ AGG: ['[/"', '"/]', "agg"], HS: ['>"', '"]', "hs"], BC: ['["', '"]', "agg"],
314
+ };
315
+
316
+ const mid = (id: string): string => id.replace(/[^A-Za-z0-9_]/g, "_");
317
+
318
+ function node(id: string, item: Obj): string {
319
+ const [left, right, cls] = SHAPES[prefix(id)] ?? ['["', '"]', "agg"];
320
+ let text = String(item.name || item.question || id).replaceAll('"', "'");
321
+ if (item.pivotal) text = `⭐ ${text}`;
322
+ return `${mid(id)}${left}${text}${right}:::${cls}`;
323
+ }
324
+
325
+ export function renderEventStorming(model: Obj): string {
326
+ const idx = indexModel(model);
327
+ const it = (key: string) => items(model, key);
328
+ const out = [
329
+ `# Event Storming – ${model.domain ?? ""}`,
330
+ "",
331
+ "> Generated from `docs/domain/events.yaml` – **do not edit manually**.",
332
+ "",
333
+ "Legend: 🟧 Event · 🟦 Command · 🟪 Policy · 🟩 Read model · 🟨 Actor · 🩷 External system · 🟥 Hotspot · ⭐ Pivotal event",
334
+ "",
335
+ ];
336
+ if (!it("events").length) {
337
+ out.push("_No events captured yet. Start with `/event-storming`._", "");
338
+ return out.join("\n");
339
+ }
340
+
341
+ const ctxOf = (id: string): string | undefined => {
342
+ const item = idx.get(id) ?? {};
343
+ return item.context ?? idx.get(item.aggregate ?? "")?.context;
344
+ };
345
+
346
+ // Big picture
347
+ const lines = ["```mermaid", "flowchart LR"];
348
+ const grouped = new Map<string | undefined, string[]>();
349
+ for (const key of ["commands", "events"]) {
350
+ for (const item of it(key)) {
351
+ const ctx = ctxOf(item.id);
352
+ grouped.set(ctx, [...(grouped.get(ctx) ?? []), item.id]);
353
+ }
354
+ }
355
+ for (const [ctx, ids] of grouped) {
356
+ if (ctx && idx.has(ctx)) {
357
+ lines.push(` subgraph ${mid(ctx)}["${idx.get(ctx)!.name}"]`);
358
+ lines.push(...ids.map((i) => ` ${node(i, idx.get(i)!)}`));
359
+ lines.push(" end");
360
+ } else {
361
+ lines.push(...ids.map((i) => ` ${node(i, idx.get(i)!)}`));
362
+ }
363
+ }
364
+ for (const key of ["actors", "external_systems", "policies", "read_models"]) {
365
+ lines.push(...it(key).map((item) => ` ${node(item.id, item)}`));
366
+ }
367
+ for (const c of it("commands")) {
368
+ if (c.actor) lines.push(` ${mid(c.actor)} --> ${mid(c.id)}`);
369
+ for (const e of list(c.produces)) lines.push(` ${mid(c.id)} --> ${mid(e)}`);
370
+ }
371
+ for (const p of it("policies")) {
372
+ for (const e of list(p.when)) lines.push(` ${mid(e)} --> ${mid(p.id)}`);
373
+ for (const c of list(p.then)) lines.push(` ${mid(p.id)} --> ${mid(c)}`);
374
+ }
375
+ for (const r of it("read_models")) {
376
+ for (const e of list(r.fed_by)) lines.push(` ${mid(e)} -.-> ${mid(r.id)}`);
377
+ for (const a of list(r.used_by)) lines.push(` ${mid(r.id)} -.-> ${mid(a)}`);
378
+ }
379
+ lines.push(CLASSDEFS, "```", "");
380
+ out.push("## Big Picture", "", ...lines);
381
+
382
+ // Flows
383
+ if (it("flows").length) {
384
+ out.push("## Flows", "");
385
+ for (const f of it("flows")) {
386
+ out.push(`### ${f.name}`, "");
387
+ if (f.description) out.push(f.description, "");
388
+ const seq = list(f.sequence).filter((i) => idx.has(i));
389
+ out.push("```mermaid", "flowchart LR");
390
+ out.push(...[...new Set(seq)].map((i) => ` ${node(i, idx.get(i)!)}`));
391
+ out.push(...seq.slice(1).map((b, n) => ` ${mid(seq[n])} --> ${mid(b)}`));
392
+ out.push(CLASSDEFS, "```", "");
393
+ }
394
+ }
395
+
396
+ // Contexts & aggregates
397
+ if (it("bounded_contexts").length) {
398
+ out.push("## Bounded Contexts & Aggregates", "", "| Context | Purpose | Aggregates | Events |", "|---|---|---|---|");
399
+ for (const bc of it("bounded_contexts")) {
400
+ const aggs = it("aggregates").filter((a) => a.context === bc.id).map((a) => a.name);
401
+ const evts = it("events").filter((e) => ctxOf(e.id) === bc.id).map((e) => e.name);
402
+ out.push(`| ${bc.name} (\`${bc.id}\`) | ${bc.purpose ?? ""} | ${aggs.join(", ") || "–"} | ${evts.join(", ") || "–"} |`);
403
+ }
404
+ out.push("");
405
+ }
406
+
407
+ // Hotspots
408
+ const hs = it("hotspots").filter((h) => (h.status ?? "open") === "open");
409
+ if (hs.length) {
410
+ out.push("## Open Hotspots", "");
411
+ for (const h of hs) {
412
+ const refs = list(h.refs);
413
+ out.push(`- **${h.id}** – ${h.question}` + (refs.length ? ` _(refs: ${refs.join(", ")})_` : ""));
414
+ }
415
+ out.push("");
416
+ }
417
+ return out.join("\n");
418
+ }
419
+
420
+ export function writeEventStormingMd(model: Obj): string {
421
+ writeFileSync(EVENT_STORMING_MD, renderEventStorming(model), "utf8");
422
+ return EVENT_STORMING_MD;
423
+ }
424
+
425
+ // --------------------------------------------------------------------------
426
+ // Stories
427
+ // --------------------------------------------------------------------------
428
+ export interface Story {
429
+ path: string;
430
+ meta: Obj;
431
+ body: string;
432
+ }
433
+
434
+ export const storyId = (s: Story): string => (typeof s.meta.id === "string" ? s.meta.id : "");
435
+
436
+ export function parseFrontmatter(path: string): { meta: Obj; body: string } {
437
+ const text = readFileSync(path, "utf8").replaceAll("\r\n", "\n");
438
+ if (!text.startsWith("---\n")) throw new Error("YAML frontmatter is missing (file must start with '---')");
439
+ const end = text.indexOf("\n---", 4);
440
+ if (end === -1) throw new Error("frontmatter is not closed with '---'");
441
+ const meta = parseYaml(text.slice(4, end)) ?? {};
442
+ if (!isType(meta, "object")) throw new Error("frontmatter must be an object");
443
+ return { meta, body: text.slice(end + 4).replace(/^\n+/, "") };
444
+ }
445
+
446
+ export function storyFiles(): string[] {
447
+ if (!existsSync(STORIES_DIR)) return [];
448
+ return readdirSync(STORIES_DIR)
449
+ .filter((n) => n.endsWith(".md") && !NON_STORY_FILES.has(n) && !n.startsWith("_"))
450
+ .sort()
451
+ .map((n) => join(STORIES_DIR, n));
452
+ }
453
+
454
+ /** Parses all story files; files with broken frontmatter end up in `failures`. */
455
+ export function loadStories(): { parsed: Story[]; failures: [string, string][] } {
456
+ const parsed: Story[] = [];
457
+ const failures: [string, string][] = [];
458
+ for (const path of storyFiles()) {
459
+ try {
460
+ parsed.push({ path, ...parseFrontmatter(path) });
461
+ } catch (e) {
462
+ failures.push([path, e instanceof Error ? e.message : String(e)]);
463
+ }
464
+ }
465
+ return { parsed, failures };
466
+ }
467
+
468
+ export function sections(body: string): Map<string, string> {
469
+ const out = new Map<string, string>();
470
+ let current: string | null = null;
471
+ for (const line of body.split("\n")) {
472
+ const m = /^##\s+(.+?)\s*$/.exec(line);
473
+ if (m) {
474
+ current = m[1].trim().toLowerCase();
475
+ out.set(current, "");
476
+ } else if (current !== null) {
477
+ out.set(current, out.get(current) + line + "\n");
478
+ }
479
+ }
480
+ return out;
481
+ }
482
+
483
+ export function section(secs: Map<string, string>, name: string): string | null {
484
+ for (const [k, v] of secs) if (k.startsWith(name)) return v;
485
+ return null;
486
+ }
487
+
488
+ export const SCENARIO_RE = /^\s*(?:Scenario Outline|Scenario Template|Scenario|Example)\s*:/m;
489
+
490
+ function checkBody(r: Report, where: string, meta: Obj, body: string): void {
491
+ const secs = sections(body);
492
+ const type = meta.type;
493
+ if (type === "story") {
494
+ const s = section(secs, "story");
495
+ if (s === null) {
496
+ r.error(where, "section '## Story' is missing");
497
+ } else if (!/\bAs an?\b.+?\bI want\b.+?\b(so that|in order to)\b/is.test(s)) {
498
+ r.error(where, "story sentence must have the form 'As a <role>, I want <goal>, so that <benefit>'");
499
+ }
500
+ }
501
+ if (type === "spike" && section(secs, "question") === null) r.error(where, "spike needs a '## Question' section");
502
+ if (type === "tech-task" && section(secs, "task") === null) r.error(where, "tech task needs a '## Task' section");
503
+
504
+ const ac = section(secs, "acceptance criteria");
505
+ if (ac === null) {
506
+ r.error(where, "section '## Acceptance Criteria' is missing");
507
+ return;
508
+ }
509
+ const scenarios = ac.split(SCENARIO_RE).slice(1);
510
+ // Scenario titles become test titles (`ST-NNN: <scenario title>`), so they must be unique within a story
511
+ const titles = scenarios.map((sc) => sc.split("\n")[0].trim().toLowerCase());
512
+ const dupes = titles.filter((t, n) => t && titles.indexOf(t) !== n);
513
+ if (dupes.length) r.error(where, `scenario titles must be unique within the story: ${[...new Set(dupes)].join(", ")}`);
514
+ if (type === "story") {
515
+ if (scenarios.length < 2) r.error(where, "at least 2 Gherkin scenarios required (happy path + error/edge case)");
516
+ scenarios.forEach((sc, n) => {
517
+ if (!/^\s*When\b/m.test(sc) || !/^\s*Then\b/m.test(sc)) {
518
+ r.error(where, `scenario ${n + 1}: 'When' and 'Then' are required`);
519
+ }
520
+ });
521
+ } else if (!scenarios.length && !/^\s*[-*]\s*\[[ xX]\]/m.test(ac)) {
522
+ r.error(where, "give acceptance criteria as scenarios or a checklist ('- [ ] …')");
523
+ }
524
+
525
+ if (COMMITTED.has(meta.status) && body.includes("[OPEN]")) {
526
+ r.error(where, `still contains [OPEN] markers, must not be '${meta.status}'`);
527
+ }
528
+ }
529
+
530
+ /** Validates all stories. With `only`, findings are reported for that file only. */
531
+ /** The scenario titles of a story body, in order. */
532
+ export function scenarioTitlesIn(body: string): string[] {
533
+ const ac = section(sections(body), "acceptance criteria") ?? "";
534
+ const out: string[] = [];
535
+ for (const line of ac.split("\n")) {
536
+ const m = /^\s*(?:Scenario Outline|Scenario Template|Scenario|Example)\s*:\s*(.+?)\s*$/.exec(line);
537
+ if (m) out.push(m[1]);
538
+ }
539
+ return out;
540
+ }
541
+
542
+ /**
543
+ * What a story with a screen has not decided yet – the UX guidelines for the empty case, rejected input and
544
+ * permission.
545
+ *
546
+ * These three gaps repeat across backlogs because nobody carries the list: stories introduce a screen with no
547
+ * empty case, never say what a rejection does with what was typed, or let a person meet a wall instead of
548
+ * removing the door. The validator should remember that, not a person.
549
+ *
550
+ * **Warnings, never errors, and a prompt to look rather than a verdict.** Each rule matches the wording stories
551
+ * actually use, which means it both misses cases and names stories where the guideline does not apply (a story
552
+ * whose screen has no list has no empty case to describe). Do not tune it against a particular backlog – that
553
+ * fits the regex to one project instead of to the rule. The `ux-designer` in `/review-stories` reads for meaning
554
+ * and stays the authority.
555
+ */
556
+ export function completenessWarnings(story: Story): string[] {
557
+ if (story.meta.type !== "story") return []; // spikes and tech tasks carry checklists, not screens
558
+ if (!list(story.meta.labels).includes("ui")) return [];
559
+ const ac = section(sections(story.body), "acceptance criteria") ?? "";
560
+ if (!ac.trim()) return [];
561
+ const titles = scenarioTitlesIn(story.body);
562
+ const out: string[] = [];
563
+
564
+ // G7 – the empty case, but only where there is something that can *be* empty. A `ui` story is often a single
565
+ // action on a page another story owns (change a status, claim a record) and has no empty case of its own.
566
+ const showsAList = /\b(listed|overview|dashboard)\b/i.test(ac);
567
+ if (showsAList && !titles.some((t) => /\b(no|nothing|none|empty)\b/i.test(t))) {
568
+ out.push("no scenario for the empty case – what does the screen show before anything exists? (UX guideline G7)");
569
+ }
570
+
571
+ // Judged per scenario, not per line: a permission rejection names the role in its Given and the refusal in
572
+ // its Then, so a line-by-line check misses exactly the case G11 is about.
573
+ const blocks = ac.split(/^(?=\s*(?:Scenario Outline|Scenario Template|Scenario|Example)\s*:)/m).filter((b) => b.trim());
574
+ const rejections = blocks.filter((b) => /\breject(ed|s)?\b/i.test(b));
575
+ // A rejection is about permission when the *role* is the point – not merely because a role is named, which
576
+ // every scenario does ("When a technician registers …"). Otherwise G8 would never fire.
577
+ const aboutPermission = (b: string) =>
578
+ /\b(cannot|can not|may not|is not allowed|tries to|do(es)? not reach)\b/i.test(b) ||
579
+ /^\s*Given\b.*\bis logged in\b/im.test(b);
580
+
581
+ // G8 – a rejected form keeps what was typed. Skipped when every rejection is a permission rejection:
582
+ // "a helper tries and is refused" has nothing typed to keep.
583
+ if (rejections.length && !rejections.every(aboutPermission)) {
584
+ if (!/\b(still (filled in|chosen|selected)|keeps what was typed|are still|is still)\b/i.test(ac)) {
585
+ out.push("a rejection, but nothing says what happens to what was typed (UX guideline G8)");
586
+ }
587
+ }
588
+
589
+ // G11 – someone who may not do a thing does not see the control, instead of meeting a wall.
590
+ if (rejections.some(aboutPermission) && !/\b(not offered|not shown|does not see|is the only|only \S.{0,40}? offered)\b/i.test(ac)) {
591
+ out.push("a permission rejection, but nothing says the control is not offered in the first place (UX guideline G11)");
592
+ }
593
+ return out;
594
+ }
595
+
596
+ export function validateStories(report: Report, only: string | null = null, lenientDeps = false): Map<string, Story> {
597
+ const model = loadEventsModel();
598
+ const idx = model ? indexModel(model) : null;
599
+ if (model === null) report.warn(rel(EVENTS_FILE), "missing or invalid – event/context references are not checked");
600
+
601
+ const onlyRes = only ? realPath(only) : null;
602
+ const isMine = (path: string) => onlyRes === null || realPath(path) === onlyRes;
603
+
604
+ const { parsed, failures } = loadStories();
605
+ for (const [path, msg] of failures) if (isMine(path)) report.error(rel(path), msg);
606
+
607
+ const counts = new Map<string, number>();
608
+ for (const s of parsed) counts.set(storyId(s), (counts.get(storyId(s)) ?? 0) + 1);
609
+ const stories = new Map<string, Story>();
610
+ for (const s of parsed) if (storyId(s)) stories.set(storyId(s), s);
611
+ const backlog = loadBacklogConfig();
612
+
613
+ for (const s of parsed) {
614
+ const r = isMine(s.path) ? report : new Report();
615
+ const where = rel(s.path);
616
+ const id = storyId(s);
617
+ const m = s.meta;
618
+ for (const e of schemaErrors(m, STORY_SCHEMA)) r.error(where, e);
619
+ if (id && !basename(s.path, ".md").startsWith(id)) {
620
+ r.error(where, `file name must start with '${id}' (e.g. ${id}-short-title.md)`);
621
+ }
622
+ if ((counts.get(id) ?? 0) > 1) r.error(where, `ID '${id}' is used more than once`);
623
+ if (idx !== null) {
624
+ const ctx = m.context;
625
+ if (typeof ctx === "string" && ctx && (!idx.has(ctx) || prefix(ctx) !== "BC")) {
626
+ r.error(where, `context '${ctx}' does not exist in events.yaml`);
627
+ }
628
+ for (const ev of list(m.events)) {
629
+ if (!idx.has(ev) || prefix(ev) !== "EVT") r.error(where, `event '${ev}' does not exist in events.yaml`);
630
+ }
631
+ }
632
+ for (const dep of list(m.depends_on)) {
633
+ if (dep === id) {
634
+ r.error(where, "depends on itself");
635
+ } else if (!stories.has(dep)) {
636
+ const msg = `depends_on '${dep}' does not exist`;
637
+ if (lenientDeps) r.warn(where, msg);
638
+ else r.error(where, msg);
639
+ }
640
+ }
641
+ const status = m.status;
642
+ if (COMMITTED.has(status) && !m.size) r.error(where, `size is missing – lead-dev must estimate before '${status}'`);
643
+ if (m.size === "XL") {
644
+ const msg = "size XL – split the story";
645
+ if (COMMITTED.has(status)) r.error(where, msg);
646
+ else r.warn(where, msg);
647
+ }
648
+ // Every scenario is a red → green cycle in /implement; past ten the story is too big or restates UX
649
+ // guideline patterns (user, 2026-10-26). `ready` ones were approved under the older rules.
650
+ const scenarioTotal = scenarioTitlesIn(s.body).length;
651
+ if (m.type === "story" && scenarioTotal > MAX_SCENARIOS && (status === "draft" || status === "review")) {
652
+ const msg = `${scenarioTotal} scenarios, at most ${MAX_SCENARIOS} – split the story, or reference the UX guideline (G-number) instead of restating its pattern as scenarios`;
653
+ if (status === "review") r.error(where, msg);
654
+ else r.warn(where, msg);
655
+ }
656
+ if (m.type === "story" && COMMITTED.has(status) && !list(m.events).length) {
657
+ r.warn(where, "no domain events linked – traceability is missing");
658
+ }
659
+
660
+ // In tracker mode the Markdown file is a mirror. A story that is handed to engineering must carry its
661
+ // external key, otherwise the next pull cannot match it and the tracker is not the source of truth any more.
662
+ if (backlog.provider !== "local") {
663
+ const ext = m.external;
664
+ if (ext !== undefined) {
665
+ if (ext === null || typeof ext !== "object" || ext.provider !== backlog.provider) {
666
+ r.error(where, `external.provider must be '${backlog.provider}' – the configured backlog provider`);
667
+ } else if (typeof ext.key !== "string" || !ext.key) {
668
+ r.error(where, "external.key is missing");
669
+ }
670
+ }
671
+ if (COMMITTED.has(status) && !(ext && typeof ext === "object" && typeof ext.key === "string" && ext.key)) {
672
+ r.error(where, `not linked to the ${backlog.provider} backlog – run /backlog-sync push before '${status}'`);
673
+ }
674
+ const prefix = labelPrefix(backlog);
675
+ for (const label of list(m.labels)) {
676
+ if (isManagedLabel(label, prefix)) r.warn(where, `label '${label}' is reserved for the backlog sync`);
677
+ }
678
+ }
679
+
680
+ checkBody(r, where, m, s.body);
681
+ // Completeness is checked while a story is being written; `ready` ones were approved under the older rules
682
+ // and are challenged in `/groom-backlog` instead (user, 2026-10-01).
683
+ if (status === "draft" || status === "review") for (const w of completenessWarnings(s)) r.warn(where, w);
684
+ }
685
+
686
+ // Cycles in depends_on
687
+ const state = new Map<string, 1 | 2>();
688
+ const visit = (i: string, trail: string[]): void => {
689
+ state.set(i, 1);
690
+ for (const dep of list(stories.get(i)!.meta.depends_on)) {
691
+ if (!stories.has(dep)) continue;
692
+ if (state.get(dep) === 1) {
693
+ const cyc = trail.includes(dep) ? [...trail.slice(trail.indexOf(dep)), dep] : [i, dep];
694
+ if (cyc.some((c) => isMine(stories.get(c)!.path))) {
695
+ report.error(rel(stories.get(i)!.path), `circular dependency: ${cyc.join(" → ")}`);
696
+ }
697
+ } else if (!state.has(dep)) {
698
+ visit(dep, [...trail, dep]);
699
+ }
700
+ }
701
+ state.set(i, 2);
702
+ };
703
+ for (const i of stories.keys()) if (!state.has(i)) visit(i, [i]);
704
+ return stories;
705
+ }
706
+
707
+ // --------------------------------------------------------------------------
708
+ // Backlog (docs/stories/BACKLOG.md)
709
+ // --------------------------------------------------------------------------
710
+ const STATUS_TITLES: [string, string][] = [
711
+ ["in-progress", "In Progress"],
712
+ ["ready", "Ready"],
713
+ ["review", "In Review"],
714
+ ["draft", "Draft"],
715
+ ["done", "Done"],
716
+ ["superseded", "Superseded"],
717
+ ];
718
+
719
+ /** Orders stories by priority, then id – but always after the stories they depend on. */
720
+ export function backlogOrder(group: Story[]): Story[] {
721
+ const rank = (s: Story) => {
722
+ const p = PRIORITIES.indexOf(s.meta.priority);
723
+ return p === -1 ? PRIORITIES.length : p;
724
+ };
725
+ const sorted = [...group].sort((a, b) => rank(a) - rank(b) || storyId(a).localeCompare(storyId(b)));
726
+ const byId = new Map(sorted.map((s) => [storyId(s), s]));
727
+ const order: Story[] = [];
728
+ const seen = new Set<Story>();
729
+ const visit = (s: Story): void => {
730
+ if (seen.has(s)) return;
731
+ seen.add(s);
732
+ for (const dep of list(s.meta.depends_on)) {
733
+ const d = byId.get(dep);
734
+ if (d) visit(d);
735
+ }
736
+ order.push(s);
737
+ };
738
+ sorted.forEach(visit);
739
+ return order;
740
+ }
741
+
742
+ const cell = (v: unknown): string => (v === null || v === undefined || v === "" ? "–" : String(v).replaceAll("|", "\\|"));
743
+
744
+ export function renderBacklog(stories: Story[], model: Obj | null): string {
745
+ const ctxNames = new Map<string, string>(
746
+ (model ? items(model, "bounded_contexts") : []).map((bc) => [bc.id, bc.name]),
747
+ );
748
+ const byId = new Map(stories.map((s) => [storyId(s), s]));
749
+ const out = [
750
+ "# Backlog",
751
+ "",
752
+ "> Generated from `docs/stories/ST-*.md` – **do not edit manually**. Change the story files instead.",
753
+ "",
754
+ ];
755
+ const backlog = loadBacklogConfig();
756
+ if (backlog.provider !== "local") {
757
+ const target = backlog.provider === "jira"
758
+ ? `Jira (\`${backlog.jira.project || "project not configured"}\`)`
759
+ : "GitHub Issues";
760
+ out.push(
761
+ `> Backlog backend: **${target}** – \`docs/stories/\` is the local mirror of the tracker. Sync with \`/backlog-sync\`.`,
762
+ "",
763
+ );
764
+ }
765
+ if (!stories.length) {
766
+ out.push("_No stories yet. Derive them from the event storming with `@requirements-engineer`._", "");
767
+ return out.join("\n");
768
+ }
769
+
770
+ const counts = STATUS_TITLES.map(([st, title]) => [title, stories.filter((s) => s.meta.status === st).length] as const);
771
+ // Progress is measured in domain stories and their scenarios, not in the total number of stories (CLAUDE.md).
772
+ // A superseded story was never built as one – its work counts where it was done.
773
+ const domain = stories.filter((s) => s.meta.type === "story" && s.meta.status !== "superseded");
774
+ const scenarioCount = (s: Story) =>
775
+ (section(sections(s.body), "acceptance criteria") ?? "")
776
+ .split("\n")
777
+ .filter((line) => /^\s*(?:Scenario Outline|Scenario Template|Scenario|Example)\s*:/.test(line)).length;
778
+ const doneDomain = domain.filter((s) => s.meta.status === "done");
779
+ out.push(
780
+ `**Progress: ${doneDomain.length} of ${domain.length} domain stories done** · ` +
781
+ `scenarios ${doneDomain.reduce((n, s) => n + scenarioCount(s), 0)} of ${domain.reduce((n, s) => n + scenarioCount(s), 0)}`,
782
+ "",
783
+ );
784
+ out.push(
785
+ `**${stories.length} ${stories.length === 1 ? "story" : "stories"}** · ` +
786
+ counts.map(([title, n]) => `${title}: ${n}`).join(" · "),
787
+ "",
788
+ "Within each section: by priority, then by story ID, with dependencies pulled in front. Open questions: [OPEN_QUESTIONS.md](OPEN_QUESTIONS.md)",
789
+ "",
790
+ );
791
+
792
+ const known = new Set(STATUS_TITLES.map(([st]) => st));
793
+ const groups: [string, Story[]][] = STATUS_TITLES.map(([st, title]) => [title, stories.filter((s) => s.meta.status === st)]);
794
+ groups.push(["Invalid Status", stories.filter((s) => !known.has(s.meta.status))]);
795
+
796
+ for (const [title, group] of groups) {
797
+ if (!group.length) continue;
798
+ out.push(`## ${title}`, "", "| ID | Title | Type | Context | Priority | Size | Risk | Depends on |", "|---|---|---|---|---|---|---|---|");
799
+ for (const s of backlogOrder(group)) {
800
+ const m = s.meta;
801
+ const deps = list(m.depends_on).map((dep) => {
802
+ const d = byId.get(dep);
803
+ if (!d) return `${dep} (missing)`;
804
+ return COMMITTED.has(d.meta.status) ? dep : `${dep} (${d.meta.status})`;
805
+ });
806
+ const ctx = typeof m.context === "string" ? ctxNames.get(m.context) ?? m.context : m.context;
807
+ out.push(
808
+ `| [${cell(storyId(s) || basename(s.path))}](${basename(s.path)}) | ${cell(m.title)} | ${cell(m.type)} | ${cell(ctx)} | ` +
809
+ `${cell(m.priority)} | ${cell(m.size)} | ${cell(m.risk)} | ${deps.join(", ") || "–"} |`,
810
+ );
811
+ }
812
+ out.push("");
813
+ }
814
+ return out.join("\n");
815
+ }
816
+
817
+ /** Regenerates BACKLOG.md from all parseable story files. */
818
+ export function writeBacklogMd(): string {
819
+ writeFileSync(BACKLOG_MD, renderBacklog(loadStories().parsed, loadEventsModel()), "utf8");
820
+ return BACKLOG_MD;
821
+ }