storymapper 0.1.0

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 (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,3908 @@
1
+ /**
2
+ * storymap — core data model (single source of truth)
3
+ *
4
+ * UMD single source of truth (shared/). Consumed two ways from ONE file:
5
+ * - Node: require() — server/core.js is a 1-line shim, tests require directly.
6
+ * - Browser: <script src> attaches to window.STORYMAP.core.
7
+ * Pure functions only — no I/O, no DOM. Edit ONLY this file; the server shim
8
+ * and the frontend symlink both resolve here, so drift is impossible.
9
+ */
10
+ (function (root, factory) {
11
+ if (typeof module === "object" && module.exports) module.exports = factory();
12
+ else (root.STORYMAP = root.STORYMAP || {}).core = factory();
13
+ }(typeof self !== "undefined" ? self : this, function () {
14
+ "use strict";
15
+
16
+ /**
17
+ * storymap — core data model.
18
+ *
19
+ * Pure functions only (no I/O). Single source of truth for the snapshot
20
+ * shape and every mutation. Mirrored 1:1 into `frontend/js/core.js` via
21
+ * UMD wrapper; `tests/test-mirror.js` enforces parity.
22
+ *
23
+ * Snapshot shape:
24
+ * {
25
+ * version: 1,
26
+ * project: { id, name, ..., definitions, ticketPrefix, ticketCounter, ... },
27
+ * tickets: [ { id, projectId, type, ticketKey, ..., definitionOfReady, definitionOfDone, ... } ],
28
+ * releases: [ { id, projectId, name, status, sortOrder, ... } ],
29
+ * processSteps: [ { id, projectId, name, epicId, sortOrder, ... } ]
30
+ * }
31
+ *
32
+ * `ops` are (snapshot, args, actor) → newSnapshot. They never mutate the
33
+ * input; they deep-clone first. Validation is OUT of scope here — see
34
+ * `server/validation.js`. Status-transition checks (DoR/DoD enforcement)
35
+ * live in validation.js too.
36
+ */
37
+
38
+ const SCHEMA_VERSION = 1;
39
+
40
+ // Virtual backlog identifiers used by the renderer to group tickets that
41
+ // have no release or no epic assignment yet. Never written to the DB —
42
+ // `position.releaseId === null` / `position.epicId === null` is the
43
+ // canonical "in backlog" state. The IDs exist so views can address the
44
+ // backlog as if it were a regular row.
45
+ const EPIC_BACKLOG_ID = "__epic_backlog__";
46
+ const STORY_BACKLOG_ID = "__story_backlog__";
47
+
48
+ const DEFAULT_TICKET_TYPES = [
49
+ "epic",
50
+ "user-story",
51
+ "technical-task-backend",
52
+ "technical-task-ui",
53
+ "bug",
54
+ // SM-53 / SM-56 — first-class test types.
55
+ // test-definition holds the reusable test script (prerequisites + steps
56
+ // with expectedResult). test-execution is a single run that references
57
+ // a definition + records actualResult/status per step + an outcome.
58
+ "test-definition",
59
+ "test-execution",
60
+ // SM-196 R-1 — spec-layer type. A `requirement` is a SpecObject: one
61
+ // sliced PRD section, an atomic statement. It is NOT a work item — it
62
+ // never appears on the kanban board or as a story-card in a story-map
63
+ // cell, carries no DoR/DoD, and serves only as a link target for
64
+ // `realises` (=DOORS satisfies) / `tests` (=validates).
65
+ "requirement",
66
+ // SM-196 R-2 — the spec-module container. One per imported document
67
+ // (DOORS "module" / ReqIF Specification). Holds its requirements via
68
+ // `contains` links, ordered by sectionPath; carries the source
69
+ // attachment id. Board-excluded like requirements.
70
+ "spec-module"
71
+ ];
72
+
73
+ // SM-196 — spec-layer types (SpecObjects + their module container). Distinct
74
+ // from work items: they live in a spec module / the Requirements view, never
75
+ // on a board.
76
+ const SPEC_TYPES = ["requirement", "spec-module"];
77
+ function isSpecType(type) { return SPEC_TYPES.indexOf(type) >= 0; }
78
+ // A ticket that appears as a movable work-card on the kanban board:
79
+ // excludes epics (containers shown specially) AND spec types (requirements).
80
+ function isBoardWorkItem(type) { return type !== "epic" && !isSpecType(type); }
81
+
82
+ const DEFAULT_STATUSES = ["backlog", "ready", "in-progress", "review", "done"];
83
+
84
+ const DEFAULT_RELEASE_STATUSES = ["planning", "active", "completed", "cancelled"];
85
+
86
+ /**
87
+ * Storymapper-bundled DoR/DoD-Vorgaben. `normalizeProject` seedet diese
88
+ * Items, falls ein neues Projekt OHNE eigene Definitions angelegt wird —
89
+ * damit hat jedes neue Projekt direkt eine arbeitsfähige Vorlage. User
90
+ * kann sie pro Projekt überschreiben (REST/MCP via set_definitions, oder
91
+ * direkt via PUT /projects/:pid).
92
+ */
93
+ const STORYMAPPER_DEFAULT_DEFINITIONS = {
94
+ ready: {
95
+ global: [
96
+ { id: "dor-acceptance", label: "Acceptance criteria are defined", required: true },
97
+ { id: "dor-clarity", label: "Scope is clear to the team", required: true }
98
+ ],
99
+ byType: {}
100
+ },
101
+ done: {
102
+ global: [
103
+ { id: "dod-tests", label: "All acceptance criteria are met", required: true },
104
+ { id: "dod-review", label: "Code reviewed by a peer", required: true }
105
+ ],
106
+ byType: {}
107
+ }
108
+ };
109
+
110
+ /**
111
+ * Storymapper-bundled Workflow-Vorgabe. `normalizeProject` seedet diese,
112
+ * falls ein Projekt kein eigenes `workflow`-Feld hat. Bestandsdaten ohne
113
+ * Workflow erben damit das gleiche Verhalten, das vor E13.C hardgekodet
114
+ * war (Gates bei → ready und → done).
115
+ *
116
+ * Shape:
117
+ * statuses: geordnete Liste der erlaubten Status — Reihenfolge
118
+ * bestimmt die "Vorwärts"-Richtung für Sprung-Übergänge.
119
+ * transitions: pro Target-Status optional `requireGate: "DoR"|"DoD"|null`.
120
+ * Bei Vorwärts-Sprung werden ALLE Gates der überquerten
121
+ * Status geprüft (z.B. backlog→done greift DoR UND DoD).
122
+ * byType: Per-Type-Overrides (analog definitions.byType).
123
+ * Pro Type kann `transitions` ganz oder teilweise überschrieben
124
+ * werden; fehlende Schlüssel fallen auf das globale Workflow
125
+ * zurück.
126
+ */
127
+ // Status-Kategorien (Jira-Modell): treiben Lane-Farben + Board-Column-
128
+ // Mapping-Defaults. Erweiterbar bewusst NICHT gehalten — diese vier deckt
129
+ // den großen Teil realistischer Workflows ab, weitere Sub-Kategorien
130
+ // können sinngemäß rein als Display-Variante kommen.
131
+ // SM-242: `cancelled` is the fifth, TERMINAL category alongside done — a
132
+ // deliberate non-implementation (scope reduction), distinct from done.
133
+ const STATUS_CATEGORIES = ["todo", "doing", "blocked", "done", "cancelled"];
134
+
135
+ // Heuristik beim Migrieren alter String-Status-Listen: gleicht den Status-
136
+ // Identifier mit bekannten Mustern ab und liefert eine sinnvolle Kategorie.
137
+ // Unbekannte IDs fallen auf "doing" — der häufigste Fall in der Praxis.
138
+ function defaultCategoryForStatusId(id) {
139
+ if (typeof id !== "string") return "doing";
140
+ const s = id.toLowerCase();
141
+ if (s === "backlog" || s === "todo" || s === "to-do" || s === "open"
142
+ || s === "new" || s === "ready") return "todo";
143
+ if (s === "blocked" || s === "on-hold" || s === "hold" || s === "waiting"
144
+ || s === "deferred" || s === "paused") return "blocked";
145
+ // SM-242: cancelled-ish ids map to the cancelled category, NOT done.
146
+ if (s === "cancelled" || s === "canceled" || s === "wontdo" || s === "won't-do"
147
+ || s === "wont-do" || s === "rejected" || s === "abandoned") return "cancelled";
148
+ if (s === "done" || s === "closed" || s === "complete" || s === "completed"
149
+ || s === "shipped") return "done";
150
+ return "doing";
151
+ }
152
+
153
+ // Sehr leichte Humanisierung: kebab-case / snake_case → Title Case.
154
+ // "in-progress" → "In Progress", "code_review" → "Code Review".
155
+ function humanizeStatusId(id) {
156
+ if (typeof id !== "string" || id.length === 0) return "";
157
+ return id.replace(/[-_]+/g, " ")
158
+ .split(" ")
159
+ .filter(Boolean)
160
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
161
+ .join(" ");
162
+ }
163
+
164
+ function normalizeStatusItem(s) {
165
+ if (typeof s === "string") {
166
+ return { id: s, name: humanizeStatusId(s), category: defaultCategoryForStatusId(s) };
167
+ }
168
+ if (!s || typeof s !== "object") return null;
169
+ const id = typeof s.id === "string" ? s.id : null;
170
+ if (!id) return null;
171
+ const name = typeof s.name === "string" && s.name.length > 0 ? s.name : humanizeStatusId(id);
172
+ const category = STATUS_CATEGORIES.includes(s.category) ? s.category : defaultCategoryForStatusId(id);
173
+ return { id: id, name: name, category: category };
174
+ }
175
+
176
+ const STORYMAPPER_DEFAULT_WORKFLOW = {
177
+ statuses: [
178
+ { id: "backlog", name: "Backlog", category: "todo" },
179
+ { id: "ready", name: "Ready", category: "todo" },
180
+ { id: "in-progress", name: "In Progress", category: "doing" },
181
+ { id: "review", name: "Review", category: "doing" },
182
+ { id: "done", name: "Done", category: "done" },
183
+ // SM-242: terminal cancel state — a deliberate non-implementation.
184
+ { id: "cancelled", name: "Cancelled", category: "cancelled" }
185
+ ],
186
+ // Jira-style named transitions: every status reachable as allowFromAny
187
+ // (= forward moves preserved by default). Gates live on 'ready' (DoR)
188
+ // and 'done' (DoD). Project owners may replace this list with stricter
189
+ // source-restrictions via the Settings UI (E21.F).
190
+ transitions: [
191
+ { id: "to-backlog", name: "→ Backlog", fromStatuses: [], toStatus: "backlog", requireGate: null, allowFromAny: true },
192
+ { id: "to-ready", name: "→ Ready", fromStatuses: [], toStatus: "ready", requireGate: "DoR", allowFromAny: true },
193
+ { id: "to-in-progress", name: "→ In Progress", fromStatuses: [], toStatus: "in-progress", requireGate: null, allowFromAny: true },
194
+ { id: "to-review", name: "→ Review", fromStatuses: [], toStatus: "review", requireGate: null, allowFromAny: true },
195
+ { id: "to-done", name: "→ Done", fromStatuses: [], toStatus: "done", requireGate: "DoD", allowFromAny: true },
196
+ // SM-242: gate-free cancel from any status (Cancel ≠ Delete — keeps history).
197
+ { id: "cancel", name: "Cancel", fromStatuses: [], toStatus: "cancelled", requireGate: null, allowFromAny: true }
198
+ ],
199
+ byType: {}
200
+ };
201
+
202
+ const LIMITS = {
203
+ maxTicketsPerProject: 5000,
204
+ maxReleases: 200,
205
+ maxProcessSteps: 200,
206
+ maxChecklistItems: 100,
207
+ maxLabelLength: 200,
208
+ maxTitleLength: 300,
209
+ maxDescriptionLength: 50000,
210
+ maxCommentBody: 50000
211
+ };
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Helpers
215
+ // ---------------------------------------------------------------------------
216
+
217
+ function uid(prefix) {
218
+ return (prefix || "") + Math.random().toString(36).slice(2, 7) + Date.now().toString(36).slice(-3);
219
+ }
220
+
221
+ function now() {
222
+ return Date.now();
223
+ }
224
+
225
+ function clone(x) {
226
+ return JSON.parse(JSON.stringify(x));
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // SM-240 — Copy-on-Write snapshot machinery.
231
+ //
232
+ // Every op used to start with a FULL deep clone of the snapshot (the real
233
+ // `commit` hotspot, ~11ms at N=1000) — destroying structural sharing for the
234
+ // undo stack and the renderers' reference-equality fast-paths. cowSnap makes
235
+ // a SHALLOW copy (project + the entity arrays are fresh; the entity OBJECTS
236
+ // stay shared); cowTicket/cowRelease/cowProcessStep deep-clone exactly the
237
+ // one entity about to be mutated and swap it into the array.
238
+ //
239
+ // The `__cowFresh` WeakSet (non-enumerable — invisible to JSON/Object.keys/
240
+ // deep-equal) tracks objects that are already private to this op run, making
241
+ // the cow* helpers idempotent: a second cow of the same entity returns the
242
+ // SAME object, so helpers that hold a reference never go stale. Freshly
243
+ // created entities are registered via markCowFresh for the same reason.
244
+ //
245
+ // Guardrails live in tests/test-core-cow.js: (a) the input snapshot is
246
+ // deep-frozen — any in-place mutation of a shared object throws; (b) untouched
247
+ // tickets must come out reference-equal; (c) every op output must be
248
+ // normalize-invariant (the store skips the full normalize on the op path).
249
+ // ---------------------------------------------------------------------------
250
+
251
+ function cowSnap(snap) {
252
+ const s = Object.assign({}, snap);
253
+ s.project = Object.assign({}, snap.project);
254
+ s.tickets = (snap.tickets || []).slice();
255
+ s.releases = (snap.releases || []).slice();
256
+ s.processSteps = (snap.processSteps || []).slice();
257
+ Object.defineProperty(s, "__cowFresh", {
258
+ value: new WeakSet(), enumerable: false, configurable: true
259
+ });
260
+ return s;
261
+ }
262
+
263
+ function markCowFresh(s, obj) {
264
+ if (s && s.__cowFresh && obj && typeof obj === "object") s.__cowFresh.add(obj);
265
+ return obj;
266
+ }
267
+
268
+ function cowEntity(s, arrName, id) {
269
+ const arr = s[arrName] || [];
270
+ const i = arr.findIndex(e => e && e.id === id);
271
+ if (i < 0) return null;
272
+ const cur = arr[i];
273
+ if (s.__cowFresh && s.__cowFresh.has(cur)) return cur; // already private
274
+ const next = clone(cur);
275
+ arr[i] = next;
276
+ if (s.__cowFresh) s.__cowFresh.add(next);
277
+ return next;
278
+ }
279
+
280
+ function cowTicket(s, id) { return cowEntity(s, "tickets", id); }
281
+ function cowRelease(s, id) { return cowEntity(s, "releases", id); }
282
+ function cowProcessStep(s, id) { return cowEntity(s, "processSteps", id); }
283
+
284
+ function isObject(x) {
285
+ return x !== null && typeof x === "object" && !Array.isArray(x);
286
+ }
287
+
288
+ function normalizeActor(actor) {
289
+ if (!isObject(actor)) return { type: "human", id: "unknown", name: "Unknown" };
290
+ const out = {
291
+ type: actor.type === "ai" ? "ai" : "human",
292
+ id: typeof actor.id === "string" ? actor.id : "unknown",
293
+ name: typeof actor.name === "string" ? actor.name : "Unknown"
294
+ };
295
+ if (typeof actor.sessionId === "string") out.sessionId = actor.sessionId;
296
+ return out;
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // normalize* — fill defaults, freeze shape
301
+ // ---------------------------------------------------------------------------
302
+
303
+ function normalizeChecklistItem(item) {
304
+ return {
305
+ id: typeof item.id === "string" ? item.id : uid("ci-"),
306
+ label: typeof item.label === "string" ? item.label : "",
307
+ required: item.required !== false,
308
+ checked: item.checked === true,
309
+ checkedAt: typeof item.checkedAt === "number" ? item.checkedAt : null,
310
+ checkedBy: isObject(item.checkedBy) ? normalizeActor(item.checkedBy) : null
311
+ };
312
+ }
313
+
314
+ function normalizeChecklist(cl) {
315
+ const items = Array.isArray(cl && cl.items) ? cl.items.map(normalizeChecklistItem) : [];
316
+ return { items };
317
+ }
318
+
319
+ function normalizeDefinitionItem(item) {
320
+ return {
321
+ id: typeof item.id === "string" ? item.id : uid("def-"),
322
+ label: typeof item.label === "string" ? item.label : "",
323
+ required: item.required !== false
324
+ };
325
+ }
326
+
327
+ function normalizeDefinitionBlock(block) {
328
+ const out = {
329
+ global: Array.isArray(block && block.global) ? block.global.map(normalizeDefinitionItem) : [],
330
+ byType: {}
331
+ };
332
+ const byType = (block && block.byType) || {};
333
+ for (const key of Object.keys(byType)) {
334
+ const entry = byType[key] || {};
335
+ const norm = {};
336
+ if (Array.isArray(entry.appended)) norm.appended = entry.appended.map(normalizeDefinitionItem);
337
+ if (Array.isArray(entry.overridden)) norm.overridden = entry.overridden.map(normalizeDefinitionItem);
338
+ out.byType[key] = norm;
339
+ }
340
+ return out;
341
+ }
342
+
343
+ function normalizeDefinitions(defs) {
344
+ defs = defs || {};
345
+ return {
346
+ ready: normalizeDefinitionBlock(defs.ready),
347
+ done: normalizeDefinitionBlock(defs.done)
348
+ };
349
+ }
350
+
351
+ function isEmptyDefinitions(defs) {
352
+ if (!defs) return true;
353
+ const ready = (defs.ready && defs.ready.global) || [];
354
+ const done = (defs.done && defs.done.global) || [];
355
+ return ready.length === 0 && done.length === 0;
356
+ }
357
+
358
+ // Legacy form: { [target]: {requireGate} }. New form: array of named
359
+ // transitions (Jira-style). normalizeWorkflowTransitions accepts either
360
+ // input form and always emits the array form. The conversion needs the
361
+ // status list because legacy migration generates one allowFromAny entry
362
+ // per known status so existing forward-step behavior is preserved.
363
+ function normalizeTransitionItem(raw, knownStatusIds) {
364
+ if (!raw || typeof raw !== "object") return null;
365
+ const toStatus = typeof raw.toStatus === "string" ? raw.toStatus : null;
366
+ if (!toStatus) return null;
367
+ if (knownStatusIds && !knownStatusIds.includes(toStatus)) return null;
368
+ const gate = raw.requireGate;
369
+ const fromStatuses = Array.isArray(raw.fromStatuses)
370
+ ? raw.fromStatuses.filter(s => typeof s === "string" && (!knownStatusIds || knownStatusIds.includes(s)))
371
+ : [];
372
+ // allowFromAny is whatever the caller said. Don't infer it from
373
+ // fromStatuses.length === 0 — that would override a user who's mid-edit
374
+ // (just unchecked "From any", hasn't picked sources yet) by silently
375
+ // re-checking it. A transition with allowFromAny=false AND empty
376
+ // fromStatuses is "unreachable" at validation time, which is fine as a
377
+ // transient state.
378
+ return {
379
+ id: typeof raw.id === "string" && raw.id.length > 0 ? raw.id : uid("tr-"),
380
+ name: typeof raw.name === "string" && raw.name.length > 0 ? raw.name : ("→ " + humanizeStatusId(toStatus)),
381
+ fromStatuses: fromStatuses,
382
+ toStatus: toStatus,
383
+ requireGate: (gate === "DoR" || gate === "DoD") ? gate : null,
384
+ allowFromAny: raw.allowFromAny === true
385
+ };
386
+ }
387
+
388
+ function normalizeWorkflowTransitions(t, knownStatusIds) {
389
+ // New form: array of named transitions.
390
+ if (Array.isArray(t)) {
391
+ const out = [];
392
+ const seenIds = new Set();
393
+ for (const raw of t) {
394
+ const tr = normalizeTransitionItem(raw, knownStatusIds);
395
+ if (!tr) continue;
396
+ if (seenIds.has(tr.id)) continue;
397
+ seenIds.add(tr.id);
398
+ out.push(tr);
399
+ }
400
+ return out;
401
+ }
402
+ // Legacy form: object keyed by target status. Build one allowFromAny
403
+ // transition per known status; entries in the legacy object inject the
404
+ // gate where defined, missing keys default to no gate.
405
+ if (t && typeof t === "object") {
406
+ const ids = Array.isArray(knownStatusIds) ? knownStatusIds : [];
407
+ const legacy = {};
408
+ for (const k of Object.keys(t)) {
409
+ const v = t[k];
410
+ if (!v || typeof v !== "object") continue;
411
+ const gate = v.requireGate;
412
+ legacy[k] = { requireGate: (gate === "DoR" || gate === "DoD") ? gate : null };
413
+ }
414
+ const out = [];
415
+ for (const id of ids) {
416
+ const entry = legacy[id];
417
+ out.push({
418
+ id: "to-" + id,
419
+ name: "→ " + humanizeStatusId(id),
420
+ fromStatuses: [],
421
+ toStatus: id,
422
+ requireGate: entry ? entry.requireGate : null,
423
+ allowFromAny: true
424
+ });
425
+ }
426
+ return out;
427
+ }
428
+ return [];
429
+ }
430
+
431
+ function normalizeStatusList(list) {
432
+ if (!Array.isArray(list)) return [];
433
+ const out = [];
434
+ const seen = new Set();
435
+ for (const raw of list) {
436
+ const s = normalizeStatusItem(raw);
437
+ if (!s) continue;
438
+ if (seen.has(s.id)) continue; // dedupe by id
439
+ seen.add(s.id);
440
+ out.push(s);
441
+ }
442
+ return out;
443
+ }
444
+
445
+ // SM-242: additive migration — a workflow with NO cancelled-category status
446
+ // gets a `cancelled` status + a gate-free `cancel` transition appended. Never
447
+ // touches existing statuses/transitions; a project that already owns a
448
+ // cancelled-category status (any id) is left byte-identical. Idempotent.
449
+ function ensureCancelledStatus(out) {
450
+ // An empty workflow stays empty so normalizeProject falls back to the default
451
+ // (which already carries cancelled). Only migrate REAL workflows.
452
+ if (!out.statuses.length) return out;
453
+ // SM-244 self-heal: an OLD (pre-SM-242) server that received a snapshot with
454
+ // a cancelled status normalizes its unknown "cancelled" category DOWN to
455
+ // "done" and persists that. After the server is upgraded the corruption
456
+ // survives (done is a valid category). Repair a status whose id is
457
+ // "cancelled" back to the cancelled category so cancel works again.
458
+ for (const s of out.statuses) {
459
+ if (s && s.id === "cancelled" && s.category !== "cancelled") s.category = "cancelled";
460
+ }
461
+ const hasCancelledCat = out.statuses.some(s => s.category === "cancelled");
462
+ const hasCancelledId = out.statuses.some(s => s.id === "cancelled");
463
+ if (hasCancelledCat || hasCancelledId) return out;
464
+ out.statuses = out.statuses.concat([{ id: "cancelled", name: "Cancelled", category: "cancelled" }]);
465
+ // Guard on BOTH toStatus AND id so the append is idempotent even when the
466
+ // workflow already owns a transition with id "cancel" pointing elsewhere
467
+ // (otherwise normalize² would dedupe the duplicate id and diverge).
468
+ if (!out.transitions.some(t => t.toStatus === "cancelled" || t.id === "cancel")) {
469
+ out.transitions = out.transitions.concat([
470
+ { id: "cancel", name: "Cancel", fromStatuses: [], toStatus: "cancelled", requireGate: null, allowFromAny: true }
471
+ ]);
472
+ }
473
+ return out;
474
+ }
475
+
476
+ function normalizeWorkflow(wf) {
477
+ if (!wf || typeof wf !== "object") return null;
478
+ const statuses = normalizeStatusList(wf.statuses);
479
+ const statusIds = statuses.map(s => s.id);
480
+ const out = ensureCancelledStatus({
481
+ statuses: statuses,
482
+ transitions: normalizeWorkflowTransitions(wf.transitions, statusIds),
483
+ byType: {}
484
+ });
485
+ if (wf.byType && typeof wf.byType === "object") {
486
+ for (const type of Object.keys(wf.byType)) {
487
+ const v = wf.byType[type];
488
+ if (!v || typeof v !== "object") continue;
489
+ const sub = {};
490
+ if (Array.isArray(v.statuses)) sub.statuses = normalizeStatusList(v.statuses);
491
+ if (v.transitions) {
492
+ // Per-type-Override darf entweder array (new) oder object (legacy)
493
+ // sein. Bei legacy-Form generieren wir NICHT die volle Liste pro
494
+ // Status — nur die expliziten Einträge, damit der Merge in
495
+ // getWorkflowForType per-Target gezielt ersetzt.
496
+ if (Array.isArray(v.transitions)) {
497
+ sub.transitions = normalizeWorkflowTransitions(v.transitions, statusIds);
498
+ } else if (typeof v.transitions === "object") {
499
+ // Bauen explizit, nur für die im Legacy-Override genannten Targets.
500
+ const overriddenTargets = Object.keys(v.transitions);
501
+ sub.transitions = [];
502
+ for (const target of overriddenTargets) {
503
+ if (!statusIds.includes(target)) continue;
504
+ const entry = v.transitions[target];
505
+ if (!entry || typeof entry !== "object") continue;
506
+ const gate = entry.requireGate;
507
+ sub.transitions.push({
508
+ id: "to-" + target,
509
+ name: "→ " + humanizeStatusId(target),
510
+ fromStatuses: [],
511
+ toStatus: target,
512
+ requireGate: (gate === "DoR" || gate === "DoD") ? gate : null,
513
+ allowFromAny: true
514
+ });
515
+ }
516
+ }
517
+ }
518
+ out.byType[type] = sub;
519
+ }
520
+ }
521
+ return out;
522
+ }
523
+
524
+ /**
525
+ * Resolve the effective workflow for a given ticket type. Layering:
526
+ * project.workflow.byType[type] (statuses/transitions)
527
+ * → overrides project.workflow.{statuses, transitions}
528
+ * → overrides STORYMAPPER_DEFAULT_WORKFLOW.
529
+ * Missing per-type fields fall back to the project-level workflow.
530
+ */
531
+ // Merge base transition list with per-type overrides. Override semantics:
532
+ // when a per-type transition has the same toStatus as a base transition,
533
+ // the per-type one replaces it. Other base transitions stay. This keeps
534
+ // the legacy behavior where `byType[type].transitions["ready"]={gate:null}`
535
+ // disables the DoR gate for that type only.
536
+ function mergeTransitionLists(base, override) {
537
+ if (!Array.isArray(override) || override.length === 0) return (base || []).slice();
538
+ const result = (base || []).slice();
539
+ for (const tr of override) {
540
+ const idx = result.findIndex(b => b.toStatus === tr.toStatus);
541
+ if (idx >= 0) result[idx] = tr;
542
+ else result.push(tr);
543
+ }
544
+ return result;
545
+ }
546
+
547
+ function getWorkflowForType(project, type) {
548
+ const base = (project && project.workflow) || STORYMAPPER_DEFAULT_WORKFLOW;
549
+ const baseStatuses = (base.statuses && base.statuses.length > 0) ? base.statuses : STORYMAPPER_DEFAULT_WORKFLOW.statuses;
550
+ const baseTransitions = Array.isArray(base.transitions) ? base.transitions : STORYMAPPER_DEFAULT_WORKFLOW.transitions;
551
+ const perType = (base.byType && base.byType[type]) || null;
552
+ if (!perType) {
553
+ return { statuses: baseStatuses.slice(), transitions: baseTransitions.slice() };
554
+ }
555
+ return {
556
+ statuses: (perType.statuses && perType.statuses.length > 0) ? perType.statuses.slice() : baseStatuses.slice(),
557
+ transitions: mergeTransitionLists(baseTransitions, perType.transitions || [])
558
+ };
559
+ }
560
+
561
+ function normalizeLabel(l) {
562
+ // Accept either a string (legacy: ticket.labels and old project.labels were
563
+ // string[]; treat the value as the label name) or an object {id?, name, color?}.
564
+ if (typeof l === "string") {
565
+ return { id: uid("lbl-"), name: l, color: "#999999" };
566
+ }
567
+ l = l || {};
568
+ return {
569
+ id: typeof l.id === "string" ? l.id : uid("lbl-"),
570
+ name: typeof l.name === "string" ? l.name : "",
571
+ color: typeof l.color === "string" ? l.color : "#999999"
572
+ };
573
+ }
574
+
575
+ // E21.C: Kanban-Board-Config. Eine Liste von Columns; jede Column bündelt
576
+ // einen oder mehrere Statuses zu einer Lane. Default-Mapping ist 1:1
577
+ // (eine Column pro Status, gleiche Reihenfolge) — Verhalten bleibt
578
+ // rückwärtskompatibel.
579
+ function normalizeKanbanColumn(c, knownStatusIds) {
580
+ if (!c || typeof c !== "object") return null;
581
+ const id = typeof c.id === "string" && c.id.length > 0 ? c.id : null;
582
+ const name = typeof c.name === "string" ? c.name : "";
583
+ const statusIds = Array.isArray(c.statusIds)
584
+ ? c.statusIds.filter(s => typeof s === "string" && knownStatusIds.includes(s))
585
+ : [];
586
+ if (!id) return null;
587
+ return { id: id, name: name, statusIds: statusIds };
588
+ }
589
+
590
+ function defaultKanbanColumns(statuses) {
591
+ return (statuses || []).map(s => ({
592
+ id: "col-" + s.id,
593
+ name: s.name,
594
+ statusIds: [s.id]
595
+ }));
596
+ }
597
+
598
+ function normalizeKanbanBoard(board, statuses) {
599
+ const knownStatusIds = statuses.map(s => s.id);
600
+ if (!board || typeof board !== "object" || !Array.isArray(board.columns)) {
601
+ return { columns: defaultKanbanColumns(statuses) };
602
+ }
603
+ const cols = [];
604
+ const seenIds = new Set();
605
+ for (const raw of board.columns) {
606
+ const c = normalizeKanbanColumn(raw, knownStatusIds);
607
+ if (!c) continue;
608
+ if (seenIds.has(c.id)) continue;
609
+ seenIds.add(c.id);
610
+ cols.push(c);
611
+ }
612
+ // Empty columns are allowed (user just created one, or every status was
613
+ // dragged elsewhere). If the board has NO columns at all → fall back to
614
+ // the default 1:1 mapping so the kanban view never goes completely blank.
615
+ if (cols.length === 0) return { columns: defaultKanbanColumns(statuses) };
616
+ return { columns: cols };
617
+ }
618
+
619
+ function normalizeProjectBoards(boards, statuses) {
620
+ return { kanban: normalizeKanbanBoard(boards && boards.kanban, statuses) };
621
+ }
622
+
623
+ // SM-100: test-flags are type-bound. The renderer + editor only honour them
624
+ // on the test types; storing them for other types is what created the
625
+ // "Epic shows Prerequisites/Steps/Outcome" leak.
626
+ const TEST_FLAGS_FOR_TYPE = {
627
+ "test-definition": ["showPrerequisites", "showSteps"],
628
+ "test-execution": ["showExecutionSteps", "showTestOutcome"]
629
+ };
630
+
631
+ function normalizeEntityTypeConfig(cfg) {
632
+ if (!cfg || typeof cfg !== "object") return {};
633
+ const out = {};
634
+ for (const k of Object.keys(cfg)) {
635
+ const v = cfg[k];
636
+ if (!v || typeof v !== "object") continue;
637
+ const entry = {
638
+ showAcceptanceCriteria: v.showAcceptanceCriteria !== false,
639
+ showDefinitionOfReady: v.showDefinitionOfReady !== false,
640
+ showDefinitionOfDone: v.showDefinitionOfDone !== false,
641
+ allowParentEpic: v.allowParentEpic !== false,
642
+ showProcessStep: v.showProcessStep !== false,
643
+ showRelease: v.showRelease !== false,
644
+ showLinks: v.showLinks !== false
645
+ };
646
+ // SM-100 migration: only carry the test-flags that apply to this type.
647
+ // Stale flags from prior writes are dropped — the snapshot becomes clean
648
+ // on the next save. Non-test types never store test-flags at all.
649
+ const allowed = TEST_FLAGS_FOR_TYPE[k] || [];
650
+ for (const flag of allowed) entry[flag] = v[flag] !== false;
651
+ out[k] = entry;
652
+ }
653
+ return out;
654
+ }
655
+
656
+ /**
657
+ * Resolve the effective config for a given ticket type on a project.
658
+ * Defaults: all sections shown, allowParentEpic=true. Special-case types:
659
+ * - "epic" hard-codes allowParentEpic=false
660
+ * - "test-definition" hides AC (they belong on the story) but shows
661
+ * prerequisites + steps
662
+ * - "test-execution" hides AC/DoR/DoD (outcome replaces the gate) and
663
+ * shows executionSteps + testOutcome
664
+ * Per-type overrides from `project.entityTypeConfig` take precedence.
665
+ */
666
+ function getEntityTypeConfig(project, type) {
667
+ const isTestDef = type === "test-definition";
668
+ const isTestExec = type === "test-execution";
669
+ const isAnyTest = isTestDef || isTestExec;
670
+ // SM-196: spec-layer types (requirement + its spec-module container) are
671
+ // SpecObjects — no AC/DoR/DoD, no board position, no parent epic. Their own
672
+ // text IS the spec. Links stay on (realises/tests point AT a requirement;
673
+ // a module contains its requirements; the modal may still surface them).
674
+ const isReq = isSpecType(type);
675
+ // SM-237: an epic's status is DERIVED from its stories (roll-up), so the
676
+ // DoR/DoD gates never fire on an epic — the checklist UI is N/A (Lockstep:
677
+ // hidden field ⇒ rule not applicable).
678
+ const isEpic = type === "epic";
679
+ const defaults = {
680
+ showAcceptanceCriteria: !isAnyTest && !isReq,
681
+ // SM-54-followup: tests have their OWN completeness criteria —
682
+ // prerequisites + steps for a definition, outcome for an execution.
683
+ // The generic DoR/DoD checklist doesn't apply.
684
+ showDefinitionOfReady: !isAnyTest && !isReq && !isEpic,
685
+ showDefinitionOfDone: !isAnyTest && !isReq && !isEpic,
686
+ allowParentEpic: type !== "epic" && !isReq,
687
+ showProcessStep: !isReq,
688
+ showRelease: !isReq,
689
+ showLinks: true,
690
+ // Test-type defaults.
691
+ showPrerequisites: isTestDef,
692
+ showSteps: isTestDef,
693
+ showExecutionSteps: isTestExec,
694
+ showTestOutcome: isTestExec
695
+ };
696
+ const override = (project && project.entityTypeConfig && project.entityTypeConfig[type]) || null;
697
+ if (!override) return defaults;
698
+ return Object.assign({}, defaults, override, {
699
+ // Hard override: epic + requirement never have a parent epic regardless of config.
700
+ allowParentEpic: (type === "epic" || isReq) ? false : (override.allowParentEpic !== false),
701
+ // SM-237 hard-lock: epics never show DoR/DoD (derived status ⇒ gates N/A),
702
+ // no matter what a stale per-type override carries.
703
+ showDefinitionOfReady: isEpic ? false : (Object.assign({}, defaults, override).showDefinitionOfReady),
704
+ showDefinitionOfDone: isEpic ? false : (Object.assign({}, defaults, override).showDefinitionOfDone),
705
+ // SM-100 hard-lock: test-flags are bound to their type. An override that
706
+ // (legacy or otherwise) carries a `true` for the wrong type must not be
707
+ // honoured — otherwise epics/stories sprout Prereqs / Steps / Outcome.
708
+ showPrerequisites: isTestDef ? (override.showPrerequisites !== false) : false,
709
+ showSteps: isTestDef ? (override.showSteps !== false) : false,
710
+ showExecutionSteps: isTestExec ? (override.showExecutionSteps !== false) : false,
711
+ showTestOutcome: isTestExec ? (override.showTestOutcome !== false) : false
712
+ });
713
+ }
714
+
715
+ function normalizeProject(p) {
716
+ p = p || {};
717
+ const ticketTypes = Array.isArray(p.ticketTypes) && p.ticketTypes.length > 0
718
+ ? p.ticketTypes.slice()
719
+ : DEFAULT_TICKET_TYPES.slice();
720
+ // Definitions: nur seeden, wenn das Projekt GAR KEINE definitions mitbringt
721
+ // (absent → neues Projekt erbt die Bundled-Defaults). Ein EXPLIZIT leeres
722
+ // definitions-Objekt ist eine bewusste User-Wahl und wird respektiert —
723
+ // sonst ließe sich eine geleerte DoR/DoD-Checkliste nie persistieren, weil
724
+ // normalizeProject auf jedem Save läuft. (SM-150)
725
+ const definitions = (p.definitions === undefined || p.definitions === null)
726
+ ? normalizeDefinitions(STORYMAPPER_DEFAULT_DEFINITIONS)
727
+ : normalizeDefinitions(p.definitions);
728
+ // Workflow: project.workflow überschreibt; sonst Default-Workflow.
729
+ const wf = normalizeWorkflow(p.workflow);
730
+ const workflow = (wf && wf.statuses.length > 0)
731
+ ? wf
732
+ : normalizeWorkflow(STORYMAPPER_DEFAULT_WORKFLOW);
733
+ const out = {
734
+ id: typeof p.id === "string" ? p.id : uid("p-"),
735
+ name: typeof p.name === "string" ? p.name : "",
736
+ description: typeof p.description === "string" ? p.description : "",
737
+ ticketPrefix: typeof p.ticketPrefix === "string" && p.ticketPrefix.length > 0 ? p.ticketPrefix : "P",
738
+ ticketCounter: typeof p.ticketCounter === "number" ? p.ticketCounter : 0,
739
+ definitions: definitions,
740
+ workflow: workflow,
741
+ boards: normalizeProjectBoards(p.boards, workflow.statuses),
742
+ ticketTypes: ticketTypes,
743
+ entityTypeConfig: normalizeEntityTypeConfig(p.entityTypeConfig),
744
+ // SM-45: project.linkTypes — seeded with defaults if empty so new
745
+ // projects come with a workable catalogue. Custom lists completely
746
+ // replace the defaults (no merge — explicit project-owner control).
747
+ linkTypes: _ensureContainsLinkType(
748
+ (Array.isArray(p.linkTypes) && p.linkTypes.length > 0)
749
+ ? p.linkTypes.map(normalizeLinkType)
750
+ : STORYMAPPER_DEFAULT_LINK_TYPES.map(normalizeLinkType)
751
+ ),
752
+ labels: Array.isArray(p.labels) ? p.labels.map(normalizeLabel) : [],
753
+ // SM-93 — Governance config (predicates + messages + tool_actions).
754
+ // Seeded with STORYMAPPER_DEFAULT_GOVERNANCE if missing/incomplete so
755
+ // every project gets a workable gate-set out of the box. Per-project
756
+ // overrides supported via updateProject({governance: ...}).
757
+ governance: normalizeGovernance(p.governance),
758
+ isDeleted: p.isDeleted === true,
759
+ deletedAt: typeof p.deletedAt === "number" ? p.deletedAt : null,
760
+ deletedBy: isObject(p.deletedBy) ? normalizeActor(p.deletedBy) : null,
761
+ createdAt: typeof p.createdAt === "number" ? p.createdAt : now(),
762
+ createdBy: isObject(p.createdBy) ? normalizeActor(p.createdBy) : { type: "human", id: "unknown", name: "Unknown" },
763
+ updatedAt: typeof p.updatedAt === "number" ? p.updatedAt : (typeof p.createdAt === "number" ? p.createdAt : now()),
764
+ updatedBy: isObject(p.updatedBy) ? normalizeActor(p.updatedBy) : (isObject(p.createdBy) ? normalizeActor(p.createdBy) : { type: "human", id: "unknown", name: "Unknown" }),
765
+ version: typeof p.version === "number" ? p.version : 1
766
+ };
767
+ return out;
768
+ }
769
+
770
+ function normalizePosition(pos) {
771
+ pos = pos || {};
772
+ return {
773
+ releaseId: typeof pos.releaseId === "string" ? pos.releaseId : null,
774
+ epicId: typeof pos.epicId === "string" ? pos.epicId : null,
775
+ processStepId: typeof pos.processStepId === "string" ? pos.processStepId : null,
776
+ sortOrder: typeof pos.sortOrder === "number" ? pos.sortOrder : 0
777
+ };
778
+ }
779
+
780
+ function normalizeAcceptanceCriterion(ac) {
781
+ return {
782
+ id: typeof ac.id === "string" ? ac.id : uid("ac-"),
783
+ text: typeof ac.text === "string" ? ac.text : "",
784
+ completed: ac.completed === true
785
+ };
786
+ }
787
+
788
+ function normalizeComment(c) {
789
+ return {
790
+ id: typeof c.id === "string" ? c.id : uid("c-"),
791
+ body: typeof c.body === "string" ? c.body : "",
792
+ actor: isObject(c.actor) ? normalizeActor(c.actor) : { type: "human", id: "unknown", name: "Unknown" },
793
+ timestamp: typeof c.timestamp === "number" ? c.timestamp : now()
794
+ };
795
+ }
796
+
797
+ // SM-45: link-type semantics. The semantic determines runtime behaviour
798
+ // (cycle-check, inverse-display, status-hooks). Hardcoded set; per-type
799
+ // `linkType.semantic` references one of these names.
800
+ // - precedence: predecessor/successor (cycle-checked)
801
+ // - blocking: blocks/blocked-by (cycle-checked)
802
+ // - sequence: follows-on/precedes (no cycle check; linear chain)
803
+ // - containment: contains/contained-by (cycle-checked)
804
+ // - validation: tests/tested-by (no cycle check; cross-domain)
805
+ // - freeform: relates-to (symmetric, no cycle check)
806
+ // - supersession: supersedes/replaces (cycle-checked; temporal — A obsoletes
807
+ // B). Drives the cross-release feature fold (SM-180): the superseding epic
808
+ // is the current version, the superseded chain is its history.
809
+ const LINK_SEMANTICS = ["precedence", "blocking", "sequence", "containment", "validation", "supersession", "freeform"];
810
+ const CYCLE_CHECKED_SEMANTICS = ["precedence", "blocking", "containment", "supersession"];
811
+
812
+ // SM-45: default link-type catalogue seeded into normalizeProject if the
813
+ // project doesn't bring its own. Covers the standard Jira-style relations
814
+ // every team needs out of the box. Projects can override the list via
815
+ // `project.linkTypes` (full replace, no merge).
816
+ const STORYMAPPER_DEFAULT_LINK_TYPES = [
817
+ { id: "predecessor-of", label: "Predecessor of", inverseLabel: "Successor of", semantic: "precedence" },
818
+ { id: "blocks", label: "Blocks", inverseLabel: "Blocked by", semantic: "blocking" },
819
+ { id: "follows-on", label: "Follows on from", inverseLabel: "Precedes", semantic: "sequence" },
820
+ { id: "contains", label: "Contains", inverseLabel: "Contained by", semantic: "containment" },
821
+ { id: "relates-to", label: "Relates to", inverseLabel: "Relates to", semantic: "freeform" },
822
+ // SM-56: test-execution → test-definition. Source = the run, target = the
823
+ // reusable spec. Inverse "Executed by" surfaces all runs on a definition.
824
+ { id: "executes", label: "Executes", inverseLabel: "Executed by", semantic: "validation" },
825
+ // SM-54-followup: test-definition → user-story/bug/task. A test-definition
826
+ // MUST have at least one outbound `tests` link before leaving backlog —
827
+ // enforced in validateStatusTransition.
828
+ { id: "tests", label: "Tests", inverseLabel: "Tested by", semantic: "validation" },
829
+ // SM-94: spec-evolution. `A modifies B` means ticket A is a follow-up
830
+ // change that modifies the spec/behaviour of B. Open modifies-tickets
831
+ // make the target's linked test-definitions show derivedHealth=stale and
832
+ // block complete_ticket (OPEN_MODIFIES gate). cycleCheck=true so we don't
833
+ // accidentally build A→mod→B→mod→A loops.
834
+ { id: "modifies", label: "Modifies", inverseLabel: "Modified by", semantic: "freeform", cycleCheck: true },
835
+ // SM-180: temporal / spec links for PRD-traceability (direction A + the
836
+ // cross-release fold). `A supersedes B` / `A replaces B` → B is the older
837
+ // version; the fold treats the superseding epic as the current feature and
838
+ // folds the superseded chain into its history. refines = adds detail (not
839
+ // obsoletes). realises = a ticket realises a PRD requirement/anchor.
840
+ { id: "supersedes", label: "Supersedes", inverseLabel: "Superseded by", semantic: "supersession" },
841
+ { id: "replaces", label: "Replaces", inverseLabel: "Replaced by", semantic: "supersession" },
842
+ { id: "refines", label: "Refines", inverseLabel: "Refined by", semantic: "freeform", cycleCheck: true },
843
+ { id: "realises", label: "Realises", inverseLabel: "Realised by", semantic: "freeform" }
844
+ ];
845
+
846
+ // SM-44 backwards-compat: the hardcoded list of link-type IDs that get
847
+ // cycle-checked when project.linkTypes is missing or doesn't define them.
848
+ // Used as a fallback by validateLink — SM-45's project.linkTypes-derived
849
+ // resolution takes precedence when available.
850
+ const CYCLE_CHECKED_LINK_TYPES = ["blocks", "predecessor-of", "contains", "supersedes", "replaces", "refines"];
851
+
852
+ // SM-52: Epic-Story containment is canonical via this linkType. The legacy
853
+ // `ticket.position.epicId` field is migrated to a `contains` link in
854
+ // normalizeSnapshot; all read/write paths use the link as the source of truth.
855
+ const CONTAINS_LINK_TYPE_ID = "contains";
856
+
857
+ // SM-54-followup: the linkType a test-definition uses to point at the
858
+ // ticket(s) it validates ("this test exercises user-story X"). Forward
859
+ // transitions out of backlog require ≥1 outbound link of this type — see
860
+ // validateStatusTransition.
861
+ const TEST_TARGET_LINK_TYPE_ID = "tests";
862
+
863
+ /**
864
+ * Make sure `contains` is in the project linkType catalogue. The migration
865
+ * pass depends on it being there — if the user customized linkTypes and
866
+ * removed it, we re-add the default. Pure helper, returns a new array.
867
+ */
868
+ function _ensureContainsLinkType(linkTypes) {
869
+ const list = Array.isArray(linkTypes) ? linkTypes.slice() : [];
870
+ if (list.some(lt => lt.id === CONTAINS_LINK_TYPE_ID)) return list;
871
+ const def = STORYMAPPER_DEFAULT_LINK_TYPES.find(lt => lt.id === CONTAINS_LINK_TYPE_ID);
872
+ if (def) list.push(normalizeLinkType(def));
873
+ return list;
874
+ }
875
+
876
+ function normalizeLinkType(lt) {
877
+ lt = lt || {};
878
+ const semantic = LINK_SEMANTICS.indexOf(lt.semantic) >= 0 ? lt.semantic : "freeform";
879
+ const out = {
880
+ id: typeof lt.id === "string" && lt.id.length > 0 ? lt.id : "relates-to",
881
+ label: typeof lt.label === "string" && lt.label.length > 0 ? lt.label : lt.id || "Relates to",
882
+ inverseLabel: typeof lt.inverseLabel === "string" && lt.inverseLabel.length > 0 ? lt.inverseLabel : (lt.label || lt.id || "Relates to"),
883
+ semantic: semantic
884
+ };
885
+ if (typeof lt.icon === "string" && lt.icon.length > 0) out.icon = lt.icon;
886
+ if (typeof lt.color === "string" && lt.color.length > 0) out.color = lt.color;
887
+ // SM-94: explicit per-linkType cycle-check opt-in. Overrides semantic
888
+ // resolution — useful for "modifies" which is semantically freeform but
889
+ // still wants the no-cycles guarantee.
890
+ if (lt.cycleCheck === true) out.cycleCheck = true;
891
+ return out;
892
+ }
893
+
894
+ function normalizeLink(l) {
895
+ l = l || {};
896
+ // Accept either the new `linkTypeId` or the legacy `type` field. Default
897
+ // to a freeform "relates-to" so legacy snapshots without a linkTypeId
898
+ // still round-trip cleanly.
899
+ const linkTypeId = typeof l.linkTypeId === "string" ? l.linkTypeId
900
+ : typeof l.type === "string" ? l.type
901
+ : "relates-to";
902
+ const out = {
903
+ id: typeof l.id === "string" ? l.id : uid("ln-"),
904
+ linkTypeId: linkTypeId,
905
+ targetTicketId: typeof l.targetTicketId === "string" ? l.targetTicketId : "",
906
+ createdAt: typeof l.createdAt === "number" ? l.createdAt : now(),
907
+ createdBy: isObject(l.createdBy) ? normalizeActor(l.createdBy) : { type: "human", id: "unknown", name: "Unknown" }
908
+ };
909
+ if (typeof l.label === "string" && l.label.length > 0) out.label = l.label;
910
+ return out;
911
+ }
912
+
913
+ // SM-53: test-definition step shape. Pure data — UI lives elsewhere.
914
+ // { id, step: string, data?: string, expectedResult: string }
915
+ function normalizeTestStep(s) {
916
+ s = s || {};
917
+ return {
918
+ id: typeof s.id === "string" ? s.id : uid("tstep-"),
919
+ step: typeof s.step === "string" ? s.step : "",
920
+ data: typeof s.data === "string" ? s.data : "",
921
+ expectedResult: typeof s.expectedResult === "string" ? s.expectedResult : ""
922
+ };
923
+ }
924
+
925
+ // SM-53: prerequisites mirror the DoR checklist shape (id/label/required/checked)
926
+ // so the same inline-toggle UI can render them. Same idempotent normalizer
927
+ // pattern as normalizeAcceptanceCriterion.
928
+ function normalizeTestPrerequisite(p) {
929
+ p = p || {};
930
+ return {
931
+ id: typeof p.id === "string" ? p.id : uid("tpre-"),
932
+ label: typeof p.label === "string" ? p.label : "",
933
+ required: p.required !== false,
934
+ checked: p.checked === true
935
+ };
936
+ }
937
+
938
+ // SM-56: a single executed step — the static fields are cloned from the
939
+ // definition at run-start; actualResult/status/note are filled during the run.
940
+ // status ∈ pending|passed|failed|blocked|skipped.
941
+ const TEST_EXEC_STEP_STATUSES = ["pending", "passed", "failed", "blocked", "skipped"];
942
+ function normalizeTestExecStep(s) {
943
+ s = s || {};
944
+ const status = TEST_EXEC_STEP_STATUSES.indexOf(s.status) >= 0 ? s.status : "pending";
945
+ const out = {
946
+ id: typeof s.id === "string" ? s.id : uid("xstep-"),
947
+ stepId: typeof s.stepId === "string" ? s.stepId : "",
948
+ step: typeof s.step === "string" ? s.step : "",
949
+ data: typeof s.data === "string" ? s.data : "",
950
+ expectedResult: typeof s.expectedResult === "string" ? s.expectedResult : "",
951
+ actualResult: typeof s.actualResult === "string" ? s.actualResult : "",
952
+ status: status
953
+ };
954
+ if (typeof s.note === "string" && s.note.length > 0) out.note = s.note;
955
+ return out;
956
+ }
957
+
958
+ // SM-56: outcome enum — same five values as step.status but at the
959
+ // execution-ticket level. getEffectiveOutcome returns the manual override
960
+ // when set, else the derived value.
961
+ const TEST_EXEC_OUTCOMES = ["pending", "passed", "failed", "blocked", "skipped"];
962
+
963
+ /**
964
+ * Derive the test-execution outcome from its steps:
965
+ * - any failed → "failed"
966
+ * - any blocked (and no failed) → "blocked"
967
+ * - all passed → "passed"
968
+ * - otherwise → "pending" (includes the empty-steps case)
969
+ *
970
+ * Pure helper, exported so MCP tools and the UI can call the same logic.
971
+ */
972
+ function deriveOutcome(executionSteps) {
973
+ if (!Array.isArray(executionSteps) || executionSteps.length === 0) return "pending";
974
+ let allPassed = true;
975
+ let anyBlocked = false;
976
+ for (const s of executionSteps) {
977
+ if (!s || typeof s.status !== "string") { allPassed = false; continue; }
978
+ if (s.status === "failed") return "failed";
979
+ if (s.status === "blocked") anyBlocked = true;
980
+ if (s.status !== "passed") allPassed = false;
981
+ }
982
+ if (anyBlocked) return "blocked";
983
+ return allPassed ? "passed" : "pending";
984
+ }
985
+
986
+ /** Manual outcomeOverride wins; otherwise the derived value. */
987
+ function getEffectiveOutcome(ticket) {
988
+ if (!ticket) return "pending";
989
+ if (typeof ticket.outcomeOverride === "string"
990
+ && TEST_EXEC_OUTCOMES.indexOf(ticket.outcomeOverride) >= 0) {
991
+ return ticket.outcomeOverride;
992
+ }
993
+ return deriveOutcome(ticket.executionSteps || []);
994
+ }
995
+
996
+ /**
997
+ * SM-57-followup: keep a test-execution's status in sync with its outcome.
998
+ * - Outcome != "pending" → status should be "done" (test produced a result).
999
+ * - Outcome == "pending" → status should be "in-progress" (test still running).
1000
+ *
1001
+ * Only fires the auto-transition when the current status is one of the two
1002
+ * states this coupling owns — `in-progress` (forward) or `done` (revert).
1003
+ * Manual states like `backlog` / `ready` / `review` are left alone so a user
1004
+ * who deliberately parked a test-execution doesn't get steamrolled.
1005
+ *
1006
+ * Pure — mutates `exec` in place; caller decides whether to bumpAudit.
1007
+ */
1008
+ function _syncExecStatusToOutcome(exec) {
1009
+ if (!exec || exec.type !== "test-execution") return false;
1010
+ const outcome = getEffectiveOutcome(exec);
1011
+ // SM-170: route through _setTicketStatus so the aging clock resets when the
1012
+ // outcome flip actually re-opens / closes the run (not a bare assignment).
1013
+ if (outcome !== "pending" && exec.status === "in-progress") {
1014
+ _setTicketStatus(exec, "done");
1015
+ return true;
1016
+ }
1017
+ if (outcome === "pending" && exec.status === "done") {
1018
+ _setTicketStatus(exec, "in-progress");
1019
+ return true;
1020
+ }
1021
+ return false;
1022
+ }
1023
+
1024
+ // ---------------------------------------------------------------------------
1025
+ // SM-93 — Spec-Evolution Governance (Predicate-Library + Evaluator +
1026
+ // Template-Renderer + DEFAULT_GOVERNANCE). Pure, no I/O. The MCP wiring
1027
+ // (SM-94) consumes these to gate tools and surface clear errors.
1028
+ // ---------------------------------------------------------------------------
1029
+
1030
+ const TEST_DEFINITION_LIFECYCLES = ["draft", "published"];
1031
+ const TEST_DEFINITION_HEALTH_STATES = ["unused", "passing", "failing", "stale", "orphaned"];
1032
+
1033
+ const STORYMAPPER_DEFAULT_GOVERNANCE = {
1034
+ gates: {
1035
+ TARGET_NOT_READY: { predicate: "linked_target_status",
1036
+ args: { linkTypeId: "tests", minStatus: "ready" } },
1037
+ MISSING_TESTS_LINK: { predicate: "outgoing_link",
1038
+ args: { linkTypeId: "tests", minCount: 1 } },
1039
+ MISSING_STEPS: { predicate: "ticket_field",
1040
+ args: { field: "steps", check: "non_empty" } },
1041
+ EMPTY_EXPECTED: { predicate: "for_each_step",
1042
+ args: { field: "expectedResult", check: "non_empty" } },
1043
+ STALE_LINKED_DEF: { predicate: "linked_definitions_health",
1044
+ args: { allowed: ["passing", "unused"] } },
1045
+ OPEN_MODIFIES: { predicate: "incoming_modifies_open", args: {} },
1046
+ // SM-299: no done without a test plan. A user-story/bug must have a
1047
+ // PUBLISHED test-definition linked via `tests` before it can complete.
1048
+ // Hard-gate, plan-only (a passing execution is NOT required — that would
1049
+ // be a stricter variant; `allowed`/exec health stays with STALE_LINKED_DEF).
1050
+ MISSING_TEST_DEFINITION: {
1051
+ predicate: "incoming_link",
1052
+ args: { linkTypeId: "tests", sourceType: "test-definition", sourceLifecycle: "published", minCount: 1 },
1053
+ appliesToTypes: ["user-story", "bug"]
1054
+ }
1055
+ },
1056
+ messages: {
1057
+ TARGET_NOT_READY: {
1058
+ title: "Target ticket is not ready",
1059
+ reason: "Definition's target {targetKey} is in status={targetStatus}. Tests can only be published once their target reaches '{minStatus}'+.",
1060
+ suggestion: "Promote {targetKey} to ready first (mark_ready). Once AC are frozen, retry publish_test_definition on {definitionKey}.",
1061
+ skillRef: "Test-Definitions require their target to be in ready+. Specs must be frozen before tests are derived."
1062
+ },
1063
+ MISSING_TESTS_LINK: {
1064
+ title: "Definition has no target",
1065
+ reason: "Definition {definitionKey} has no outgoing 'tests'-link.",
1066
+ suggestion: "Add a 'tests'-link via link_create pointing at the feature/bug to be validated, then retry publish_test_definition.",
1067
+ skillRef: "A test-definition without a target is orphaned. Every definition tests a specific feature."
1068
+ },
1069
+ MISSING_STEPS: {
1070
+ title: "Definition has no steps",
1071
+ reason: "Definition {definitionKey} has 0 steps.",
1072
+ suggestion: "Add at least one step via test_def_step_add (each step needs a non-empty expectedResult), then retry publish_test_definition.",
1073
+ skillRef: "A test-definition needs steps with expectedResult — that's the spec being tested."
1074
+ },
1075
+ EMPTY_EXPECTED: {
1076
+ title: "Step has empty expectedResult",
1077
+ reason: "At least one step in {definitionKey} has an empty expectedResult.",
1078
+ suggestion: "Fill in the expectedResult for every step via test_def_step_update, then retry publish_test_definition.",
1079
+ skillRef: "Each step's expectedResult defines pass-criteria — empty makes the step un-verifiable."
1080
+ },
1081
+ STALE_LINKED_DEF: {
1082
+ title: "Linked test-definition is stale",
1083
+ reason: "Definition(s) {staleReasons} block completion of {targetKey}.",
1084
+ suggestion: "Resolve the blocking changes first, THEN re-run the affected definition(s) via test_exec_start, THEN retry complete_ticket on {targetKey}.",
1085
+ skillRef: "Specs frieren ein. Aenderungen werden zu Tickets. Stale tests block done-transitions."
1086
+ },
1087
+ OPEN_MODIFIES: {
1088
+ title: "Open modification ticket(s) block completion",
1089
+ reason: "Open modifies-ticket(s) {modBlockerKeys} point at {targetKey}.",
1090
+ suggestion: "Resolve {modBlockerKeys} (move them to done) before completing {targetKey}.",
1091
+ skillRef: "An open modifies-link means the spec is mid-change. complete_ticket must wait."
1092
+ },
1093
+ MISSING_TEST_DEFINITION: {
1094
+ title: "No test plan linked",
1095
+ reason: "{targetKey} ({targetType}) has no PUBLISHED test-definition linked via 'tests' — a test plan is required before done.",
1096
+ suggestion: "1) ticket_create type=test-definition (the test plan). 2) test_def_step_add for each step (with expectedResult). 3) link_create linkTypeId='tests' from the definition to {targetKey}. 4) publish_test_definition (target must be ready+). THEN complete_ticket on {targetKey}.",
1097
+ skillRef: "Kein done ohne Testplan: jede user-story/bug braucht eine publizierte, per 'tests'-Link verknuepfte test-definition (SM-299)."
1098
+ }
1099
+ },
1100
+ tool_actions: {
1101
+ publish_test_definition: {
1102
+ gates: ["MISSING_STEPS", "MISSING_TESTS_LINK", "TARGET_NOT_READY", "EMPTY_EXPECTED"]
1103
+ },
1104
+ complete_ticket: {
1105
+ // SM-299: MISSING_TEST_DEFINITION ordered LAST — when a story ALSO
1106
+ // trips STALE_LINKED_DEF/OPEN_MODIFIES, that more-specific gate stays
1107
+ // errors[0] (the one surfaced to the caller).
1108
+ gates: ["STALE_LINKED_DEF", "OPEN_MODIFIES", "MISSING_TEST_DEFINITION"]
1109
+ }
1110
+ },
1111
+ tool_warnings: {}
1112
+ };
1113
+
1114
+ /**
1115
+ * Normalize `project.governance` — accepts caller-supplied overrides, falls
1116
+ * back to STORYMAPPER_DEFAULT_GOVERNANCE when the payload is missing or
1117
+ * structurally incomplete. Idempotent.
1118
+ */
1119
+ function normalizeGovernance(g) {
1120
+ const fallback = STORYMAPPER_DEFAULT_GOVERNANCE;
1121
+ if (!isObject(g)) return clone(fallback);
1122
+ const out = {
1123
+ gates: (isObject(g.gates)) ? g.gates : clone(fallback.gates),
1124
+ messages: (isObject(g.messages)) ? g.messages : clone(fallback.messages),
1125
+ tool_actions: (isObject(g.tool_actions)) ? g.tool_actions : clone(fallback.tool_actions),
1126
+ tool_warnings: (isObject(g.tool_warnings)) ? g.tool_warnings : {}
1127
+ };
1128
+ return out;
1129
+ }
1130
+
1131
+ // ---- Predicate library (closed set of pure functions) ----------------------
1132
+ // Each predicate signature: (snapshot, ticket, args) -> { ok: bool, context?: object }.
1133
+ // `context` is used by the renderer to fill in error messages.
1134
+
1135
+ function _matchCheck(value, check) {
1136
+ if (typeof check === "string") {
1137
+ if (check === "non_empty") {
1138
+ if (value == null) return false;
1139
+ if (typeof value === "string") return value.length > 0;
1140
+ if (Array.isArray(value)) return value.length > 0;
1141
+ return true;
1142
+ }
1143
+ }
1144
+ if (isObject(check)) {
1145
+ if (check.equals !== undefined) return value === check.equals;
1146
+ if (check.gte !== undefined) return typeof value === "number" && value >= check.gte;
1147
+ if (check.lte !== undefined) return typeof value === "number" && value <= check.lte;
1148
+ if (Array.isArray(check.in)) return check.in.indexOf(value) >= 0;
1149
+ }
1150
+ return false;
1151
+ }
1152
+
1153
+ const GOVERNANCE_PREDICATES = {
1154
+ ticket_field(snap, ticket, args) {
1155
+ args = args || {};
1156
+ if (!ticket || !args.field) return { ok: false, context: { reason: "missing field" } };
1157
+ const v = ticket[args.field];
1158
+ return _matchCheck(v, args.check)
1159
+ ? { ok: true }
1160
+ : { ok: false, context: { field: args.field, value: v } };
1161
+ },
1162
+ outgoing_link(snap, ticket, args) {
1163
+ args = args || {};
1164
+ const links = (ticket && ticket.links) || [];
1165
+ let matching = links.filter(l => l.linkTypeId === args.linkTypeId);
1166
+ if (args.targetStatus) {
1167
+ matching = matching.filter(l => {
1168
+ const tgt = ((snap && snap.tickets) || []).find(t => t.id === l.targetTicketId);
1169
+ return tgt && tgt.status === args.targetStatus;
1170
+ });
1171
+ }
1172
+ const minCount = args.minCount || 1;
1173
+ return matching.length >= minCount
1174
+ ? { ok: true }
1175
+ : { ok: false, context: { linkTypeId: args.linkTypeId, count: matching.length, required: minCount } };
1176
+ },
1177
+ incoming_link(snap, ticket, args) {
1178
+ args = args || {};
1179
+ if (!ticket || !snap || !snap.tickets) return { ok: false, context: { reason: "no snapshot" } };
1180
+ let matching = snap.tickets.filter(t =>
1181
+ !t.isDeleted && // SM-299: a soft-deleted source cannot satisfy the gate
1182
+ (t.links || []).some(l => l.linkTypeId === args.linkTypeId && l.targetTicketId === ticket.id));
1183
+ if (args.sourceStatus) matching = matching.filter(t => t.status === args.sourceStatus);
1184
+ // SM-299: optional source-ticket filters (a test-plan gate wants an
1185
+ // incoming `tests`-link specifically from a PUBLISHED test-definition).
1186
+ if (args.sourceType) matching = matching.filter(t => t.type === args.sourceType);
1187
+ if (args.sourceLifecycle) matching = matching.filter(t => t.lifecycle === args.sourceLifecycle);
1188
+ const minCount = args.minCount || 1;
1189
+ return matching.length >= minCount
1190
+ ? { ok: true }
1191
+ : { ok: false, context: { linkTypeId: args.linkTypeId, count: matching.length, required: minCount } };
1192
+ },
1193
+ linked_target_status(snap, ticket, args) {
1194
+ args = args || {};
1195
+ const links = ((ticket && ticket.links) || []).filter(l => l.linkTypeId === args.linkTypeId);
1196
+ if (links.length === 0) return { ok: false, context: { reason: "no outgoing " + args.linkTypeId + "-link" } };
1197
+ const wf = snap && snap.project && snap.project.workflow;
1198
+ const statuses = (wf && Array.isArray(wf.statuses)) ? wf.statuses : [];
1199
+ const minIdx = statuses.findIndex(s => s.id === args.minStatus);
1200
+ if (minIdx < 0) return { ok: true }; // unknown status floor → can't validate, treat as pass
1201
+ for (const l of links) {
1202
+ const tgt = ((snap && snap.tickets) || []).find(t => t.id === l.targetTicketId);
1203
+ if (!tgt) continue;
1204
+ const tgtIdx = statuses.findIndex(s => s.id === tgt.status);
1205
+ if (tgtIdx < minIdx) {
1206
+ return { ok: false, context: {
1207
+ targetKey: tgt.ticketKey, targetStatus: tgt.status, minStatus: args.minStatus
1208
+ }};
1209
+ }
1210
+ }
1211
+ return { ok: true };
1212
+ },
1213
+ linked_definitions_health(snap, ticket, args) {
1214
+ args = args || {};
1215
+ if (!ticket || !snap || !snap.tickets) return { ok: true };
1216
+ const allowed = new Set(args.allowed || ["passing", "unused"]);
1217
+ const definitions = snap.tickets.filter(t =>
1218
+ t.type === "test-definition" && !t.isDeleted &&
1219
+ (t.links || []).some(l => l.linkTypeId === "tests" && l.targetTicketId === ticket.id));
1220
+ const offending = definitions.filter(d => !allowed.has(d.derivedHealth || "unused"));
1221
+ if (offending.length === 0) return { ok: true };
1222
+ const staleReasons = offending.map(d => d.ticketKey + " is " + (d.derivedHealth || "unused")).join("; ");
1223
+ return { ok: false, context: {
1224
+ stale: offending.map(d => ({ defKey: d.ticketKey, health: d.derivedHealth || "unused" })),
1225
+ staleReasons: staleReasons,
1226
+ count: offending.length
1227
+ }};
1228
+ },
1229
+ incoming_modifies_open(snap, ticket, args) {
1230
+ if (!ticket || !snap || !snap.tickets) return { ok: true };
1231
+ const proj = snap.project;
1232
+ const openMods = snap.tickets.filter(t =>
1233
+ !t.isDeleted && !isTerminalStatus(proj, t.status) && // SM-242: cancelled is terminal too
1234
+ (t.links || []).some(l => l.linkTypeId === "modifies" && l.targetTicketId === ticket.id));
1235
+ if (openMods.length === 0) return { ok: true };
1236
+ return { ok: false, context: {
1237
+ modBlockerKeys: openMods.map(t => t.ticketKey).join(", "),
1238
+ count: openMods.length
1239
+ }};
1240
+ },
1241
+ for_each_step(snap, ticket, args) {
1242
+ args = args || {};
1243
+ const steps = (ticket && ticket.steps) || [];
1244
+ if (steps.length === 0) return { ok: false, context: { reason: "no steps" } };
1245
+ for (const s of steps) {
1246
+ if (!_matchCheck(s[args.field], args.check)) {
1247
+ return { ok: false, context: { field: args.field, stepId: s.id } };
1248
+ }
1249
+ }
1250
+ return { ok: true };
1251
+ },
1252
+ for_each_prereq(snap, ticket, args) {
1253
+ args = args || {};
1254
+ const prereqs = (ticket && ticket.prerequisites) || [];
1255
+ for (const p of prereqs) {
1256
+ if (!_matchCheck(p[args.field], args.check)) {
1257
+ return { ok: false, context: { field: args.field, prereqId: p.id } };
1258
+ }
1259
+ }
1260
+ return { ok: true };
1261
+ }
1262
+ };
1263
+
1264
+ /**
1265
+ * Run all gates configured for a given tool action. Pure — no side effects.
1266
+ * Returns `{ ok: true }` when every gate passes, otherwise
1267
+ * `{ ok: false, errors: [{kind, predicate, args, context}, ...] }`.
1268
+ *
1269
+ * Unknown error-kinds (referenced in tool_actions but not defined in
1270
+ * governance.gates) are silently skipped — they may be remnants of a stale
1271
+ * config; rejecting them outright would brick the project on schema-drift.
1272
+ */
1273
+ function evaluateGates(toolAction, snapshot, ticket, governance) {
1274
+ governance = governance || STORYMAPPER_DEFAULT_GOVERNANCE;
1275
+ const action = (governance.tool_actions || {})[toolAction];
1276
+ if (!action || !Array.isArray(action.gates) || action.gates.length === 0) return { ok: true };
1277
+ const errors = [];
1278
+ for (const errorKind of action.gates) {
1279
+ const gateCfg = (governance.gates || {})[errorKind];
1280
+ if (!gateCfg || !gateCfg.predicate) continue;
1281
+ // SM-299: per-gate type scoping. An absent/empty appliesToTypes = every
1282
+ // type (backward compatible); otherwise the gate only evaluates for the
1283
+ // listed ticket types (e.g. MISSING_TEST_DEFINITION → user-story + bug).
1284
+ if (Array.isArray(gateCfg.appliesToTypes) && gateCfg.appliesToTypes.length > 0
1285
+ && (!ticket || gateCfg.appliesToTypes.indexOf(ticket.type) < 0)) continue;
1286
+ const pred = GOVERNANCE_PREDICATES[gateCfg.predicate];
1287
+ if (!pred) {
1288
+ errors.push({ kind: errorKind, predicate: gateCfg.predicate,
1289
+ args: gateCfg.args || {}, context: { error: "unknown predicate" } });
1290
+ continue;
1291
+ }
1292
+ const result = pred(snapshot, ticket, gateCfg.args || {});
1293
+ if (!result.ok) {
1294
+ errors.push({ kind: errorKind, predicate: gateCfg.predicate,
1295
+ args: gateCfg.args || {}, context: result.context || {} });
1296
+ }
1297
+ }
1298
+ return errors.length === 0 ? { ok: true } : { ok: false, errors: errors };
1299
+ }
1300
+
1301
+ /**
1302
+ * Same shape as evaluateGates but for soft warnings — never blocks. Reads
1303
+ * governance.tool_warnings[toolAction].gates. Always returns
1304
+ * `{ warnings: [...] }` (possibly empty).
1305
+ */
1306
+ function evaluateWarnings(toolAction, snapshot, ticket, governance) {
1307
+ governance = governance || STORYMAPPER_DEFAULT_GOVERNANCE;
1308
+ const action = (governance.tool_warnings || {})[toolAction];
1309
+ if (!action || !Array.isArray(action.gates) || action.gates.length === 0) return { warnings: [] };
1310
+ const warnings = [];
1311
+ for (const errorKind of action.gates) {
1312
+ const gateCfg = (governance.gates || {})[errorKind];
1313
+ if (!gateCfg || !gateCfg.predicate) continue;
1314
+ if (Array.isArray(gateCfg.appliesToTypes) && gateCfg.appliesToTypes.length > 0 // SM-299
1315
+ && (!ticket || gateCfg.appliesToTypes.indexOf(ticket.type) < 0)) continue;
1316
+ const pred = GOVERNANCE_PREDICATES[gateCfg.predicate];
1317
+ if (!pred) continue;
1318
+ const result = pred(snapshot, ticket, gateCfg.args || {});
1319
+ if (!result.ok) {
1320
+ warnings.push({ kind: errorKind, predicate: gateCfg.predicate,
1321
+ args: gateCfg.args || {}, context: result.context || {} });
1322
+ }
1323
+ }
1324
+ return { warnings: warnings };
1325
+ }
1326
+
1327
+ // ---- Template-Renderer ----------------------------------------------------
1328
+ // Minimal-deps templater for governance.messages. Supports:
1329
+ // {var} — interpolate context[var]
1330
+ // {var | format} — apply formatter (shortdate, join, ticketKeyList, statusName)
1331
+ // {#count > N}...{/count} — conditional based on context.count
1332
+
1333
+ function _formatShortDate(v) {
1334
+ const d = (typeof v === "number") ? new Date(v) : new Date(String(v));
1335
+ if (isNaN(d.getTime())) return String(v);
1336
+ return d.toISOString().slice(0, 10);
1337
+ }
1338
+
1339
+ function _interpolate(str, ctx) {
1340
+ if (typeof str !== "string") return "";
1341
+ ctx = ctx || {};
1342
+ // Step 1: pluralization conditionals.
1343
+ str = str.replace(/\{#count\s*>\s*(\d+)\}([\s\S]*?)\{\/count\}/g, (_m, n, content) =>
1344
+ (Number(ctx.count) || 0) > Number(n) ? content : "");
1345
+ // Step 2: variable interpolation, optional pipe-format.
1346
+ return str.replace(/\{(\w+)(?:\s*\|\s*(\w+))?\}/g, (_m, name, fmt) => {
1347
+ const val = ctx[name];
1348
+ if (val === undefined || val === null) return "";
1349
+ if (fmt === "join") return Array.isArray(val) ? val.join(", ") : String(val);
1350
+ if (fmt === "shortdate") return _formatShortDate(val);
1351
+ if (fmt === "ticketKeyList") return Array.isArray(val) ? val.join(", ") : String(val);
1352
+ if (fmt === "statusName") return String(val);
1353
+ return String(val);
1354
+ });
1355
+ }
1356
+
1357
+ /**
1358
+ * Render a message-template (one of governance.messages[errorKind]) with the
1359
+ * provided context. Returns `{title, reason, suggestion, skillRef}` — every
1360
+ * field is always a string (empty when the template field is missing).
1361
+ */
1362
+ function renderMessage(template, context) {
1363
+ template = template || {};
1364
+ return {
1365
+ title: _interpolate(template.title || "", context),
1366
+ reason: _interpolate(template.reason || "", context),
1367
+ suggestion: _interpolate(template.suggestion || "", context),
1368
+ skillRef: _interpolate(template.skillRef || "", context)
1369
+ };
1370
+ }
1371
+
1372
+ // ---- derivedHealth for test-definition tickets ----------------------------
1373
+ // Computed at snapshot-load time (post-pass in normalizeSnapshot). Not
1374
+ // persisted on the ticket — pure derivation from the link graph + execution
1375
+ // history. Five states:
1376
+ // unused — no execution yet
1377
+ // passing — latest execution passed, no spec change since
1378
+ // failing — latest execution failed or blocked
1379
+ // stale — latest run was green but definition/target updated since
1380
+ // orphaned — every linked target is soft-deleted or missing
1381
+
1382
+ function computeDerivedHealth(snap, td) {
1383
+ if (!td || td.type !== "test-definition") return "";
1384
+ const tickets = (snap && snap.tickets) || [];
1385
+ const targetLinks = (td.links || []).filter(l => l.linkTypeId === "tests");
1386
+ // Resolve targets — only non-deleted, existing tickets count as "live".
1387
+ let liveTargets = 0;
1388
+ let liveTargetMaxUpdatedAt = 0;
1389
+ let anyTargetLink = targetLinks.length > 0;
1390
+ for (const l of targetLinks) {
1391
+ const tgt = tickets.find(t => t.id === l.targetTicketId);
1392
+ if (!tgt || tgt.isDeleted) continue;
1393
+ liveTargets++;
1394
+ if ((tgt.updatedAt || 0) > liveTargetMaxUpdatedAt) liveTargetMaxUpdatedAt = (tgt.updatedAt || 0);
1395
+ }
1396
+ if (anyTargetLink && liveTargets === 0) return "orphaned";
1397
+
1398
+ const executions = tickets.filter(t =>
1399
+ t.type === "test-execution" && !t.isDeleted && t.referencedTestDefinitionId === td.id);
1400
+ if (executions.length === 0) return "unused";
1401
+ const latest = executions.slice().sort((a, b) => (b.runAt || 0) - (a.runAt || 0))[0];
1402
+ const outcome = getEffectiveOutcome(latest);
1403
+ if (outcome === "failed" || outcome === "blocked") return "failing";
1404
+ if (outcome === "passed") {
1405
+ const lastRun = latest.runAt || 0;
1406
+ if ((td.updatedAt || 0) > lastRun) return "stale";
1407
+ if (liveTargetMaxUpdatedAt > lastRun) return "stale";
1408
+ return "passing";
1409
+ }
1410
+ // pending / skipped — no green run on record, treat as unused-equivalent.
1411
+ return "unused";
1412
+ }
1413
+
1414
+ function _annotateDerivedHealth(snap) {
1415
+ const tickets = (snap && snap.tickets) || [];
1416
+ for (const td of tickets) {
1417
+ if (td.type !== "test-definition") continue;
1418
+ td.derivedHealth = computeDerivedHealth(snap, td);
1419
+ }
1420
+ }
1421
+
1422
+ // ---------------------------------------------------------------------------
1423
+
1424
+ // SM-196 R-1: a requirement's back-reference into its source PRD. Points at
1425
+ // the project attachment + (once the Markdown pre-slicer runs, R-3/R-4) the
1426
+ // section id and char span that produced this slice — the anchor that powers
1427
+ // the reconciliation UI (R-7). Tolerates partial/missing input; null when no
1428
+ // anchor is set (the common case until a slice is imported).
1429
+ function normalizeSourceAnchor(a) {
1430
+ if (!isObject(a)) return null;
1431
+ return {
1432
+ attachmentId: typeof a.attachmentId === "string" ? a.attachmentId : "",
1433
+ sectionId: typeof a.sectionId === "string" ? a.sectionId : "",
1434
+ charStart: typeof a.charStart === "number" ? a.charStart : null,
1435
+ charEnd: typeof a.charEnd === "number" ? a.charEnd : null
1436
+ };
1437
+ }
1438
+
1439
+ function normalizeTicket(t) {
1440
+ t = t || {};
1441
+ const createdBy = isObject(t.createdBy) ? normalizeActor(t.createdBy) : { type: "human", id: "unknown", name: "Unknown" };
1442
+ const createdAt = typeof t.createdAt === "number" ? t.createdAt : now();
1443
+ const type = typeof t.type === "string" ? t.type : "user-story";
1444
+
1445
+ // SM-196 R-1: spec-layer fields. `sectionPath` is the DOORS-style object
1446
+ // identifier ("2.1") that orders requirements within their spec module;
1447
+ // `sourceAnchor` points back to the source PRD section. Gated to the
1448
+ // requirement type so every other ticket keeps a uniform empty shape.
1449
+ const sectionPath = (type === "requirement" && typeof t.sectionPath === "string") ? t.sectionPath : "";
1450
+ const sourceAnchor = (type === "requirement") ? normalizeSourceAnchor(t.sourceAnchor) : null;
1451
+ // SM-196 R-2: a spec-module is bound to exactly one source attachment (the
1452
+ // PRD it slices). Gated to the spec-module type; "" otherwise.
1453
+ const sourceAttachmentId = (type === "spec-module" && typeof t.sourceAttachmentId === "string") ? t.sourceAttachmentId : "";
1454
+
1455
+ // SM-53 / SM-56 — type-specific test fields. Stored on every ticket but
1456
+ // only populated for the matching type; non-test tickets carry empty
1457
+ // arrays so the JSON shape stays uniform (cheap, simplifies all readers).
1458
+ const prerequisites = (type === "test-definition" && Array.isArray(t.prerequisites))
1459
+ ? t.prerequisites.map(normalizeTestPrerequisite) : [];
1460
+ const steps = (type === "test-definition" && Array.isArray(t.steps))
1461
+ ? t.steps.map(normalizeTestStep) : [];
1462
+ const executionSteps = (type === "test-execution" && Array.isArray(t.executionSteps))
1463
+ ? t.executionSteps.map(normalizeTestExecStep) : [];
1464
+ const refDefId = (type === "test-execution" && typeof t.referencedTestDefinitionId === "string")
1465
+ ? t.referencedTestDefinitionId : "";
1466
+ const runAt = (type === "test-execution" && typeof t.runAt === "number") ? t.runAt : null;
1467
+ const runBy = (type === "test-execution" && isObject(t.runBy)) ? normalizeActor(t.runBy) : null;
1468
+ const env = (type === "test-execution" && typeof t.env === "string") ? t.env : "";
1469
+ const outcomeOverride = (type === "test-execution"
1470
+ && typeof t.outcomeOverride === "string"
1471
+ && TEST_EXEC_OUTCOMES.indexOf(t.outcomeOverride) >= 0)
1472
+ ? t.outcomeOverride : null;
1473
+
1474
+ // SM-93: lifecycle for test-definition only. draft = still being authored,
1475
+ // published = locked-in spec runnable via test_exec_start. Default = draft.
1476
+ // Other types get a stable "" so the JSON shape stays uniform.
1477
+ const lifecycle = (type === "test-definition")
1478
+ ? (TEST_DEFINITION_LIFECYCLES.indexOf(t.lifecycle) >= 0 ? t.lifecycle : "draft")
1479
+ : "";
1480
+
1481
+ return {
1482
+ id: typeof t.id === "string" ? t.id : uid("t-"),
1483
+ projectId: typeof t.projectId === "string" ? t.projectId : "",
1484
+ type: type,
1485
+ ticketKey: typeof t.ticketKey === "string" ? t.ticketKey : "",
1486
+ title: typeof t.title === "string" ? t.title : "",
1487
+ description: typeof t.description === "string" ? t.description : "",
1488
+ status: typeof t.status === "string" ? t.status : "backlog",
1489
+ position: normalizePosition(t.position),
1490
+ // SM-196 — spec-layer fields (requirement: sectionPath/sourceAnchor;
1491
+ // spec-module: sourceAttachmentId). Empty/null on every other type.
1492
+ sectionPath: sectionPath,
1493
+ sourceAnchor: sourceAnchor,
1494
+ sourceAttachmentId: sourceAttachmentId,
1495
+ definitionOfReady: normalizeChecklist(t.definitionOfReady),
1496
+ definitionOfDone: normalizeChecklist(t.definitionOfDone),
1497
+ acceptanceCriteria: Array.isArray(t.acceptanceCriteria) ? t.acceptanceCriteria.map(normalizeAcceptanceCriterion) : [],
1498
+ comments: Array.isArray(t.comments) ? t.comments.map(normalizeComment) : [],
1499
+ labels: Array.isArray(t.labels) ? t.labels.slice() : [],
1500
+ links: Array.isArray(t.links) ? t.links.map(normalizeLink) : [],
1501
+ // SM-53 / SM-56 — test-type fields.
1502
+ prerequisites: prerequisites,
1503
+ steps: steps,
1504
+ executionSteps: executionSteps,
1505
+ referencedTestDefinitionId: refDefId,
1506
+ runAt: runAt,
1507
+ runBy: runBy,
1508
+ env: env,
1509
+ outcomeOverride: outcomeOverride,
1510
+ // SM-93 — test-definition lifecycle + derived health (the latter gets
1511
+ // recomputed in normalizeSnapshot's post-pass, so the per-ticket field
1512
+ // here just carries the persisted hint or "" until then).
1513
+ lifecycle: lifecycle,
1514
+ derivedHealth: typeof t.derivedHealth === "string" ? t.derivedHealth : "",
1515
+ isDeleted: t.isDeleted === true,
1516
+ deletedAt: typeof t.deletedAt === "number" ? t.deletedAt : null,
1517
+ deletedBy: isObject(t.deletedBy) ? normalizeActor(t.deletedBy) : null,
1518
+ createdAt: createdAt,
1519
+ createdBy: createdBy,
1520
+ updatedAt: typeof t.updatedAt === "number" ? t.updatedAt : createdAt,
1521
+ updatedBy: isObject(t.updatedBy) ? normalizeActor(t.updatedBy) : createdBy,
1522
+ // SM-170 (Card-Aging): when the ticket last ENTERED its current status.
1523
+ // Distinct from updatedAt (which bumps on any edit) — this only resets on
1524
+ // an actual status transition. Legacy tickets fall back to createdAt.
1525
+ statusEnteredAt: typeof t.statusEnteredAt === "number" ? t.statusEnteredAt : createdAt,
1526
+ version: typeof t.version === "number" ? t.version : 1
1527
+ };
1528
+ }
1529
+
1530
+ function normalizeRelease(r) {
1531
+ r = r || {};
1532
+ const createdBy = isObject(r.createdBy) ? normalizeActor(r.createdBy) : { type: "human", id: "unknown", name: "Unknown" };
1533
+ const createdAt = typeof r.createdAt === "number" ? r.createdAt : now();
1534
+ return {
1535
+ id: typeof r.id === "string" ? r.id : uid("r-"),
1536
+ projectId: typeof r.projectId === "string" ? r.projectId : "",
1537
+ name: typeof r.name === "string" ? r.name : "",
1538
+ description: typeof r.description === "string" ? r.description : "",
1539
+ startDate: typeof r.startDate === "string" || typeof r.startDate === "number" ? r.startDate : null,
1540
+ endDate: typeof r.endDate === "string" || typeof r.endDate === "number" ? r.endDate : null,
1541
+ status: DEFAULT_RELEASE_STATUSES.indexOf(r.status) >= 0 ? r.status : "planning",
1542
+ sortOrder: typeof r.sortOrder === "number" ? r.sortOrder : 0,
1543
+ isDeleted: r.isDeleted === true,
1544
+ deletedAt: typeof r.deletedAt === "number" ? r.deletedAt : null,
1545
+ deletedBy: isObject(r.deletedBy) ? normalizeActor(r.deletedBy) : null,
1546
+ createdAt: createdAt,
1547
+ createdBy: createdBy,
1548
+ updatedAt: typeof r.updatedAt === "number" ? r.updatedAt : createdAt,
1549
+ updatedBy: isObject(r.updatedBy) ? normalizeActor(r.updatedBy) : createdBy,
1550
+ version: typeof r.version === "number" ? r.version : 1
1551
+ };
1552
+ }
1553
+
1554
+ function normalizeProcessStep(s) {
1555
+ s = s || {};
1556
+ const createdBy = isObject(s.createdBy) ? normalizeActor(s.createdBy) : { type: "human", id: "unknown", name: "Unknown" };
1557
+ const createdAt = typeof s.createdAt === "number" ? s.createdAt : now();
1558
+ return {
1559
+ id: typeof s.id === "string" ? s.id : uid("ps-"),
1560
+ projectId: typeof s.projectId === "string" ? s.projectId : "",
1561
+ name: typeof s.name === "string" ? s.name : "",
1562
+ description: typeof s.description === "string" ? s.description : "",
1563
+ epicId: typeof s.epicId === "string" ? s.epicId : null,
1564
+ sortOrder: typeof s.sortOrder === "number" ? s.sortOrder : 0,
1565
+ isDeleted: s.isDeleted === true,
1566
+ deletedAt: typeof s.deletedAt === "number" ? s.deletedAt : null,
1567
+ deletedBy: isObject(s.deletedBy) ? normalizeActor(s.deletedBy) : null,
1568
+ createdAt: createdAt,
1569
+ createdBy: createdBy,
1570
+ updatedAt: typeof s.updatedAt === "number" ? s.updatedAt : createdAt,
1571
+ updatedBy: isObject(s.updatedBy) ? normalizeActor(s.updatedBy) : createdBy,
1572
+ version: typeof s.version === "number" ? s.version : 1
1573
+ };
1574
+ }
1575
+
1576
+ function normalizeSnapshot(snap) {
1577
+ snap = snap || {};
1578
+ const out = {
1579
+ version: SCHEMA_VERSION,
1580
+ project: normalizeProject(snap.project),
1581
+ tickets: Array.isArray(snap.tickets) ? snap.tickets.map(normalizeTicket) : [],
1582
+ releases: Array.isArray(snap.releases) ? snap.releases.map(normalizeRelease) : [],
1583
+ processSteps: Array.isArray(snap.processSteps) ? snap.processSteps.map(normalizeProcessStep) : []
1584
+ };
1585
+ // SM-44: post-process tickets — drop ticket.links whose targetTicketId no
1586
+ // longer exists in the snapshot (stale-target cleanup). This keeps the
1587
+ // model self-consistent even after a referenced ticket is hard-deleted.
1588
+ // Soft-deleted (isDeleted=true) tickets STILL count as valid targets so
1589
+ // restoring them preserves their links.
1590
+ const validIds = new Set(out.tickets.map(t => t.id));
1591
+ for (const t of out.tickets) {
1592
+ if (t.links && t.links.length > 0) {
1593
+ t.links = t.links.filter(l => validIds.has(l.targetTicketId));
1594
+ }
1595
+ }
1596
+ // SM-52: migrate legacy ticket.position.epicId → outgoing contains-link
1597
+ // from the parent epic. Idempotent: if the link already exists, just
1598
+ // clear the legacy field.
1599
+ migrateEpicContainsLinks(out);
1600
+ // SM-78: defensive cleanup — null any ticket.position.processStepId or
1601
+ // releaseId that no longer references a live (non-soft-deleted) entry.
1602
+ // Without this, stale positions left over from a deleted PS/Release make
1603
+ // the ticket invisible on the Map (no matching cell) while still visible
1604
+ // on the Kanban (status-based) — a confusing inconsistency. Runs BEFORE
1605
+ // SM-67's sync-pass so that a stale epic position doesn't get cascaded
1606
+ // to its contained stories. Idempotent.
1607
+ pruneStaleContainerRefs(out);
1608
+ // SM-67: sync-pass — any contained story whose releaseId/processStepId
1609
+ // diverged from its container epic gets pulled back into alignment.
1610
+ // Idempotent: aligned stories are untouched.
1611
+ syncContainedStoryPositions(out);
1612
+ // SM-236: derive every epic's status from its contained stories. Runs after
1613
+ // the contains-link migration + position sync so the children are final.
1614
+ // This is the MIGRATION pass for legacy data (epics manually walked to a
1615
+ // status that no longer matches their stories) and the safety net behind
1616
+ // the per-op recompute calls (the SM-220 invariance test is the
1617
+ // completeness check that no mutating op forgets to call it).
1618
+ recomputeEpicStatuses(out);
1619
+ // SM-93: annotate every test-definition with its derivedHealth, based on
1620
+ // the link-graph + execution history. Runs LAST so all link / position /
1621
+ // execution data is in its final form.
1622
+ _annotateDerivedHealth(out);
1623
+ return out;
1624
+ }
1625
+
1626
+ /**
1627
+ * SM-78: defensive pass that clears stale ticket.position.processStepId /
1628
+ * releaseId references. A reference is "stale" when the target Release /
1629
+ * ProcessStep is either hard-removed from the snapshot or soft-deleted
1630
+ * (isDeleted=true). Cleared fields land in the Backlog, which is at least
1631
+ * VISIBLE — better than the silent disappearance from the Map cells.
1632
+ * Idempotent.
1633
+ */
1634
+ function pruneStaleContainerRefs(snap) {
1635
+ const tickets = snap.tickets || [];
1636
+ if (!tickets.length) return;
1637
+ const liveReleaseIds = new Set();
1638
+ for (const r of (snap.releases || [])) {
1639
+ if (r && r.id && !r.isDeleted) liveReleaseIds.add(r.id);
1640
+ }
1641
+ const livePsIds = new Set();
1642
+ for (const ps of (snap.processSteps || [])) {
1643
+ if (ps && ps.id && !ps.isDeleted) livePsIds.add(ps.id);
1644
+ }
1645
+ for (const t of tickets) {
1646
+ const p = t.position;
1647
+ if (!p) continue;
1648
+ const curRel = p.releaseId || null;
1649
+ const curPs = p.processStepId || null;
1650
+ const newRel = (curRel && !liveReleaseIds.has(curRel)) ? null : curRel;
1651
+ const newPs = (curPs && !livePsIds.has(curPs)) ? null : curPs;
1652
+ if (newRel === curRel && newPs === curPs) continue;
1653
+ t.position = normalizePosition(Object.assign({}, p, {
1654
+ releaseId: newRel,
1655
+ processStepId: newPs
1656
+ }));
1657
+ }
1658
+ }
1659
+
1660
+ /**
1661
+ * SM-67: idempotent pass that aligns every contained story's release+
1662
+ * processStep to its container epic's. Runs after migration so legacy
1663
+ * snapshots with mis-aligned positions get repaired on load.
1664
+ */
1665
+ function syncContainedStoryPositions(snap) {
1666
+ const tickets = snap.tickets || [];
1667
+ if (!tickets.length) return;
1668
+ const byId = new Map(tickets.map(t => [t.id, t]));
1669
+ for (const epic of tickets) {
1670
+ if (epic.type !== "epic" || epic.isDeleted || !epic.links || !epic.links.length) continue;
1671
+ if (!epic.position) continue;
1672
+ const epicRel = epic.position.releaseId || null;
1673
+ const epicPs = epic.position.processStepId || null;
1674
+ for (const l of epic.links) {
1675
+ const lt = l.linkTypeId || l.type;
1676
+ if (lt !== CONTAINS_LINK_TYPE_ID) continue;
1677
+ const story = byId.get(l.targetTicketId);
1678
+ if (!story || story.isDeleted) continue;
1679
+ const sp = story.position || {};
1680
+ if ((sp.releaseId || null) === epicRel && (sp.processStepId || null) === epicPs) continue;
1681
+ story.position = normalizePosition(Object.assign({}, sp, {
1682
+ releaseId: epicRel,
1683
+ processStepId: epicPs
1684
+ }));
1685
+ }
1686
+ }
1687
+ }
1688
+
1689
+ // SM-52 helpers ------------------------------------------------------------
1690
+
1691
+ /**
1692
+ * Migrate every ticket carrying a non-null `position.epicId` to the canonical
1693
+ * representation: an outgoing `contains` link from the epic, with the legacy
1694
+ * field cleared. Idempotent — running multiple times on an already-migrated
1695
+ * snapshot is a no-op (the link check prevents duplicates).
1696
+ */
1697
+ function migrateEpicContainsLinks(snap) {
1698
+ const tickets = snap.tickets || [];
1699
+ if (!tickets.length) return;
1700
+ const byId = new Map(tickets.map(t => [t.id, t]));
1701
+ for (const t of tickets) {
1702
+ const epicId = t.position && t.position.epicId;
1703
+ if (!epicId) continue;
1704
+ const epic = byId.get(epicId);
1705
+ if (epic && epic.id !== t.id && epic.type === "epic") {
1706
+ _ensureContainsLink(snap, epic.id, t.id, t.createdBy);
1707
+ }
1708
+ // Always clear — the link is canonical; if the epic doesn't exist
1709
+ // anymore we drop the legacy pointer silently.
1710
+ t.position = Object.assign({}, t.position, { epicId: null });
1711
+ }
1712
+ }
1713
+
1714
+ /**
1715
+ * Ensure that `epicId` has an outgoing `contains` link to `targetId`. No-op
1716
+ * if the link already exists. Returns true if added, false if pre-existing.
1717
+ */
1718
+ function _ensureContainsLink(snap, epicId, targetId, actor) {
1719
+ if (!epicId || !targetId || epicId === targetId) return false;
1720
+ // Read first (cheap), COW only when we actually add the link (SM-240).
1721
+ const probe = (snap.tickets || []).find(t => t.id === epicId);
1722
+ if (!probe) return false;
1723
+ for (const l of (probe.links || [])) {
1724
+ const lt = l.linkTypeId || l.type;
1725
+ if (lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === targetId) return false;
1726
+ }
1727
+ const epic = findTicket(snap, epicId); // COW-aware on cow snapshots
1728
+ epic.links = epic.links || [];
1729
+ const a = isObject(actor) ? normalizeActor(actor) : { type: "system", id: "sm-52", name: "SM-52 migration" };
1730
+ epic.links.push(normalizeLink({
1731
+ linkTypeId: CONTAINS_LINK_TYPE_ID,
1732
+ targetTicketId: targetId,
1733
+ createdBy: a
1734
+ }));
1735
+ return true;
1736
+ }
1737
+
1738
+ /**
1739
+ * Strip any incoming `contains` link to `ticketId`. Used when re-parenting
1740
+ * a ticket or removing it from its epic container.
1741
+ *
1742
+ * SM-166: when `actor` is supplied, every epic that actually loses a link is
1743
+ * bumpAudit'd — a containment change is a real edit to that epic and must be
1744
+ * versioned + visible (previously it was silent: no version bump on the epic).
1745
+ */
1746
+ function _removeContainsLinksTo(snap, ticketId, actor) {
1747
+ // Iterate a snapshot of the array — cowTicket swaps entries in place.
1748
+ for (const t of (snap.tickets || []).slice()) {
1749
+ if (t.type !== "epic" || !t.links || !t.links.length) continue;
1750
+ const hasLink = t.links.some(l => {
1751
+ const lt = l.linkTypeId || l.type;
1752
+ return lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === ticketId;
1753
+ });
1754
+ if (!hasLink) continue;
1755
+ const epic = findTicket(snap, t.id); // COW-aware (SM-240)
1756
+ epic.links = epic.links.filter(l => {
1757
+ const lt = l.linkTypeId || l.type;
1758
+ return !(lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === ticketId);
1759
+ });
1760
+ if (actor) bumpAudit(epic, actor);
1761
+ }
1762
+ }
1763
+
1764
+ /**
1765
+ * Re-parent `ticketId` so its container is `newEpicId` (or none, if null).
1766
+ * Strips any existing inbound contains-link first to guarantee uniqueness.
1767
+ * SM-166: bumpAudit fires on both the old container (via _removeContainsLinksTo)
1768
+ * and the new container (here) so the re-parent is reflected in their versions.
1769
+ */
1770
+ function _setContainerEpic(snap, ticketId, newEpicId, actor) {
1771
+ _removeContainsLinksTo(snap, ticketId, actor);
1772
+ if (newEpicId) {
1773
+ const added = _ensureContainsLink(snap, newEpicId, ticketId, actor);
1774
+ if (added && actor) {
1775
+ const epic = findTicket(snap, newEpicId); // COW-aware (SM-240)
1776
+ if (epic) bumpAudit(epic, actor);
1777
+ }
1778
+ }
1779
+ }
1780
+
1781
+ /**
1782
+ * Map<storyId, epicId> — which epic currently contains each story (via
1783
+ * inbound contains-link). Used by read-side helpers.
1784
+ */
1785
+ function _containerEpicByStoryId(snap) {
1786
+ const map = new Map();
1787
+ for (const t of (snap.tickets || [])) {
1788
+ if (t.type !== "epic" || t.isDeleted) continue;
1789
+ if (!t.links || !t.links.length) continue;
1790
+ for (const l of t.links) {
1791
+ const lt = l.linkTypeId || l.type;
1792
+ if (lt === CONTAINS_LINK_TYPE_ID) map.set(l.targetTicketId, t.id);
1793
+ }
1794
+ }
1795
+ return map;
1796
+ }
1797
+
1798
+ /**
1799
+ * Public: which epic currently contains the given ticket, via outbound
1800
+ * `contains` link? Returns the epic-id or `null`. SM-52: this is the
1801
+ * canonical source for the Epic-parent — `ticket.position.epicId` is
1802
+ * stripped to null on every write, so do NOT read it for display either.
1803
+ *
1804
+ * Bug context (SM-77-followup): the Epic-dropdown in the ticket-detail
1805
+ * modal used to read `ticket.position.epicId` directly. That value is
1806
+ * always null after SM-52, so the dropdown showed "— none —" even when
1807
+ * the ticket was contained. Worse, the no-op-save path then persisted
1808
+ * the null choice and dropped the contains-link silently. Always use
1809
+ * this helper to display + diff the current container.
1810
+ */
1811
+ function containerEpicIdOf(snap, ticketId) {
1812
+ if (!snap || !ticketId) return null;
1813
+ for (const t of (snap.tickets || [])) {
1814
+ if (t.type !== "epic" || t.isDeleted || !t.links) continue;
1815
+ for (const l of t.links) {
1816
+ const lt = l.linkTypeId || l.type;
1817
+ if (lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === ticketId) return t.id;
1818
+ }
1819
+ }
1820
+ return null;
1821
+ }
1822
+
1823
+ // SM-67 helpers ------------------------------------------------------------
1824
+
1825
+ /**
1826
+ * SM-67: a contained story inherits its containerEpic's releaseId+processStepId.
1827
+ * Given an input position-patch and a target container-epic-id, return a
1828
+ * position object where releaseId+processStepId are forced to the epic's
1829
+ * values (other fields — sortOrder — kept from input). If the epic doesn't
1830
+ * exist or has no position, the input is returned unchanged.
1831
+ *
1832
+ * Pure: does not mutate `inputPos`.
1833
+ */
1834
+ function _inheritPositionFromEpic(snap, containerEpicId, inputPos) {
1835
+ const pos = Object.assign({}, inputPos || {});
1836
+ if (!containerEpicId) return pos;
1837
+ const epic = (snap.tickets || []).find(t =>
1838
+ t.id === containerEpicId && t.type === "epic" && !t.isDeleted);
1839
+ if (!epic || !epic.position) return pos;
1840
+ pos.releaseId = epic.position.releaseId || null;
1841
+ pos.processStepId = epic.position.processStepId || null;
1842
+ return pos;
1843
+ }
1844
+
1845
+ /**
1846
+ * SM-67: when an epic's release/processStep changes, every story contained
1847
+ * via `contains` link cascades to the new position (release+processStep
1848
+ * only; sortOrder is preserved per story). bumpAudit fires for each
1849
+ * touched story so the audit trail reflects the cascade.
1850
+ */
1851
+ function _cascadeEpicPositionToContainedStories(snap, epicId, actor) {
1852
+ const epic = (snap.tickets || []).find(t =>
1853
+ t.id === epicId && t.type === "epic" && !t.isDeleted);
1854
+ if (!epic || !epic.position || !epic.links || !epic.links.length) return;
1855
+ const epicRel = epic.position.releaseId || null;
1856
+ const epicPs = epic.position.processStepId || null;
1857
+ for (const l of epic.links) {
1858
+ const lt = l.linkTypeId || l.type;
1859
+ if (lt !== CONTAINS_LINK_TYPE_ID) continue;
1860
+ const probe = (snap.tickets || []).find(t => t.id === l.targetTicketId);
1861
+ if (!probe || probe.isDeleted) continue;
1862
+ const sp = probe.position || {};
1863
+ if ((sp.releaseId || null) === epicRel && (sp.processStepId || null) === epicPs) continue;
1864
+ const story = findTicket(snap, probe.id); // COW-aware (SM-240)
1865
+ story.position = normalizePosition(Object.assign({}, story.position || {}, {
1866
+ releaseId: epicRel,
1867
+ processStepId: epicPs
1868
+ }));
1869
+ bumpAudit(story, actor);
1870
+ }
1871
+ }
1872
+
1873
+ // ---------------------------------------------------------------------------
1874
+ // resolveDefinitions + buildTicketChecklists
1875
+ // ---------------------------------------------------------------------------
1876
+
1877
+ function resolveBlock(block, ticketType) {
1878
+ block = block || { global: [], byType: {} };
1879
+ const byType = (block.byType && block.byType[ticketType]) || null;
1880
+ if (byType && Array.isArray(byType.overridden)) {
1881
+ return byType.overridden.map(it => Object.assign({}, it));
1882
+ }
1883
+ const list = (block.global || []).map(it => Object.assign({}, it));
1884
+ if (byType && Array.isArray(byType.appended)) {
1885
+ for (const it of byType.appended) list.push(Object.assign({}, it));
1886
+ }
1887
+ return list;
1888
+ }
1889
+
1890
+ function resolveDefinitions(definitions, ticketType) {
1891
+ const defs = normalizeDefinitions(definitions);
1892
+ return {
1893
+ ready: resolveBlock(defs.ready, ticketType),
1894
+ done: resolveBlock(defs.done, ticketType)
1895
+ };
1896
+ }
1897
+
1898
+ function freezeChecklist(items) {
1899
+ return {
1900
+ items: items.map(it => ({
1901
+ id: it.id,
1902
+ label: it.label,
1903
+ required: it.required !== false,
1904
+ checked: false,
1905
+ checkedAt: null,
1906
+ checkedBy: null
1907
+ }))
1908
+ };
1909
+ }
1910
+
1911
+ function buildTicketChecklists(project, ticketPartial) {
1912
+ const resolved = resolveDefinitions(project.definitions, (ticketPartial && ticketPartial.type) || "user-story");
1913
+ return {
1914
+ definitionOfReady: freezeChecklist(resolved.ready),
1915
+ definitionOfDone: freezeChecklist(resolved.done)
1916
+ };
1917
+ }
1918
+
1919
+ // ---------------------------------------------------------------------------
1920
+ // ops — pure (snapshot, args, actor) → newSnapshot
1921
+ // ---------------------------------------------------------------------------
1922
+
1923
+ function bumpAudit(entity, actor) {
1924
+ const a = normalizeActor(actor);
1925
+ entity.updatedAt = now();
1926
+ entity.updatedBy = a;
1927
+ entity.version = (entity.version || 1) + 1;
1928
+ }
1929
+
1930
+ // SM-170 (Card-Aging): set a ticket's status, resetting `statusEnteredAt` ONLY
1931
+ // when the status actually changes. A no-op transition (same status) keeps the
1932
+ // original entry time, so re-saving a ticket doesn't reset its aging clock.
1933
+ function _setTicketStatus(t, newStatus) {
1934
+ if (t.status !== newStatus) t.statusEnteredAt = now();
1935
+ t.status = newStatus;
1936
+ }
1937
+
1938
+ // SM-240: ops resolve their mutation target through findTicket. On a COW
1939
+ // snapshot (cowSnap output) this returns the PRIVATE clone of the ticket —
1940
+ // the one central seam that converts nearly every op to copy-on-write. On a
1941
+ // plain snapshot (read paths, normalize) it behaves exactly as before.
1942
+ function findTicket(snap, ticketId) {
1943
+ if (snap && snap.__cowFresh) return cowTicket(snap, ticketId) || undefined;
1944
+ return snap.tickets.find(t => t.id === ticketId);
1945
+ }
1946
+
1947
+ // SM-111 (A2): a PUBLISHED test-definition's spec is frozen. Structural
1948
+ // spec edits (steps add/update/remove/reorder, prerequisites add/update/
1949
+ // remove) throw DEFINITION_FROZEN so neither REST nor MCP can mutate a
1950
+ // locked spec out from under the executions cloned from it. Runtime state
1951
+ // toggles (prereq check/uncheck) are intentionally NOT frozen — they don't
1952
+ // change the spec. Drafts (lifecycle !== "published") edit freely.
1953
+ function assertSpecEditable(ticket) {
1954
+ if (ticket && ticket.type === "test-definition" && ticket.lifecycle === "published") {
1955
+ const e = new Error("test-definition spec is frozen (published): " + (ticket.ticketKey || ticket.id));
1956
+ e.statusCode = 409;
1957
+ e.kind = "DEFINITION_FROZEN";
1958
+ throw e;
1959
+ }
1960
+ }
1961
+
1962
+ // ---------------------------------------------------------------------------
1963
+ // SM-236 — Epic status is DERIVED (roll-up) from the categories of the epic's
1964
+ // contained board-work-item children. It is NEVER set manually (SM-237 locks
1965
+ // the manual paths). Pure + deterministic: aggregates over the project
1966
+ // workflow's status CATEGORIES (todo/doing/blocked/done) so custom workflows
1967
+ // work without hard-coded status ids.
1968
+ // ---------------------------------------------------------------------------
1969
+
1970
+ // Build id → category lookup across the base workflow AND every per-type
1971
+ // workflow status list, so a child carrying a per-type status still resolves.
1972
+ // Ids absent from the workflow fall back to the kebab-id heuristic.
1973
+ function _statusCategoryLookup(project) {
1974
+ const map = new Map();
1975
+ const add = (list) => {
1976
+ for (const s of (list || [])) {
1977
+ const n = normalizeStatusItem(s);
1978
+ if (n && !map.has(n.id)) map.set(n.id, n.category);
1979
+ }
1980
+ };
1981
+ const wf = (project && project.workflow) || STORYMAPPER_DEFAULT_WORKFLOW;
1982
+ add(wf.statuses && wf.statuses.length ? wf.statuses : STORYMAPPER_DEFAULT_WORKFLOW.statuses);
1983
+ if (wf.byType) for (const k of Object.keys(wf.byType)) add(wf.byType[k] && wf.byType[k].statuses);
1984
+ return (id) => (map.has(id) ? map.get(id) : defaultCategoryForStatusId(id));
1985
+ }
1986
+
1987
+ // SM-242: the first status id of a given category in the project's base
1988
+ // workflow (used by cancelTicket to resolve the cancelled-category status).
1989
+ function firstStatusOfCategory(project, category) {
1990
+ const wf = getWorkflowForType(project, null);
1991
+ const statuses = (wf.statuses && wf.statuses.length) ? wf.statuses : STORYMAPPER_DEFAULT_WORKFLOW.statuses;
1992
+ const s = statuses.find(x => normalizeStatusItem(x).category === category);
1993
+ return s ? (typeof s === "string" ? s : s.id) : null;
1994
+ }
1995
+
1996
+ // SM-242: category of a status id (custom-workflow-safe, heuristic fallback).
1997
+ function statusCategoryOf(project, statusId) {
1998
+ return _statusCategoryLookup(project)(statusId);
1999
+ }
2000
+
2001
+ // SM-242: a status is TERMINAL when its category is done OR cancelled — i.e. the
2002
+ // work item is closed (successfully or deliberately dropped). Replaces hardcoded
2003
+ // `status === "done"` checks so cancelled items don't count as open.
2004
+ function isTerminalStatus(project, statusId) {
2005
+ const c = statusCategoryOf(project, statusId);
2006
+ return c === "done" || c === "cancelled";
2007
+ }
2008
+
2009
+ // Counts of contained board-work-item children by aggregated category.
2010
+ // blocked is folded into "doing" (work is underway, just stuck).
2011
+ // NOTE (by design, SM-236): children resolve via `storiesInEpic`, which counts
2012
+ // only board-work-items — nested epics and spec types do NOT count. The model
2013
+ // is 3-level (Cell → Epic → Story); an epic that contains ONLY sub-epics has
2014
+ // total=0 and therefore derives to the first todo status (backlog). Epic-of-
2015
+ // epics roll-up is intentionally unsupported.
2016
+ function epicChildStats(snap, epicId) {
2017
+ const children = tickets.storiesInEpic(snap || { tickets: [] }, epicId);
2018
+ const catOf = _statusCategoryLookup(snap && snap.project);
2019
+ let done = 0, doing = 0, todo = 0, cancelled = 0;
2020
+ for (const c of children) {
2021
+ const cat = catOf(c.status);
2022
+ if (cat === "done") done++;
2023
+ else if (cat === "cancelled") cancelled++; // SM-243: terminal, not open work
2024
+ else if (cat === "doing" || cat === "blocked") doing++;
2025
+ else todo++;
2026
+ }
2027
+ return { total: children.length, done, doing, todo, cancelled };
2028
+ }
2029
+
2030
+ // Pure: the status an epic SHOULD have given its children.
2031
+ // no children → first todo status (unstarted plan), UNLESS the epic
2032
+ // was deliberately cancelled (empty-epic cancel, SM-243)
2033
+ // all children cancelled → first cancelled status (SM-243)
2034
+ // all terminal, ≥1 done → first done status (done dominates cancelled)
2035
+ // any active doing/blocked/done → first doing status (work is live)
2036
+ // else (all active todo) → first todo status
2037
+ // "active" = children NOT in the cancelled category — cancelled is scope
2038
+ // reduction and never counts as open work. Defensive: when the workflow lacks a
2039
+ // status of the needed category, the epic's current status is preserved.
2040
+ function deriveEpicStatus(snap, epicId) {
2041
+ const epic = ((snap && snap.tickets) || []).find(t => t && t.id === epicId);
2042
+ if (!epic) return null;
2043
+ const wf = getWorkflowForType(snap && snap.project, "epic");
2044
+ const statuses = (wf.statuses && wf.statuses.length) ? wf.statuses : STORYMAPPER_DEFAULT_WORKFLOW.statuses;
2045
+ const firstOf = (cat) => {
2046
+ const s = statuses.find(x => normalizeStatusItem(x).category === cat);
2047
+ return s ? (typeof s === "string" ? s : s.id) : null;
2048
+ };
2049
+ const stats = epicChildStats(snap, epicId);
2050
+ const active = stats.total - stats.cancelled; // SM-243: cancelled ≠ open
2051
+ let target;
2052
+ if (stats.total === 0) {
2053
+ // Empty epic: unstarted plan, unless it was deliberately cancelled.
2054
+ target = (statusCategoryOf(snap && snap.project, epic.status) === "cancelled")
2055
+ ? epic.status : firstOf("todo");
2056
+ } else if (active === 0) {
2057
+ // every child cancelled → cancelled. NEVER fall back to done here (an
2058
+ // all-cancelled epic is not "done"); keep the current status if the
2059
+ // workflow can't resolve a cancelled-category status (defensive).
2060
+ target = firstOf("cancelled") || epic.status;
2061
+ } else if (stats.done === active) {
2062
+ target = firstOf("done"); // all non-cancelled done
2063
+ } else if (stats.doing > 0 || stats.done > 0) {
2064
+ target = firstOf("doing");
2065
+ } else {
2066
+ target = firstOf("todo");
2067
+ }
2068
+ return target || epic.status;
2069
+ }
2070
+
2071
+ // Recompute every (non-deleted) epic's status from its children. Only epics
2072
+ // whose derived status actually differs are COW-cloned + mutated, so untouched
2073
+ // epics stay reference-equal (SM-240 structural sharing). Sets ONLY .status —
2074
+ // the derivation is not an actor action, so it never bumps version/updatedBy/
2075
+ // statusEnteredAt (keeps normalize-invariance + audit-noise-free). Called at
2076
+ // the end of every op that can change child status/containment, and as the
2077
+ // last pass of normalizeSnapshot (migration + safety net).
2078
+ function recomputeEpicStatuses(s) {
2079
+ if (!s || !Array.isArray(s.tickets)) return s;
2080
+ for (const t of s.tickets) {
2081
+ if (!t || t.isDeleted || t.type !== "epic") continue;
2082
+ const derived = deriveEpicStatus(s, t.id);
2083
+ if (derived && derived !== t.status) {
2084
+ const fresh = cowTicket(s, t.id);
2085
+ if (fresh) fresh.status = derived;
2086
+ }
2087
+ }
2088
+ return s;
2089
+ }
2090
+
2091
+ // SM-239 — release progress over its NON-EPIC, non-spec work items (exactly the
2092
+ // set the Kanban renders, so the count never includes tickets you can't see —
2093
+ // that was the root of the SM-226 confusion). `done` counts items whose status
2094
+ // is in the done CATEGORY (custom-workflow-safe, not the hard-coded "done" id).
2095
+ // Returns { done, total, complete } — complete is false for an empty release.
2096
+ function releaseProgress(snap, releaseId) {
2097
+ const catOf = _statusCategoryLookup(snap && snap.project);
2098
+ let total = 0, done = 0;
2099
+ for (const t of ((snap && snap.tickets) || [])) {
2100
+ if (!t || t.isDeleted || !isBoardWorkItem(t.type)) continue;
2101
+ if (!t.position || t.position.releaseId !== releaseId) continue;
2102
+ const cat = catOf(t.status);
2103
+ if (cat === "cancelled") continue; // SM-244: cancelled = scope reduction, out of X AND Y
2104
+ total++;
2105
+ if (cat === "done") done++;
2106
+ }
2107
+ return { done, total, complete: total > 0 && done === total };
2108
+ }
2109
+
2110
+ const ops = {
2111
+
2112
+ createTicket(snap, partial, actor) {
2113
+ const s = cowSnap(snap);
2114
+ s.project = normalizeProject(s.project);
2115
+ s.project.ticketCounter = (s.project.ticketCounter || 0) + 1;
2116
+ const ticketKey = s.project.ticketPrefix + "-" + s.project.ticketCounter;
2117
+ const checklists = buildTicketChecklists(s.project, partial);
2118
+ const a = normalizeActor(actor);
2119
+
2120
+ // Auto-Epic-Assignment: ein Story-Ticket (kein Epic), das in eine
2121
+ // (releaseId, processStepId)-Zelle gelegt wird, die GENAU EIN Epic
2122
+ // enthält, bekommt automatisch dieses Epic als Container. Bei 0 oder
2123
+ // ≥2 Epics bleibt der Container leer (zu ambig). Explizit gesetzter
2124
+ // epicId-Wert im partial wird respektiert. Epics selbst bekommen
2125
+ // niemals einen Container.
2126
+ // SM-52: containment is expressed as a `contains` link from the epic;
2127
+ // position.epicId is the legacy input shape but never persisted.
2128
+ // SM-196: a spec object (requirement/spec-module) is never contained by an
2129
+ // epic — it lives in a spec module. Block BOTH explicit and inferred epic
2130
+ // containment for spec types (defense-in-depth: the MCP surface could
2131
+ // otherwise pass a position that triggers the auto-assign below).
2132
+ const isSpecNew = isSpecType(partial && partial.type);
2133
+ let inferredEpicId = null;
2134
+ const inputPos = partial && partial.position;
2135
+ const explicitEpicId = (!isSpecNew && inputPos && typeof inputPos.epicId === "string") ? inputPos.epicId : null;
2136
+ if (!explicitEpicId && !isSpecNew && partial && partial.type !== "epic"
2137
+ && inputPos && inputPos.releaseId && inputPos.processStepId) {
2138
+ const candidates = (s.tickets || []).filter(t =>
2139
+ !t.isDeleted
2140
+ && t.type === "epic"
2141
+ && t.position
2142
+ && t.position.releaseId === inputPos.releaseId
2143
+ && t.position.processStepId === inputPos.processStepId);
2144
+ if (candidates.length === 1) inferredEpicId = candidates[0].id;
2145
+ }
2146
+ const containerEpicId = explicitEpicId || inferredEpicId || null;
2147
+
2148
+ // The merged partial may still carry epicId in its position; we strip it
2149
+ // BEFORE normalizeTicket so the persisted shape never holds the legacy
2150
+ // pointer (the contains-link gets added at the end).
2151
+ let mergedPartial = partial;
2152
+
2153
+ // SM-67: when the new ticket is contained by an epic, its release+
2154
+ // processStep are inherited from the epic — not from the input. Apply
2155
+ // inheritance to the merged partial's position BEFORE the auto-sortOrder
2156
+ // peer filter, so peer grouping matches the values that will actually
2157
+ // be persisted.
2158
+ if (partial && partial.type !== "epic" && containerEpicId) {
2159
+ const inheritedPos = _inheritPositionFromEpic(s,
2160
+ containerEpicId, mergedPartial && mergedPartial.position || {});
2161
+ mergedPartial = Object.assign({}, mergedPartial, { position: inheritedPos });
2162
+ }
2163
+
2164
+ // Auto-sortOrder: neue Tickets landen IMMER am Ende ihrer Peer-Liste,
2165
+ // egal ob in einer Cell, unter einem Epic, oder als Orphan im Backlog.
2166
+ // SM-52: peers are now grouped by "same release × processStep × container-
2167
+ // epic" — and container-epic is read from the contains-link, not from
2168
+ // position.epicId. For Orphans the container is null on both sides.
2169
+ const containerByStory = _containerEpicByStoryId(s);
2170
+ const mergedPos = mergedPartial && mergedPartial.position;
2171
+ const hasNoExplicitSort = !mergedPos || typeof mergedPos.sortOrder !== "number" || mergedPos.sortOrder === 0;
2172
+ if (hasNoExplicitSort) {
2173
+ const peerRel = (mergedPos && mergedPos.releaseId) || null;
2174
+ const peerPs = (mergedPos && mergedPos.processStepId) || null;
2175
+ const peerEpic = containerEpicId;
2176
+ const peers = (s.tickets || []).filter(tk => {
2177
+ if (tk.isDeleted) return false;
2178
+ const tp = tk.position || {};
2179
+ const tkEpic = (tk.type === "epic") ? null : (containerByStory.get(tk.id) || null);
2180
+ return (tp.releaseId || null) === peerRel
2181
+ && (tp.processStepId || null) === peerPs
2182
+ && tkEpic === peerEpic;
2183
+ });
2184
+ const maxSort = peers.reduce((m, tk) => Math.max(m, (tk.position && tk.position.sortOrder) || 0), -1);
2185
+ mergedPartial = Object.assign({}, mergedPartial, {
2186
+ position: Object.assign({}, mergedPos || {}, { sortOrder: maxSort + 1 })
2187
+ });
2188
+ }
2189
+
2190
+ // Allow the caller (UI / MCP) to override the auto-frozen checklists by
2191
+ // passing their own `definitionOfReady`/`definitionOfDone` in the partial.
2192
+ // Per-ticket configuration: items can be customized at create time
2193
+ // (E19-followup). Missing → fall back to the project-default freeze.
2194
+ const explicitDor = mergedPartial && mergedPartial.definitionOfReady;
2195
+ const explicitDod = mergedPartial && mergedPartial.definitionOfDone;
2196
+ // SM-52: position.epicId is stripped here — never persisted on the ticket.
2197
+ // SM-67 inheritance was already applied above (mergedPartial.position
2198
+ // carries the epic's release+processStep if there's a container).
2199
+ const cleanedPos = Object.assign({}, mergedPartial && mergedPartial.position || {}, { epicId: null });
2200
+ const t = normalizeTicket(Object.assign({}, mergedPartial, {
2201
+ id: uid("t-"),
2202
+ projectId: s.project.id,
2203
+ ticketKey: ticketKey,
2204
+ position: cleanedPos,
2205
+ definitionOfReady: explicitDor ? normalizeChecklist(explicitDor) : checklists.definitionOfReady,
2206
+ definitionOfDone: explicitDod ? normalizeChecklist(explicitDod) : checklists.definitionOfDone,
2207
+ createdBy: a,
2208
+ updatedBy: a,
2209
+ createdAt: now(),
2210
+ updatedAt: now(),
2211
+ version: 1
2212
+ }));
2213
+ s.tickets.push(markCowFresh(s, t));
2214
+ // SM-52: add the canonical contains-link if the new ticket has a parent epic.
2215
+ if (containerEpicId) _setContainerEpic(s, t.id, containerEpicId, a);
2216
+ bumpAudit(s.project, actor);
2217
+ recomputeEpicStatuses(s); // SM-236
2218
+ return s;
2219
+ },
2220
+
2221
+ updateTicket(snap, ticketId, patch, actor) {
2222
+ const s = cowSnap(snap);
2223
+ let t = findTicket(s, ticketId);
2224
+ if (!t) return s;
2225
+ // SM-237: an epic's status is derived (roll-up) — reject an explicit
2226
+ // status patch rather than silently ignoring it. The check uses the
2227
+ // FINAL type (a patch may convert away from epic, which is allowed).
2228
+ if (patch && Object.prototype.hasOwnProperty.call(patch, "status")) {
2229
+ const finalType = Object.prototype.hasOwnProperty.call(patch, "type") ? patch.type : t.type;
2230
+ if (finalType === "epic") {
2231
+ const e = new Error("epic status is derived from its contained stories — move the stories instead");
2232
+ e.statusCode = 422;
2233
+ e.kind = "EPIC_STATUS_DERIVED";
2234
+ throw e;
2235
+ }
2236
+ }
2237
+ const allowed = ["title", "description", "type", "status", "position",
2238
+ "acceptanceCriteria", "labels", "definitionOfReady", "definitionOfDone",
2239
+ // SM-54 — modal patches for test-definition.
2240
+ "prerequisites", "steps",
2241
+ // SM-57 — modal patches for test-execution.
2242
+ "executionSteps", "outcomeOverride", "env",
2243
+ // SM-94 — test-definition lifecycle (draft|published). Driven by the
2244
+ // publish_test_definition / reopen_test_definition MCP tools.
2245
+ "lifecycle"];
2246
+ for (const k of allowed) {
2247
+ if (Object.prototype.hasOwnProperty.call(patch, k)) {
2248
+ if (k === "position") {
2249
+ // SM-52: patch.position.epicId means "re-parent to this epic" —
2250
+ // never persisted on the ticket itself.
2251
+ // SM-166: only an EXPLICIT epicId key re-parents. An absent key means
2252
+ // "leave the container as-is", NOT "detach" — otherwise a sortOrder-
2253
+ // only position patch would silently strip the inbound contains-link.
2254
+ const posHasEpicId = patch.position
2255
+ && Object.prototype.hasOwnProperty.call(patch.position, "epicId");
2256
+ const newEpicId = posHasEpicId && typeof patch.position.epicId === "string"
2257
+ ? patch.position.epicId : null;
2258
+ // SM-260: a PARTIAL position patch merges with the current position —
2259
+ // an absent key keeps its current value (e.g. `{releaseId}` moves the
2260
+ // release only; processStepId + sortOrder survive). An EXPLICIT null
2261
+ // still clears a field. (Previously the patch replaced the position
2262
+ // wholesale, so a partial patch silently nulled the omitted fields.)
2263
+ let cleanedPos = Object.assign({}, t.position || {}, patch.position || {}, { epicId: null });
2264
+ // SM-67: contained stories inherit release+processStep from their
2265
+ // container. If the patch keeps/sets a container epic, override
2266
+ // the position with the epic's values. SM-240 review: when the
2267
+ // patch OMITS epicId (SM-166: keep container as-is), the KEPT
2268
+ // container must dictate release+processStep too — normalize's
2269
+ // syncContainedStoryPositions used to repair this; the op path no
2270
+ // longer runs it.
2271
+ const keptEpicId = (!posHasEpicId && t.type !== "epic")
2272
+ ? containerEpicIdOf(s, ticketId) : null;
2273
+ if (t.type !== "epic" && (newEpicId || keptEpicId)) {
2274
+ cleanedPos = _inheritPositionFromEpic(s, newEpicId || keptEpicId, cleanedPos);
2275
+ }
2276
+ t.position = normalizePosition(cleanedPos);
2277
+ if (t.type !== "epic" && posHasEpicId) _setContainerEpic(s, ticketId, newEpicId, actor);
2278
+ // SM-67: when an epic's position changes, every contained story
2279
+ // cascades to the new release+processStep.
2280
+ if (t.type === "epic") {
2281
+ _cascadeEpicPositionToContainedStories(s, ticketId, actor);
2282
+ }
2283
+ }
2284
+ else if (k === "type") {
2285
+ const becameEpic = patch.type === "epic" && t.type !== "epic";
2286
+ t.type = patch.type;
2287
+ // An epic cannot be contained by another epic — strip any stale
2288
+ // inbound contains-link so syncContainedStoryPositions won't later
2289
+ // drag the now-epic back to its ex-parent's position. (SM-150)
2290
+ if (becameEpic) _removeContainsLinksTo(s, ticketId, actor);
2291
+ // SM-240 review: re-gate the type-conditional fields (steps/
2292
+ // prerequisites/lifecycle/execution fields) exactly the way
2293
+ // normalizeTicket would — the commit path no longer runs the full
2294
+ // normalize, so a type change must not leave e.g. test-definition
2295
+ // steps on a converted user-story. Swap the re-gated object in.
2296
+ const regated = markCowFresh(s, normalizeTicket(t));
2297
+ const ti = s.tickets.indexOf(t);
2298
+ if (ti >= 0) s.tickets[ti] = regated;
2299
+ t = regated;
2300
+ }
2301
+ else if (k === "acceptanceCriteria" && Array.isArray(patch.acceptanceCriteria)) {
2302
+ t.acceptanceCriteria = patch.acceptanceCriteria.map(normalizeAcceptanceCriterion);
2303
+ }
2304
+ else if (k === "definitionOfReady" || k === "definitionOfDone") {
2305
+ t[k] = normalizeChecklist(patch[k]);
2306
+ }
2307
+ else if (k === "prerequisites" && Array.isArray(patch.prerequisites)) {
2308
+ t.prerequisites = patch.prerequisites.map(normalizeTestPrerequisite);
2309
+ }
2310
+ else if (k === "steps" && Array.isArray(patch.steps)) {
2311
+ t.steps = patch.steps.map(normalizeTestStep);
2312
+ }
2313
+ else if (k === "executionSteps" && Array.isArray(patch.executionSteps)) {
2314
+ t.executionSteps = patch.executionSteps.map(normalizeTestExecStep);
2315
+ // SM-57-followup: outcome derived from these steps may have
2316
+ // flipped — sync status. The exec.outcomeOverride field may
2317
+ // still be patched in a later loop iteration; calling sync
2318
+ // here covers the executionSteps-only patch path. The override
2319
+ // path below calls sync again, idempotent.
2320
+ _syncExecStatusToOutcome(t);
2321
+ }
2322
+ else if (k === "outcomeOverride") {
2323
+ if (patch.outcomeOverride == null || patch.outcomeOverride === "auto") {
2324
+ t.outcomeOverride = null;
2325
+ } else if (TEST_EXEC_OUTCOMES.indexOf(patch.outcomeOverride) >= 0) {
2326
+ t.outcomeOverride = patch.outcomeOverride;
2327
+ }
2328
+ // Invalid values are silently ignored — UI-level validation should
2329
+ // prevent this from reaching us; defensive on the data layer.
2330
+ // SM-57-followup: outcome flipped — sync status accordingly.
2331
+ _syncExecStatusToOutcome(t);
2332
+ }
2333
+ else if (k === "status") _setTicketStatus(t, patch.status); // SM-170: aging clock
2334
+ // SM-240: object values (labels, …) are cloned — with the full
2335
+ // normalize gone from the op path, a shared caller reference would
2336
+ // otherwise leak straight into the store.
2337
+ else t[k] = (patch[k] && typeof patch[k] === "object") ? clone(patch[k]) : patch[k];
2338
+ }
2339
+ }
2340
+ bumpAudit(t, actor);
2341
+ recomputeEpicStatuses(s); // SM-236 (status/type/containment change may roll up)
2342
+ return s;
2343
+ },
2344
+
2345
+ softDeleteTicket(snap, ticketId, actor) {
2346
+ const s = cowSnap(snap);
2347
+ const t = findTicket(s, ticketId);
2348
+ if (!t) return s;
2349
+ t.isDeleted = true;
2350
+ t.deletedAt = now();
2351
+ t.deletedBy = normalizeActor(actor);
2352
+ bumpAudit(t, actor);
2353
+ recomputeEpicStatuses(s); // SM-236 (a removed child may complete its epic)
2354
+ return s;
2355
+ },
2356
+
2357
+ changeStatus(snap, ticketId, newStatus, actor) {
2358
+ const s = cowSnap(snap);
2359
+ const t = findTicket(s, ticketId);
2360
+ if (!t) return s;
2361
+ // SM-237: an epic's status is derived — reject a manual change at the op
2362
+ // layer itself, so the rule is structural (not call-site discipline). The
2363
+ // roll-up recompute below mutates ticket.status directly, never via this op.
2364
+ if (t.type === "epic") {
2365
+ const e = new Error("epic status is derived from its contained stories — move the stories instead");
2366
+ e.statusCode = 422;
2367
+ e.kind = "EPIC_STATUS_DERIVED";
2368
+ throw e;
2369
+ }
2370
+ _setTicketStatus(t, newStatus); // SM-170: resets statusEnteredAt on change
2371
+ bumpAudit(t, actor);
2372
+ recomputeEpicStatuses(s); // SM-236 (child status change rolls up to its epic)
2373
+ return s;
2374
+ },
2375
+
2376
+ // SM-242: cancel a ticket WITHOUT any gate check — the documented, explicit
2377
+ // way to record a deliberate non-implementation (Cancel ≠ Delete: the ticket
2378
+ // stays visible with its history + links). Sets the first cancelled-category
2379
+ // status of the project workflow. Epics are rejected here (kind=EPIC_CANCEL —
2380
+ // the cascade arrives in SM-243); spec types have their own lifecycle.
2381
+ cancelTicket(snap, ticketId, actor) {
2382
+ const s = cowSnap(snap);
2383
+ const t = findTicket(s, ticketId);
2384
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2385
+ if (isSpecType(t.type)) {
2386
+ const e = new Error("spec objects have their own lifecycle — cannot be cancelled");
2387
+ e.statusCode = 422; e.kind = "SPEC_CANCEL"; throw e;
2388
+ }
2389
+ const cancelledId = firstStatusOfCategory(s.project, "cancelled");
2390
+ if (!cancelledId) {
2391
+ const e = new Error("workflow has no cancelled-category status");
2392
+ e.statusCode = 422; e.kind = "NO_CANCEL_STATUS"; throw e;
2393
+ }
2394
+ if (t.type === "epic") {
2395
+ // SM-243: cancelling an epic CASCADES — every contained non-terminal
2396
+ // story is cancelled in this one snapshot (atomic; the persist layer tags
2397
+ // it "epic_cancel"). done/cancelled children are left untouched. The epic
2398
+ // status is then DERIVED by the roll-up — EXCEPT an empty epic, which is
2399
+ // set directly (the only allowed manual epic-status change, SM-237).
2400
+ const children = tickets.storiesInEpic(s, ticketId);
2401
+ if (children.length === 0) {
2402
+ _setTicketStatus(t, cancelledId);
2403
+ bumpAudit(t, actor);
2404
+ } else {
2405
+ for (const child of children) {
2406
+ if (isTerminalStatus(s.project, child.status)) continue; // skip done + cancelled
2407
+ const fresh = cowTicket(s, child.id);
2408
+ _setTicketStatus(fresh, cancelledId);
2409
+ bumpAudit(fresh, actor);
2410
+ }
2411
+ }
2412
+ recomputeEpicStatuses(s);
2413
+ return s;
2414
+ }
2415
+ _setTicketStatus(t, cancelledId);
2416
+ bumpAudit(t, actor);
2417
+ recomputeEpicStatuses(s); // SM-236/243 (a cancelled child rolls up to its epic)
2418
+ return s;
2419
+ },
2420
+
2421
+ moveTicket(snap, ticketId, position, actor) {
2422
+ const s = cowSnap(snap);
2423
+ const t = findTicket(s, ticketId);
2424
+ if (!t) return s;
2425
+ // SM-52: position.epicId in the input is interpreted as a re-parenting
2426
+ // request (which epic should contain this ticket). It's translated into
2427
+ // contains-link updates and never persisted on the ticket itself.
2428
+ const newEpicId = position && typeof position.epicId === "string" ? position.epicId : null;
2429
+ let cleanedPos = Object.assign({}, position || {}, { epicId: null });
2430
+ // SM-67: contained stories inherit release+processStep from container.
2431
+ if (t.type !== "epic" && newEpicId) {
2432
+ cleanedPos = _inheritPositionFromEpic(s, newEpicId, cleanedPos);
2433
+ }
2434
+ t.position = normalizePosition(cleanedPos);
2435
+ if (t.type !== "epic") _setContainerEpic(s, ticketId, newEpicId, actor);
2436
+ // SM-67: epic move cascades to contained stories.
2437
+ if (t.type === "epic") {
2438
+ _cascadeEpicPositionToContainedStories(s, ticketId, actor);
2439
+ }
2440
+ bumpAudit(t, actor);
2441
+ recomputeEpicStatuses(s); // SM-236 (re-parenting changes both epics' children)
2442
+ return s;
2443
+ },
2444
+
2445
+ addComment(snap, ticketId, comment, actor) {
2446
+ const s = cowSnap(snap);
2447
+ const t = findTicket(s, ticketId);
2448
+ if (!t) return s;
2449
+ t.comments.push(normalizeComment(Object.assign({}, comment, {
2450
+ actor: normalizeActor(actor),
2451
+ timestamp: now()
2452
+ })));
2453
+ bumpAudit(t, actor);
2454
+ return s;
2455
+ },
2456
+
2457
+ checkDorItem(snap, ticketId, itemId, actor) {
2458
+ const s = cowSnap(snap);
2459
+ const t = findTicket(s, ticketId);
2460
+ if (!t) return s;
2461
+ const item = t.definitionOfReady.items.find(i => i.id === itemId);
2462
+ if (!item) return s;
2463
+ item.checked = true;
2464
+ item.checkedAt = now();
2465
+ item.checkedBy = normalizeActor(actor);
2466
+ bumpAudit(t, actor);
2467
+ return s;
2468
+ },
2469
+
2470
+ uncheckDorItem(snap, ticketId, itemId, actor) {
2471
+ const s = cowSnap(snap);
2472
+ const t = findTicket(s, ticketId);
2473
+ if (!t) return s;
2474
+ const item = t.definitionOfReady.items.find(i => i.id === itemId);
2475
+ if (!item) return s;
2476
+ item.checked = false;
2477
+ item.checkedAt = null;
2478
+ item.checkedBy = null;
2479
+ bumpAudit(t, actor);
2480
+ return s;
2481
+ },
2482
+
2483
+ checkDodItem(snap, ticketId, itemId, actor) {
2484
+ const s = cowSnap(snap);
2485
+ const t = findTicket(s, ticketId);
2486
+ if (!t) return s;
2487
+ const item = t.definitionOfDone.items.find(i => i.id === itemId);
2488
+ if (!item) return s;
2489
+ item.checked = true;
2490
+ item.checkedAt = now();
2491
+ item.checkedBy = normalizeActor(actor);
2492
+ bumpAudit(t, actor);
2493
+ return s;
2494
+ },
2495
+
2496
+ uncheckDodItem(snap, ticketId, itemId, actor) {
2497
+ const s = cowSnap(snap);
2498
+ const t = findTicket(s, ticketId);
2499
+ if (!t) return s;
2500
+ const item = t.definitionOfDone.items.find(i => i.id === itemId);
2501
+ if (!item) return s;
2502
+ item.checked = false;
2503
+ item.checkedAt = null;
2504
+ item.checkedBy = null;
2505
+ bumpAudit(t, actor);
2506
+ return s;
2507
+ },
2508
+
2509
+ // ---- SM-58: test-definition step + prereq ops -------------------------
2510
+ //
2511
+ // These operate on type='test-definition' tickets. Mutations append/edit/
2512
+ // delete/reorder the prerequisites or steps arrays in-place. Every op
2513
+ // returns a fresh cloned snapshot so the store-wrapper can capture an
2514
+ // undoable commit. Throws { statusCode } for not-found.
2515
+
2516
+ // SM-301: derive a test-definition from a story's acceptance criteria —
2517
+ // each AC becomes one step (step = the criterion, expectedResult = the
2518
+ // criterion as the observable outcome). The plan is a "north star" created
2519
+ // at AC-time (draft by default) and refined during implementation; the
2520
+ // SM-299 gate then requires it published before done. Returns the snapshot
2521
+ // with the new definition appended LAST (createTicket appends) and a
2522
+ // `tests`-link from the definition back to the story. Compound but ONE
2523
+ // logical op → ONE revision at the surface.
2524
+ deriveTestDefinition(snap, ticketId, opts, actor) {
2525
+ opts = opts || {};
2526
+ const target = findTicket(snap, ticketId);
2527
+ if (!target) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2528
+ if (target.isDeleted) { const e = new Error("cannot derive from a deleted ticket: " + ticketId); e.statusCode = 404; throw e; }
2529
+ // Deriving a test plan FOR a test plan (or an execution) is nonsensical —
2530
+ // review finding on f714cfa.
2531
+ if (target.type === "test-definition" || target.type === "test-execution") {
2532
+ const e = new Error("cannot derive a test-definition from a " + target.type + ": " + ticketId);
2533
+ e.statusCode = 400; throw e;
2534
+ }
2535
+ const acs = (Array.isArray(target.acceptanceCriteria) ? target.acceptanceCriteria : [])
2536
+ .filter((a) => a && String(a.text || "").trim());
2537
+ if (acs.length === 0) {
2538
+ const e = new Error("cannot derive a test-definition: " + (target.ticketKey || ticketId)
2539
+ + " has no acceptance criteria (add AC first — they are the test plan)");
2540
+ e.statusCode = 422; e.kind = "NO_ACCEPTANCE_CRITERIA";
2541
+ throw e;
2542
+ }
2543
+ const title = (opts.title && String(opts.title).trim())
2544
+ || ("Testplan: " + (target.title || target.ticketKey || ticketId));
2545
+ let s = ops.createTicket(snap, { type: "test-definition", title: title }, actor);
2546
+ const def = s.tickets[s.tickets.length - 1];
2547
+ acs.forEach((ac) => {
2548
+ const text = String(ac.text).trim();
2549
+ s = ops.addTestStep(s, def.id, { step: text, expectedResult: text }, actor);
2550
+ });
2551
+ s = ops.addLink(s, def.id, { linkTypeId: "tests", targetTicketId: ticketId }, actor);
2552
+ return s;
2553
+ },
2554
+
2555
+ addTestStep(snap, ticketId, patch, actor) {
2556
+ const s = cowSnap(snap);
2557
+ const t = findTicket(s, ticketId);
2558
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2559
+ assertSpecEditable(t);
2560
+ const step = normalizeTestStep(patch || {});
2561
+ const list = Array.isArray(t.steps) ? t.steps : (t.steps = []);
2562
+ const pos = patch && typeof patch.position === "number" ? patch.position : list.length;
2563
+ list.splice(Math.max(0, Math.min(list.length, pos)), 0, step);
2564
+ bumpAudit(t, actor);
2565
+ return s;
2566
+ },
2567
+
2568
+ updateTestStep(snap, ticketId, stepId, patch, actor) {
2569
+ const s = cowSnap(snap);
2570
+ const t = findTicket(s, ticketId);
2571
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2572
+ assertSpecEditable(t);
2573
+ const step = (t.steps || []).find(x => x.id === stepId);
2574
+ if (!step) { const e = new Error("step not found: " + stepId); e.statusCode = 404; throw e; }
2575
+ if (patch && typeof patch.step === "string") step.step = patch.step;
2576
+ if (patch && typeof patch.data === "string") step.data = patch.data;
2577
+ if (patch && typeof patch.expectedResult === "string") step.expectedResult = patch.expectedResult;
2578
+ bumpAudit(t, actor);
2579
+ return s;
2580
+ },
2581
+
2582
+ removeTestStep(snap, ticketId, stepId, actor) {
2583
+ const s = cowSnap(snap);
2584
+ const t = findTicket(s, ticketId);
2585
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2586
+ assertSpecEditable(t);
2587
+ const before = (t.steps || []).length;
2588
+ t.steps = (t.steps || []).filter(x => x.id !== stepId);
2589
+ if (t.steps.length !== before) bumpAudit(t, actor);
2590
+ return s;
2591
+ },
2592
+
2593
+ reorderTestSteps(snap, ticketId, orderedStepIds, actor) {
2594
+ const s = cowSnap(snap);
2595
+ const t = findTicket(s, ticketId);
2596
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2597
+ assertSpecEditable(t);
2598
+ const byId = new Map((t.steps || []).map(x => [x.id, x]));
2599
+ const reordered = [];
2600
+ for (const id of (orderedStepIds || [])) {
2601
+ const x = byId.get(id);
2602
+ if (x) { reordered.push(x); byId.delete(id); }
2603
+ }
2604
+ // Append any steps the caller didn't list (defensive — keep them).
2605
+ for (const remaining of byId.values()) reordered.push(remaining);
2606
+ t.steps = reordered;
2607
+ bumpAudit(t, actor);
2608
+ return s;
2609
+ },
2610
+
2611
+ addTestPrereq(snap, ticketId, patch, actor) {
2612
+ const s = cowSnap(snap);
2613
+ const t = findTicket(s, ticketId);
2614
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2615
+ assertSpecEditable(t);
2616
+ const item = normalizeTestPrerequisite(patch || {});
2617
+ if (!Array.isArray(t.prerequisites)) t.prerequisites = [];
2618
+ t.prerequisites.push(item);
2619
+ bumpAudit(t, actor);
2620
+ return s;
2621
+ },
2622
+
2623
+ updateTestPrereq(snap, ticketId, prereqId, patch, actor) {
2624
+ const s = cowSnap(snap);
2625
+ const t = findTicket(s, ticketId);
2626
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2627
+ assertSpecEditable(t);
2628
+ const item = (t.prerequisites || []).find(x => x.id === prereqId);
2629
+ if (!item) { const e = new Error("prereq not found: " + prereqId); e.statusCode = 404; throw e; }
2630
+ if (patch && typeof patch.label === "string") item.label = patch.label;
2631
+ if (patch && typeof patch.required === "boolean") item.required = patch.required;
2632
+ bumpAudit(t, actor);
2633
+ return s;
2634
+ },
2635
+
2636
+ removeTestPrereq(snap, ticketId, prereqId, actor) {
2637
+ const s = cowSnap(snap);
2638
+ const t = findTicket(s, ticketId);
2639
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2640
+ assertSpecEditable(t);
2641
+ const before = (t.prerequisites || []).length;
2642
+ t.prerequisites = (t.prerequisites || []).filter(x => x.id !== prereqId);
2643
+ if (t.prerequisites.length !== before) bumpAudit(t, actor);
2644
+ return s;
2645
+ },
2646
+
2647
+ checkTestPrereq(snap, ticketId, prereqId, actor) {
2648
+ const s = cowSnap(snap);
2649
+ const t = findTicket(s, ticketId);
2650
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2651
+ const item = (t.prerequisites || []).find(x => x.id === prereqId);
2652
+ if (!item) { const e = new Error("prereq not found: " + prereqId); e.statusCode = 404; throw e; }
2653
+ item.checked = true;
2654
+ bumpAudit(t, actor);
2655
+ return s;
2656
+ },
2657
+
2658
+ uncheckTestPrereq(snap, ticketId, prereqId, actor) {
2659
+ const s = cowSnap(snap);
2660
+ const t = findTicket(s, ticketId);
2661
+ if (!t) { const e = new Error("ticket not found: " + ticketId); e.statusCode = 404; throw e; }
2662
+ const item = (t.prerequisites || []).find(x => x.id === prereqId);
2663
+ if (!item) { const e = new Error("prereq not found: " + prereqId); e.statusCode = 404; throw e; }
2664
+ item.checked = false;
2665
+ bumpAudit(t, actor);
2666
+ return s;
2667
+ },
2668
+
2669
+ // ---- SM-58: test-execution lifecycle ops ------------------------------
2670
+ //
2671
+ // startTestExecution clones a test-definition's steps onto a new
2672
+ // test-execution ticket (snapshot-freeze semantic — later definition
2673
+ // changes don't bleed back). Also creates the "executes" link from the
2674
+ // run → definition, and bumps the project ticketCounter for the new key.
2675
+
2676
+ startTestExecution(snap, definitionId, opts, actor) {
2677
+ const s = cowSnap(snap);
2678
+ const def = findTicket(s, definitionId);
2679
+ if (!def) { const e = new Error("definition not found: " + definitionId); e.statusCode = 404; throw e; }
2680
+ if (def.type !== "test-definition") {
2681
+ const e = new Error("ticket is not a test-definition: " + definitionId);
2682
+ e.statusCode = 422; e.kind = "WRONG_TYPE";
2683
+ throw e;
2684
+ }
2685
+ const a = normalizeActor(actor);
2686
+ const env = opts && typeof opts.env === "string" ? opts.env : "";
2687
+ const runBy = (opts && isObject(opts.runBy)) ? normalizeActor(opts.runBy) : a;
2688
+
2689
+ // Clone steps with snapshot semantics: each execution step keeps a
2690
+ // pointer to the definition's stepId so MCP recording can address by id.
2691
+ const clonedSteps = (def.steps || []).map(src => normalizeTestExecStep({
2692
+ stepId: src.id,
2693
+ step: src.step,
2694
+ data: src.data,
2695
+ expectedResult: src.expectedResult,
2696
+ status: "pending"
2697
+ }));
2698
+
2699
+ s.project = normalizeProject(s.project);
2700
+ s.project.ticketCounter = (s.project.ticketCounter || 0) + 1;
2701
+ const ticketKey = s.project.ticketPrefix + "-" + s.project.ticketCounter;
2702
+
2703
+ const exec = normalizeTicket({
2704
+ id: uid("t-"),
2705
+ projectId: s.project.id,
2706
+ ticketKey: ticketKey,
2707
+ type: "test-execution",
2708
+ title: "Run of " + (def.ticketKey || def.id),
2709
+ description: "Execution of test-definition " + (def.ticketKey || def.id),
2710
+ status: "in-progress",
2711
+ // Place in the same release as the definition so it's visible nearby.
2712
+ position: { releaseId: (def.position && def.position.releaseId) || null,
2713
+ processStepId: (def.position && def.position.processStepId) || null,
2714
+ epicId: null,
2715
+ sortOrder: 0 },
2716
+ executionSteps: clonedSteps,
2717
+ referencedTestDefinitionId: def.id,
2718
+ runAt: now(),
2719
+ runBy: runBy,
2720
+ env: env,
2721
+ createdBy: a,
2722
+ updatedBy: a,
2723
+ createdAt: now(),
2724
+ updatedAt: now(),
2725
+ version: 1
2726
+ });
2727
+ s.tickets.push(markCowFresh(s, exec));
2728
+
2729
+ // Wire the 'executes' link: source = exec, target = def.
2730
+ exec.links = exec.links || [];
2731
+ exec.links.push(normalizeLink({
2732
+ linkTypeId: "executes",
2733
+ targetTicketId: def.id,
2734
+ createdBy: a
2735
+ }));
2736
+ bumpAudit(s.project, a);
2737
+ return s;
2738
+ },
2739
+
2740
+ recordTestExecStep(snap, executionId, stepId, patch, actor) {
2741
+ const s = cowSnap(snap);
2742
+ const exec = findTicket(s, executionId);
2743
+ if (!exec) { const e = new Error("execution not found: " + executionId); e.statusCode = 404; throw e; }
2744
+ if (exec.type !== "test-execution") {
2745
+ const e = new Error("ticket is not a test-execution: " + executionId);
2746
+ e.statusCode = 422; e.kind = "WRONG_TYPE";
2747
+ throw e;
2748
+ }
2749
+ // Match by execution-step.stepId (the cloned reference to the definition)
2750
+ // OR by execution-step.id (the execution's own id). Either is fine —
2751
+ // callers usually have the definition step id at hand.
2752
+ const step = (exec.executionSteps || []).find(x => x.stepId === stepId || x.id === stepId);
2753
+ if (!step) { const e = new Error("step not found: " + stepId); e.statusCode = 404; throw e; }
2754
+ if (patch && typeof patch.actualResult === "string") step.actualResult = patch.actualResult;
2755
+ if (patch && typeof patch.status === "string") {
2756
+ if (TEST_EXEC_STEP_STATUSES.indexOf(patch.status) < 0) {
2757
+ const e = new Error("invalid status: " + patch.status + " (allowed: " + TEST_EXEC_STEP_STATUSES.join(",") + ")");
2758
+ e.statusCode = 422; throw e;
2759
+ }
2760
+ step.status = patch.status;
2761
+ }
2762
+ if (patch && typeof patch.note === "string") {
2763
+ if (patch.note.length > 0) step.note = patch.note;
2764
+ else delete step.note;
2765
+ }
2766
+ // SM-57-followup: outcome may have flipped — sync status accordingly.
2767
+ _syncExecStatusToOutcome(exec);
2768
+ bumpAudit(exec, actor);
2769
+ return s;
2770
+ },
2771
+
2772
+ setTestExecOutcome(snap, executionId, outcome, actor) {
2773
+ const s = cowSnap(snap);
2774
+ const exec = findTicket(s, executionId);
2775
+ if (!exec) { const e = new Error("execution not found: " + executionId); e.statusCode = 404; throw e; }
2776
+ if (exec.type !== "test-execution") {
2777
+ const e = new Error("ticket is not a test-execution: " + executionId);
2778
+ e.statusCode = 422; e.kind = "WRONG_TYPE";
2779
+ throw e;
2780
+ }
2781
+ // "auto" resets the override so derive-from-steps takes over.
2782
+ if (outcome === "auto" || outcome == null) {
2783
+ exec.outcomeOverride = null;
2784
+ } else if (TEST_EXEC_OUTCOMES.indexOf(outcome) >= 0) {
2785
+ exec.outcomeOverride = outcome;
2786
+ } else {
2787
+ const e = new Error("invalid outcome: " + outcome + " (allowed: " + TEST_EXEC_OUTCOMES.join(",") + " or 'auto')");
2788
+ e.statusCode = 422; throw e;
2789
+ }
2790
+ // SM-57-followup: outcome flipped — sync status accordingly.
2791
+ _syncExecStatusToOutcome(exec);
2792
+ bumpAudit(exec, actor);
2793
+ return s;
2794
+ },
2795
+
2796
+ // E18.B: Project-Header-Update als Store-Op (für undo/redo + snapshot-PUT
2797
+ // Pipeline). Erlaubte Felder: name, description, definitions, workflow,
2798
+ // ticketTypes, entityTypeConfig, labels. id/ticketPrefix/ticketCounter sind
2799
+ // bewusst gesperrt — id ist Identität, ticketPrefix/Counter würden bestehende
2800
+ // ticketKeys brechen.
2801
+ updateProject(snap, patch, actor) {
2802
+ const s = cowSnap(snap);
2803
+ const allowed = ["name", "description", "definitions", "workflow",
2804
+ "boards", "ticketTypes", "entityTypeConfig", "labels", "linkTypes",
2805
+ // SM-93
2806
+ "governance"];
2807
+ const merged = Object.assign({}, s.project);
2808
+ for (const k of allowed) {
2809
+ if (patch && Object.prototype.hasOwnProperty.call(patch, k)) {
2810
+ // SM-240: clone object values — see updateTicket fall-through.
2811
+ merged[k] = (patch[k] && typeof patch[k] === "object") ? clone(patch[k]) : patch[k];
2812
+ }
2813
+ }
2814
+ s.project = normalizeProject(merged);
2815
+ bumpAudit(s.project, actor);
2816
+ return s;
2817
+ },
2818
+
2819
+ createRelease(snap, partial, actor) {
2820
+ const s = cowSnap(snap);
2821
+ const a = normalizeActor(actor);
2822
+ const maxSortOrder = s.releases.reduce((m, r) => Math.max(m, r.sortOrder), -1);
2823
+ const r = normalizeRelease(Object.assign({}, partial, {
2824
+ id: uid("r-"),
2825
+ projectId: s.project.id,
2826
+ sortOrder: typeof partial.sortOrder === "number" ? partial.sortOrder : maxSortOrder + 1,
2827
+ createdBy: a,
2828
+ updatedBy: a,
2829
+ createdAt: now(),
2830
+ updatedAt: now(),
2831
+ version: 1
2832
+ }));
2833
+ s.releases.push(markCowFresh(s, r));
2834
+ return s;
2835
+ },
2836
+
2837
+ updateRelease(snap, releaseId, patch, actor) {
2838
+ const s = cowSnap(snap);
2839
+ const r = cowRelease(s, releaseId);
2840
+ if (!r) return s;
2841
+ const allowed = ["name", "description", "startDate", "endDate", "status", "sortOrder"];
2842
+ for (const k of allowed) {
2843
+ if (Object.prototype.hasOwnProperty.call(patch, k)) r[k] = patch[k];
2844
+ }
2845
+ bumpAudit(r, actor);
2846
+ return s;
2847
+ },
2848
+
2849
+ softDeleteRelease(snap, releaseId, actor) {
2850
+ const s = cowSnap(snap);
2851
+ const r = cowRelease(s, releaseId);
2852
+ if (!r) return s;
2853
+ r.isDeleted = true;
2854
+ r.deletedAt = now();
2855
+ r.deletedBy = normalizeActor(actor);
2856
+ bumpAudit(r, actor);
2857
+ // SM-240 (normalize-only-at-edges): normalizeSnapshot used to prune the
2858
+ // now-dangling position.releaseId on the next pass; with the op path no
2859
+ // longer running a full normalize, the op must leave an invariant
2860
+ // snapshot itself — mirror SM-167's processStep behaviour.
2861
+ for (const probe of s.tickets.slice()) {
2862
+ if (!probe.position || probe.position.releaseId !== releaseId) continue;
2863
+ const t = cowTicket(s, probe.id);
2864
+ t.position = normalizePosition(Object.assign({}, t.position, { releaseId: null }));
2865
+ // SM-240 review: prune soft-deleted tickets too (normalize parity),
2866
+ // without an audit bump.
2867
+ if (!t.isDeleted) bumpAudit(t, actor);
2868
+ }
2869
+ return s;
2870
+ },
2871
+
2872
+ reorderReleases(snap, orderedIds, actor) {
2873
+ const s = cowSnap(snap);
2874
+ const byId = new Map(s.releases.map(r => [r.id, r]));
2875
+ const newList = [];
2876
+ orderedIds.forEach((id, idx) => {
2877
+ if (byId.has(id)) {
2878
+ const r = cowRelease(s, id); // COW-aware (SM-240)
2879
+ r.sortOrder = idx;
2880
+ bumpAudit(r, actor);
2881
+ newList.push(r);
2882
+ byId.delete(id);
2883
+ }
2884
+ });
2885
+ // any releases not in orderedIds keep relative order at the end
2886
+ for (const r of byId.values()) newList.push(r);
2887
+ s.releases = newList;
2888
+ return s;
2889
+ },
2890
+
2891
+ createProcessStep(snap, partial, actor) {
2892
+ const s = cowSnap(snap);
2893
+ const a = normalizeActor(actor);
2894
+ const maxSortOrder = s.processSteps.reduce((m, p) => Math.max(m, p.sortOrder), -1);
2895
+ const ps = normalizeProcessStep(Object.assign({}, partial, {
2896
+ id: uid("ps-"),
2897
+ projectId: s.project.id,
2898
+ sortOrder: typeof partial.sortOrder === "number" ? partial.sortOrder : maxSortOrder + 1,
2899
+ createdBy: a,
2900
+ updatedBy: a,
2901
+ createdAt: now(),
2902
+ updatedAt: now(),
2903
+ version: 1
2904
+ }));
2905
+ s.processSteps.push(markCowFresh(s, ps));
2906
+ return s;
2907
+ },
2908
+
2909
+ updateProcessStep(snap, stepId, patch, actor) {
2910
+ const s = cowSnap(snap);
2911
+ const ps = cowProcessStep(s, stepId);
2912
+ if (!ps) return s;
2913
+ const allowed = ["name", "description", "epicId", "sortOrder"];
2914
+ for (const k of allowed) {
2915
+ if (Object.prototype.hasOwnProperty.call(patch, k)) ps[k] = patch[k];
2916
+ }
2917
+ bumpAudit(ps, actor);
2918
+ return s;
2919
+ },
2920
+
2921
+ softDeleteProcessStep(snap, stepId, actor) {
2922
+ const s = cowSnap(snap);
2923
+ const ps = cowProcessStep(s, stepId);
2924
+ if (!ps) return s;
2925
+ ps.isDeleted = true;
2926
+ ps.deletedAt = now();
2927
+ ps.deletedBy = normalizeActor(actor);
2928
+ bumpAudit(ps, actor);
2929
+ // SM-167: tickets positioned in this step would otherwise keep a dangling
2930
+ // processStepId — they render in NO cell (the step is gone) AND are excluded
2931
+ // from the backlog's per-release group (which treats a truthy processStepId
2932
+ // as "placed in a cell"). Result: invisible in the map yet still counted
2933
+ // toward their release (e.g. the release-completion warning). Clear the
2934
+ // processStepId — keep the releaseId — so they resurface in the backlog's
2935
+ // "Scheduled for <release> but unplaced" group, where the user can re-place
2936
+ // or unschedule them. Contained stories follow their epic via
2937
+ // syncContainedStoryPositions on the next normalize.
2938
+ for (const probe of s.tickets.slice()) {
2939
+ if (!probe.position || probe.position.processStepId !== stepId) continue;
2940
+ const t = cowTicket(s, probe.id); // COW-aware (SM-240)
2941
+ t.position = normalizePosition(Object.assign({}, t.position, { processStepId: null }));
2942
+ // SM-240 review: soft-deleted tickets are pruned too (normalize's
2943
+ // pruneStaleContainerRefs does) — but without an audit bump.
2944
+ if (!t.isDeleted) bumpAudit(t, actor);
2945
+ }
2946
+ return s;
2947
+ },
2948
+
2949
+ reorderProcessSteps(snap, orderedIds, actor) {
2950
+ const s = cowSnap(snap);
2951
+ const byId = new Map(s.processSteps.map(p => [p.id, p]));
2952
+ const newList = [];
2953
+ orderedIds.forEach((id, idx) => {
2954
+ if (byId.has(id)) {
2955
+ const ps = cowProcessStep(s, id); // COW-aware (SM-240)
2956
+ ps.sortOrder = idx;
2957
+ bumpAudit(ps, actor);
2958
+ newList.push(ps);
2959
+ byId.delete(id);
2960
+ }
2961
+ });
2962
+ for (const ps of byId.values()) newList.push(ps);
2963
+ s.processSteps = newList;
2964
+ return s;
2965
+ },
2966
+
2967
+ /**
2968
+ * SM-247 — split a process step: insert a NEW step directly after the
2969
+ * original and move the chosen epics (and, via the SM-67 cascade, their
2970
+ * contained stories) into it. Loose stories stay with the original — the
2971
+ * split decision is expressed in epics, the unit the user sees. One
2972
+ * snapshot transition (one revision when persisted).
2973
+ *
2974
+ * opts: { name (required), description?, epicIds? } — every epicId must
2975
+ * be a non-deleted epic currently positioned in `processStepId`, else
2976
+ * 422 with the offending ids in `missing`. Empty epicIds = pure
2977
+ * insert-after (an empty new column).
2978
+ */
2979
+ splitProcessStep(snap, processStepId, opts, actor) {
2980
+ const s = cowSnap(snap);
2981
+ const orig = s.processSteps.find(p => p.id === processStepId && !p.isDeleted);
2982
+ if (!orig) {
2983
+ const e = new Error("process step not found: " + processStepId);
2984
+ e.statusCode = 404;
2985
+ throw e;
2986
+ }
2987
+ const name = (opts && typeof opts.name === "string") ? opts.name.trim() : "";
2988
+ if (!name) {
2989
+ const e = new Error("split requires a name for the new process step");
2990
+ e.statusCode = 400;
2991
+ throw e;
2992
+ }
2993
+ const epicIds = (opts && Array.isArray(opts.epicIds)) ? opts.epicIds : [];
2994
+ const notInStep = epicIds.filter(id => {
2995
+ const t = (s.tickets || []).find(x => x.id === id);
2996
+ return !t || t.isDeleted || t.type !== "epic"
2997
+ || !t.position || t.position.processStepId !== processStepId;
2998
+ });
2999
+ if (notInStep.length > 0) {
3000
+ const e = new Error("epics not in step " + processStepId + ": " + notInStep.join(", "));
3001
+ e.statusCode = 422;
3002
+ e.kind = "SPLIT_EPICS_NOT_IN_STEP";
3003
+ e.missing = notInStep;
3004
+ throw e;
3005
+ }
3006
+ const a = normalizeActor(actor);
3007
+ const newPs = normalizeProcessStep({
3008
+ id: uid("ps-"),
3009
+ projectId: s.project.id,
3010
+ name: name,
3011
+ description: (opts && typeof opts.description === "string") ? opts.description : "",
3012
+ sortOrder: (orig.sortOrder || 0) + 1, // renumbered below
3013
+ createdBy: a, updatedBy: a,
3014
+ createdAt: now(), updatedAt: now(),
3015
+ version: 1
3016
+ });
3017
+ s.processSteps.push(markCowFresh(s, newPs));
3018
+ // Renumber the LIVE steps contiguously with newPs directly after orig.
3019
+ const live = s.processSteps.filter(p => !p.isDeleted && p.id !== newPs.id)
3020
+ .slice().sort((x, y) => (x.sortOrder || 0) - (y.sortOrder || 0));
3021
+ const order = [];
3022
+ for (const p of live) {
3023
+ order.push(p.id);
3024
+ if (p.id === processStepId) order.push(newPs.id);
3025
+ }
3026
+ order.forEach((id, idx) => {
3027
+ const probe = s.processSteps.find(p => p.id === id);
3028
+ if (!probe || probe.sortOrder === idx) return;
3029
+ const ps = cowProcessStep(s, id);
3030
+ ps.sortOrder = idx;
3031
+ if (id !== newPs.id) bumpAudit(ps, actor);
3032
+ });
3033
+ // Move the chosen epics; contained stories follow via the SM-67 cascade.
3034
+ for (const id of epicIds) {
3035
+ const epic = findTicket(s, id); // COW-aware
3036
+ epic.position = normalizePosition(Object.assign({}, epic.position, {
3037
+ processStepId: newPs.id
3038
+ }));
3039
+ bumpAudit(epic, actor);
3040
+ _cascadeEpicPositionToContainedStories(s, id, actor);
3041
+ }
3042
+ return s;
3043
+ },
3044
+
3045
+ /**
3046
+ * Reorder tickets within a scope (epic, cell, or backlog). Mirrors the
3047
+ * exact shape of reorderProcessSteps: takes an `orderedIds` array and
3048
+ * assigns sortOrder = idx to each. If `scope` is provided (with
3049
+ * releaseId/processStepId/epicId fields), it is ALSO applied to every
3050
+ * ticket in the list — which covers cross-container moves cleanly.
3051
+ * Tickets not in orderedIds are untouched.
3052
+ */
3053
+ reorderTickets(snap, orderedIds, scope, actor) {
3054
+ const s = cowSnap(snap);
3055
+ const byId = new Map(s.tickets.map(t => [t.id, t]));
3056
+ // SM-52: scope.epicId is interpreted as the target container; it never
3057
+ // lands on position.epicId. We re-parent via contains-links instead.
3058
+ const scopeEpicId = scope && typeof scope.epicId === "string" ? scope.epicId : null;
3059
+ // SM-67: when scope re-parents into an epic, the epic's release+processStep
3060
+ // dictate the final position for contained stories — override whatever
3061
+ // releaseId/processStepId scope passed in.
3062
+ const epicPos = scopeEpicId ? _inheritPositionFromEpic(s, scopeEpicId, {}) : null;
3063
+ orderedIds.forEach((id, idx) => {
3064
+ const probe = byId.get(id);
3065
+ if (!probe || probe.isDeleted) return;
3066
+ const t = cowTicket(s, id); // COW-aware (SM-240)
3067
+ if (scope) {
3068
+ const useInherit = (t.type !== "epic" && epicPos);
3069
+ t.position = normalizePosition({
3070
+ releaseId: useInherit ? (epicPos.releaseId || null)
3071
+ : (scope.releaseId != null ? scope.releaseId : null),
3072
+ processStepId: useInherit ? (epicPos.processStepId || null)
3073
+ : (scope.processStepId != null ? scope.processStepId : null),
3074
+ epicId: null, // never persisted
3075
+ sortOrder: idx
3076
+ });
3077
+ if (t.type !== "epic") _setContainerEpic(s, id, scopeEpicId, actor);
3078
+ // SM-67: if an epic is among the reordered tickets and scope changed
3079
+ // its position, cascade to its contained stories.
3080
+ if (t.type === "epic") {
3081
+ _cascadeEpicPositionToContainedStories(s, id, actor);
3082
+ }
3083
+ } else {
3084
+ t.position = normalizePosition(Object.assign({}, t.position, { sortOrder: idx, epicId: null }));
3085
+ }
3086
+ bumpAudit(t, actor);
3087
+ });
3088
+ recomputeEpicStatuses(s); // SM-236 (scope.epicId re-parents → roll up)
3089
+ return s;
3090
+ },
3091
+
3092
+ // SM-44 — typed ticket links --------------------------------------------
3093
+ //
3094
+ // ticket.links is an array of `{id, linkTypeId, targetTicketId, label?,
3095
+ // createdAt, createdBy}`. linkTypeId references a project.linkTypes entry
3096
+ // (SM-45 will introduce the per-project list; defaults work without it).
3097
+ //
3098
+ // Validation enforced on add/setLinks:
3099
+ // - self-link forbidden (sourceId === targetId)
3100
+ // - target must exist in snapshot.tickets
3101
+ // - duplicate forbidden (same linkTypeId + targetTicketId)
3102
+ // - for cycle-checked types (blocks, predecessor-of, contains): adding
3103
+ // the link must not introduce a cycle in the same-type subgraph.
3104
+
3105
+ addLink(snap, sourceTicketId, link, actor) {
3106
+ const s = cowSnap(snap);
3107
+ const source = findTicket(s, sourceTicketId); // COW-aware (SM-240)
3108
+ if (!source) {
3109
+ const e = new Error("source ticket not found: " + sourceTicketId);
3110
+ e.statusCode = 404;
3111
+ throw e;
3112
+ }
3113
+ const normalized = normalizeLink(link || {});
3114
+ // Stamp the link's createdBy from the acting actor when the caller didn't
3115
+ // supply one explicitly — otherwise normalizeLink defaults it to the
3116
+ // "unknown" fallback even though we know who is adding the link. (SM-149)
3117
+ if (actor != null && (!link || link.createdBy == null)) {
3118
+ normalized.createdBy = normalizeActor(actor);
3119
+ }
3120
+ validateLink(s, sourceTicketId, normalized, source.links || []);
3121
+ source.links = (source.links || []).concat([normalized]);
3122
+ bumpAudit(source, actor);
3123
+ recomputeEpicStatuses(s); // SM-236 (a contains-link adds a child to an epic)
3124
+ return s;
3125
+ },
3126
+
3127
+ removeLink(snap, sourceTicketId, linkId, actor) {
3128
+ const s = cowSnap(snap);
3129
+ const source = findTicket(s, sourceTicketId); // COW-aware (SM-240)
3130
+ if (!source) {
3131
+ const e = new Error("source ticket not found: " + sourceTicketId);
3132
+ e.statusCode = 404;
3133
+ throw e;
3134
+ }
3135
+ const before = (source.links || []).length;
3136
+ source.links = (source.links || []).filter(l => l.id !== linkId);
3137
+ if (source.links.length !== before) bumpAudit(source, actor);
3138
+ recomputeEpicStatuses(s); // SM-236 (removing a contains-link drops a child)
3139
+ return s;
3140
+ },
3141
+
3142
+ setLinks(snap, sourceTicketId, links, actor) {
3143
+ const s = cowSnap(snap);
3144
+ const source = findTicket(s, sourceTicketId); // COW-aware (SM-240)
3145
+ if (!source) {
3146
+ const e = new Error("source ticket not found: " + sourceTicketId);
3147
+ e.statusCode = 404;
3148
+ throw e;
3149
+ }
3150
+ const normalized = (Array.isArray(links) ? links : []).map(normalizeLink);
3151
+ // Validate each link against the snapshot. Run validation against the
3152
+ // accumulating set so duplicates within the new list are also caught.
3153
+ const accepted = [];
3154
+ for (const link of normalized) {
3155
+ validateLink(s, sourceTicketId, link, accepted);
3156
+ accepted.push(link);
3157
+ }
3158
+ source.links = accepted;
3159
+ bumpAudit(source, actor);
3160
+ recomputeEpicStatuses(s); // SM-236 (contains-links may have changed)
3161
+ return s;
3162
+ }
3163
+ };
3164
+
3165
+ // ---------------------------------------------------------------------------
3166
+ // SM-254 — default scaffold. Every project should be immediately usable: a
3167
+ // fresh project gets one default release + one default process step, so the
3168
+ // Story Map has a real cell from the first load. Seeded by BOTH server and MCP
3169
+ // create paths (the UI no longer double-seeds). Idempotent: only fills what's
3170
+ // missing, so re-running it (or running it on a non-empty snapshot) is a no-op.
3171
+ const DEFAULT_RELEASE_NAME = "v1.0";
3172
+ const DEFAULT_PROCESS_STEP_NAME = "Activities";
3173
+
3174
+ function seedDefaultScaffold(snap, actor) {
3175
+ let s = snap;
3176
+ const hasRelease = (s.releases || []).some(r => !r.isDeleted);
3177
+ const hasStep = (s.processSteps || []).some(p => !p.isDeleted);
3178
+ if (!hasRelease) s = ops.createRelease(s, { name: DEFAULT_RELEASE_NAME }, actor);
3179
+ if (!hasStep) s = ops.createProcessStep(s, { name: DEFAULT_PROCESS_STEP_NAME }, actor);
3180
+ return s;
3181
+ }
3182
+
3183
+ // ---------------------------------------------------------------------------
3184
+ // SM-240 — op-path normalize-invariance: derivedHealth post-pass.
3185
+ //
3186
+ // normalizeSnapshot annotates every test-definition with its derivedHealth
3187
+ // (_annotateDerivedHealth). With the store no longer running a full normalize
3188
+ // on the op path, the ops themselves must leave that projection consistent —
3189
+ // empirically the ONLY normalize-divergence an op can produce (the COW
3190
+ // guardrail suite proves it per op). Instead of analysing 35 ops one by one
3191
+ // (and re-analysing every future op), every op is wrapped: when its output is
3192
+ // a COW snapshot, refresh derivedHealth copy-on-write — touching only the
3193
+ // test-definitions whose value actually changed, so structural sharing for
3194
+ // everything else is preserved.
3195
+ // ---------------------------------------------------------------------------
3196
+
3197
+ function refreshDerivedHealthCow(s) {
3198
+ const tickets = s.tickets || [];
3199
+ for (const probe of tickets.slice()) {
3200
+ if (probe.type !== "test-definition" || probe.isDeleted) continue;
3201
+ const h = computeDerivedHealth(s, probe);
3202
+ if ((probe.derivedHealth || "") === h) continue;
3203
+ const td = cowTicket(s, probe.id);
3204
+ td.derivedHealth = h;
3205
+ }
3206
+ }
3207
+
3208
+ for (const _opName of Object.keys(ops)) {
3209
+ const _orig = ops[_opName];
3210
+ ops[_opName] = function () {
3211
+ const out = _orig.apply(this, arguments);
3212
+ if (out && out.__cowFresh) refreshDerivedHealthCow(out);
3213
+ return out;
3214
+ };
3215
+ }
3216
+
3217
+ // ---------------------------------------------------------------------------
3218
+ // SM-44 — link validation helpers (pure, throwing)
3219
+ // ---------------------------------------------------------------------------
3220
+
3221
+ function validateLink(snap, sourceTicketId, link, existingLinks) {
3222
+ if (!link.targetTicketId) {
3223
+ const e = new Error("link.targetTicketId is required");
3224
+ e.statusCode = 400; e.kind = "LINK_TARGET_REQUIRED";
3225
+ throw e;
3226
+ }
3227
+ if (link.targetTicketId === sourceTicketId) {
3228
+ const e = new Error("self-link forbidden (source === target)");
3229
+ e.statusCode = 400; e.kind = "LINK_SELF";
3230
+ throw e;
3231
+ }
3232
+ const target = snap.tickets.find(t => t.id === link.targetTicketId);
3233
+ if (!target) {
3234
+ const e = new Error("link.targetTicketId not found: " + link.targetTicketId);
3235
+ e.statusCode = 404; e.kind = "LINK_TARGET_MISSING";
3236
+ throw e;
3237
+ }
3238
+ // Duplicate-check on (linkTypeId, targetTicketId).
3239
+ for (const existing of existingLinks) {
3240
+ if (existing.linkTypeId === link.linkTypeId && existing.targetTicketId === link.targetTicketId) {
3241
+ const e = new Error("duplicate link (" + link.linkTypeId + " -> " + link.targetTicketId + ")");
3242
+ e.statusCode = 409; e.kind = "LINK_DUPLICATE";
3243
+ throw e;
3244
+ }
3245
+ }
3246
+ // Cycle-check for directional semantics. SM-45: resolve via the
3247
+ // project's linkType catalogue when available — falls back to the
3248
+ // hardcoded ID list (SM-44 behaviour) if linkTypes is missing or the
3249
+ // ID isn't registered. A cycle in the same-linkType subgraph is
3250
+ // rejected.
3251
+ if (isCycleChecked(snap.project, link.linkTypeId)) {
3252
+ if (wouldCreateCycle(snap, sourceTicketId, link.linkTypeId, link.targetTicketId)) {
3253
+ const e = new Error("link would create a cycle (" + link.linkTypeId + ")");
3254
+ e.statusCode = 409; e.kind = "LINK_CYCLE";
3255
+ throw e;
3256
+ }
3257
+ }
3258
+ }
3259
+
3260
+ /**
3261
+ * SM-45: resolve whether a given linkTypeId is cycle-checked. Prefer the
3262
+ * project's linkType.semantic lookup; fall back to the SM-44 hardcoded
3263
+ * ID list (so snapshots created before SM-45 still behave identically).
3264
+ */
3265
+ function isCycleChecked(project, linkTypeId) {
3266
+ if (project && Array.isArray(project.linkTypes)) {
3267
+ const lt = project.linkTypes.find(t => t.id === linkTypeId);
3268
+ if (lt) {
3269
+ // SM-94: explicit per-linkType cycleCheck:true overrides semantic check.
3270
+ if (lt.cycleCheck === true) return true;
3271
+ return CYCLE_CHECKED_SEMANTICS.indexOf(lt.semantic) >= 0;
3272
+ }
3273
+ }
3274
+ // Fallback for snapshots/tests without a registered linkTypes catalogue.
3275
+ return CYCLE_CHECKED_LINK_TYPES.indexOf(linkTypeId) >= 0;
3276
+ }
3277
+
3278
+ /**
3279
+ * Build the existing same-linkTypeId adjacency (source → set<target>) and
3280
+ * check if `target` can reach `source` by following edges of the same type.
3281
+ * If yes, adding source → target would close a cycle.
3282
+ */
3283
+ function wouldCreateCycle(snap, sourceId, linkTypeId, targetId) {
3284
+ if (sourceId === targetId) return true; // belt+suspenders; caller already checked
3285
+ const adj = new Map();
3286
+ for (const t of snap.tickets) {
3287
+ for (const l of t.links || []) {
3288
+ if (l.linkTypeId !== linkTypeId) continue;
3289
+ if (!adj.has(t.id)) adj.set(t.id, new Set());
3290
+ adj.get(t.id).add(l.targetTicketId);
3291
+ }
3292
+ }
3293
+ // BFS from target — if we can reach source, the new edge source→target
3294
+ // would form a cycle.
3295
+ const visited = new Set([targetId]);
3296
+ const queue = [targetId];
3297
+ while (queue.length > 0) {
3298
+ const cur = queue.shift();
3299
+ if (cur === sourceId) return true;
3300
+ const neighbours = adj.get(cur);
3301
+ if (!neighbours) continue;
3302
+ for (const n of neighbours) {
3303
+ if (visited.has(n)) continue;
3304
+ visited.add(n);
3305
+ queue.push(n);
3306
+ }
3307
+ }
3308
+ return false;
3309
+ }
3310
+
3311
+ // ---------------------------------------------------------------------------
3312
+ // Transition rule catalog + walker (SM-102 / SM-103)
3313
+ // ---------------------------------------------------------------------------
3314
+ //
3315
+ // A closed, declarative catalog of transition gates. Each rule owns BOTH its
3316
+ // applicability (`enabledFor(type, project)`) and its check
3317
+ // (`check(ticket, project, ctx)`). This makes the SM-100 / SM-101 bug class
3318
+ // structurally impossible: a rule cannot fire for a type whose backing field
3319
+ // is configured off, because the same predicate that hides the field is the
3320
+ // one that gates the rule.
3321
+ //
3322
+ // Rule shape:
3323
+ // {
3324
+ // id: string, // stable catalog key
3325
+ // label: string, // human-readable
3326
+ // enabledFor(type, project): boolean, // does this rule apply to the type?
3327
+ // check(ticket, project, ctx): // null = satisfied,
3328
+ // null | { kind, missing?, message? } // non-null = violation
3329
+ // }
3330
+ //
3331
+ // `ctx` is the transition context { currIdx, nextIdx, toStatus, toCategory }.
3332
+ //
3333
+ // SM-103 shipped the seam (catalog + walker). SM-104 fills in the DoR/DoD
3334
+ // rules and wires the walker into validateStatusTransition; the SM-101
3335
+ // lockstep skip now lives INSIDE each rule's `enabledFor`. SM-105/106 add the
3336
+ // global test-type rules and remove the last hardcoded gate blocks.
3337
+
3338
+ const TRANSITION_RULES = {
3339
+ "dor.allRequiredMet": {
3340
+ id: "dor.allRequiredMet",
3341
+ label: "Definition of Ready met",
3342
+ enabledFor: (type, project) =>
3343
+ getEntityTypeConfig(project || {}, type).showDefinitionOfReady !== false,
3344
+ check: (ticket) => {
3345
+ const items = (ticket && ticket.definitionOfReady && ticket.definitionOfReady.items) || [];
3346
+ const missing = items.filter(it => it.required !== false && !it.checked);
3347
+ if (missing.length === 0) return null;
3348
+ return {
3349
+ kind: "DoR",
3350
+ message: "definition of ready not met",
3351
+ missing: missing.map(it => ({ id: it.id, label: it.label }))
3352
+ };
3353
+ }
3354
+ },
3355
+ "dod.allRequiredMet": {
3356
+ id: "dod.allRequiredMet",
3357
+ label: "Definition of Done met",
3358
+ enabledFor: (type, project) =>
3359
+ getEntityTypeConfig(project || {}, type).showDefinitionOfDone !== false,
3360
+ check: (ticket) => {
3361
+ const items = (ticket && ticket.definitionOfDone && ticket.definitionOfDone.items) || [];
3362
+ const missing = items.filter(it => it.required !== false && !it.checked);
3363
+ if (missing.length === 0) return null;
3364
+ return {
3365
+ kind: "DoD",
3366
+ message: "definition of done not met",
3367
+ missing: missing.map(it => ({ id: it.id, label: it.label }))
3368
+ };
3369
+ }
3370
+ },
3371
+ // SM-105: test-definition backlog-exit gate. A test must point at ≥1 ticket
3372
+ // it validates (outbound `tests` link) before leaving backlog. This is a
3373
+ // GLOBAL rule (see GLOBAL_TRANSITION_RULES) — it isn't attached to a
3374
+ // workflow transition; the backlog-exit condition lives in check via ctx.
3375
+ "links.hasTestTarget": {
3376
+ id: "links.hasTestTarget",
3377
+ label: "Test-definition links to at least one target",
3378
+ enabledFor: (type) => type === "test-definition",
3379
+ check: (ticket, project, ctx) => {
3380
+ // Only gate the transition OUT of backlog (currIdx === 0). A test can
3381
+ // leave backlog once; later forward moves aren't re-checked (a user may
3382
+ // have removed the last link after the fact — the gate is on the exit).
3383
+ if (!ctx || ctx.currIdx !== 0) return null;
3384
+ const links = Array.isArray(ticket && ticket.links) ? ticket.links : [];
3385
+ const hasTarget = links.some(l => l && l.linkTypeId === TEST_TARGET_LINK_TYPE_ID);
3386
+ if (hasTarget) return null;
3387
+ return {
3388
+ kind: "TEST_TARGETS",
3389
+ message: "a test-definition must link to at least one ticket it tests (linkType: 'tests') before leaving backlog"
3390
+ };
3391
+ }
3392
+ },
3393
+ // SM-106: test-execution outcome gate. Moving a test-execution to a
3394
+ // done-category status requires a non-pending outcome (the outcome IS the
3395
+ // gate — the DoD checklist isn't the right tool for tests). enabledFor uses
3396
+ // showTestOutcome — lockstep by design: hide the outcome section ⇒ gate N/A.
3397
+ "outcome.notPending": {
3398
+ id: "outcome.notPending",
3399
+ label: "Test-execution outcome is not pending",
3400
+ enabledFor: (type, project) =>
3401
+ getEntityTypeConfig(project || {}, type).showTestOutcome !== false,
3402
+ check: (ticket, project, ctx) => {
3403
+ if (!ctx || ctx.toCategory !== "done") return null;
3404
+ if (getEffectiveOutcome(ticket) !== "pending") return null;
3405
+ return {
3406
+ kind: "TEST_OUTCOME",
3407
+ message: "test-execution outcome is still pending — record per-step results or set outcomeOverride before closing"
3408
+ };
3409
+ }
3410
+ }
3411
+ };
3412
+
3413
+ // Rules evaluated on EVERY forward transition, in addition to the matched
3414
+ // transition's own rules. Their applicability is decided entirely by each
3415
+ // rule's enabledFor + the transition ctx inside its check (e.g. backlog-exit
3416
+ // only, or done-category only). These are the gates that used to be hardcoded
3417
+ // as type-checks inside validateStatusTransition.
3418
+ const GLOBAL_TRANSITION_RULES = ["links.hasTestTarget", "outcome.notPending"];
3419
+
3420
+ /**
3421
+ * Legacy adapter: map a workflow transition to its catalog rule ids. Existing
3422
+ * snapshots carry `requireGate: 'DoR'|'DoD'`; newer ones may carry an explicit
3423
+ * `rules: [...]`. requireGate wins (back-compat); otherwise the transition's
3424
+ * own rules[] is used. No snapshot migration needed.
3425
+ */
3426
+ function transitionRuleIds(transition) {
3427
+ if (!transition) return [];
3428
+ if (transition.requireGate === "DoR") return ["dor.allRequiredMet"];
3429
+ if (transition.requireGate === "DoD") return ["dod.allRequiredMet"];
3430
+ return Array.isArray(transition.rules) ? transition.rules : [];
3431
+ }
3432
+
3433
+ /**
3434
+ * Pure walker. Evaluates the given rule ids against the catalog and returns
3435
+ * the list of violations in ruleIds order. A rule is skipped when its id is
3436
+ * not in the catalog or when `enabledFor(type, project)` is false.
3437
+ *
3438
+ * @param {string[]} ruleIds
3439
+ * @param {{ ticket: object, project: object, ctx?: object }} context
3440
+ * @param {object} [catalog] defaults to TRANSITION_RULES; tests inject their own.
3441
+ * @returns {Array<{ kind, missing?, message? }>}
3442
+ */
3443
+ function evaluateTransitionRules(ruleIds, context, catalog) {
3444
+ catalog = catalog || TRANSITION_RULES;
3445
+ const ticket = context && context.ticket;
3446
+ const project = context && context.project;
3447
+ const ctx = context && context.ctx;
3448
+ const type = ticket && ticket.type;
3449
+ const failures = [];
3450
+ const ids = Array.isArray(ruleIds) ? ruleIds : [];
3451
+ for (const id of ids) {
3452
+ const rule = catalog[id];
3453
+ if (!rule) continue;
3454
+ if (typeof rule.enabledFor === "function" && !rule.enabledFor(type, project)) continue;
3455
+ const result = typeof rule.check === "function" ? rule.check(ticket, project, ctx) : null;
3456
+ if (result) failures.push(result);
3457
+ }
3458
+ return failures;
3459
+ }
3460
+
3461
+ // ---------------------------------------------------------------------------
3462
+ // validateStatusTransition — DoR / DoD-Gate-Check (pure, throwing)
3463
+ // ---------------------------------------------------------------------------
3464
+ //
3465
+ // Identische Logik wie server/validation.js#changeStatusTransition (das ist
3466
+ // jetzt ein Thin-Delegate). Wurde nach core gezogen, damit das Frontend
3467
+ // dieselbe Validierung VOR dem `store.changeStatus`-Commit fahren kann —
3468
+ // snapshot-PUT bypasst Transition-Validation by design (state-replace,
3469
+ // nicht workflow-step), also muss das Gate clientseitig greifen, bevor
3470
+ // der Commit überhaupt entsteht. Wirft `{statusCode, kind, missing}` bei
3471
+ // Verletzung.
3472
+
3473
+ function validateStatusTransition(ticket, newStatus, project) {
3474
+ // SM-237: an epic's status is DERIVED (roll-up from its contained stories) —
3475
+ // no path may set it manually. This is the single choke-point shared by the
3476
+ // REST status route, store.changeStatusGated and the MCP status tools.
3477
+ if (ticket && ticket.type === "epic") {
3478
+ const e = new Error("epic status is derived from its contained stories — move the stories instead");
3479
+ e.statusCode = 422;
3480
+ e.kind = "EPIC_STATUS_DERIVED";
3481
+ throw e;
3482
+ }
3483
+ if (typeof newStatus !== "string") {
3484
+ const e = new Error("status invalid: " + newStatus);
3485
+ e.statusCode = 400;
3486
+ throw e;
3487
+ }
3488
+ const wf = getWorkflowForType(project || {}, ticket && ticket.type);
3489
+ const statusIds = wf.statuses.map(s => (typeof s === "string" ? s : s.id));
3490
+ if (!statusIds.includes(newStatus)) {
3491
+ const e = new Error("status invalid: " + newStatus + " (workflow: " + statusIds.join(", ") + ")");
3492
+ e.statusCode = 400;
3493
+ throw e;
3494
+ }
3495
+ // Reverse moves (target index < current index) and same-state moves are
3496
+ // unrestricted: a Reopen-style move shouldn't require gates. Forward
3497
+ // moves go through the named-transition matcher.
3498
+ const order = {};
3499
+ statusIds.forEach((id, i) => { order[id] = i; });
3500
+ const currIdx = order[ticket.status];
3501
+ const nextIdx = order[newStatus];
3502
+ if (currIdx == null || nextIdx == null || nextIdx <= currIdx) return;
3503
+ // Find matching named transition: target equals newStatus AND
3504
+ // (allowFromAny OR ticket.status in fromStatuses).
3505
+ const transitions = Array.isArray(wf.transitions) ? wf.transitions : [];
3506
+ const matching = transitions.filter(tr =>
3507
+ tr.toStatus === newStatus &&
3508
+ (tr.allowFromAny || (Array.isArray(tr.fromStatuses) && tr.fromStatuses.includes(ticket.status)))
3509
+ );
3510
+ if (matching.length === 0) {
3511
+ const e = new Error("transition not allowed: " + ticket.status + " → " + newStatus);
3512
+ e.statusCode = 422; e.kind = "TRANSITION";
3513
+ throw e;
3514
+ }
3515
+ // If multiple match (source-specific + allowFromAny), prefer the source-
3516
+ // specific one — that's the more restrictive author intent. Both should
3517
+ // typically carry the same gate, but if not, the specific transition's
3518
+ // gate wins.
3519
+ matching.sort((a, b) => Number(!!a.allowFromAny) - Number(!!b.allowFromAny));
3520
+ const matched = matching[0];
3521
+ // SM-104: gate evaluation is table-driven. The matched transition's
3522
+ // requireGate (or explicit rules[]) maps to catalog rule ids; each rule
3523
+ // decides its OWN applicability via enabledFor — the SM-101 lockstep skip
3524
+ // (entityTypeConfig hides the section ⇒ gate N/A) now lives inside the
3525
+ // rule, not here. The first violation is thrown with the same shape as
3526
+ // before ({ statusCode: 422, kind, missing? }).
3527
+ const wfStatus = wf.statuses.find(s => (typeof s === "string" ? s : s.id) === newStatus);
3528
+ const toCategory = wfStatus && typeof wfStatus === "object" ? wfStatus.category : null;
3529
+ const ctx = { currIdx, nextIdx, toStatus: newStatus, toCategory };
3530
+ const failures = evaluateTransitionRules(
3531
+ [...transitionRuleIds(matched), ...GLOBAL_TRANSITION_RULES],
3532
+ { ticket, project, ctx });
3533
+ if (failures.length > 0) {
3534
+ const f = failures[0];
3535
+ const e = new Error(f.message || "transition gate not met");
3536
+ e.statusCode = 422;
3537
+ e.kind = f.kind;
3538
+ if (f.missing) e.missing = f.missing;
3539
+ throw e;
3540
+ }
3541
+ // SM-106: validateStatusTransition is now fully walker-driven — every gate
3542
+ // (DoR, DoD, test-targets, test-outcome) is a declarative catalog rule. No
3543
+ // type-specific gate code remains here.
3544
+ }
3545
+
3546
+ // ---------------------------------------------------------------------------
3547
+ // tickets — render-aggregation helpers (consumed by the Story-Map renderer)
3548
+ // ---------------------------------------------------------------------------
3549
+ //
3550
+ // Diese Helper sind PURE — sie selektieren/sortieren nur, mutieren nichts.
3551
+ // Sie konsumieren das normalisierte Snapshot-Format und liefern Listen,
3552
+ // die der Renderer 1:1 in Zellen / Spalten / Zeilen umsetzt. Tests gegen
3553
+ // die Helper sind robuster als Tests gegen den Renderer.
3554
+
3555
+ function _byTypeStory(t) {
3556
+ // SM-196: spec-layer types (requirement/spec-module) are NOT story cards —
3557
+ // they never appear in story-map cells or the backlog. isBoardWorkItem also
3558
+ // excludes epics (containers), preserving the prior behaviour.
3559
+ return !t.isDeleted && isBoardWorkItem(t.type);
3560
+ }
3561
+ function _byTypeEpic(t) {
3562
+ return !t.isDeleted && t.type === "epic";
3563
+ }
3564
+ function _sortBySortOrder(a, b) {
3565
+ return ((a.position && a.position.sortOrder) || 0) - ((b.position && b.position.sortOrder) || 0);
3566
+ }
3567
+
3568
+ // SM-196 R-2: natural compare of dotted section paths ("2.1" < "2.10" < "10.1").
3569
+ // Each dot-segment is compared numerically when both sides are numeric, else
3570
+ // lexically — so "2.1a" still orders sanely. Empty paths sort first.
3571
+ function compareSectionPath(a, b) {
3572
+ const pa = String(a == null ? "" : a).split(".");
3573
+ const pb = String(b == null ? "" : b).split(".");
3574
+ const n = Math.max(pa.length, pb.length);
3575
+ for (let i = 0; i < n; i++) {
3576
+ const sa = pa[i] === undefined ? "" : pa[i];
3577
+ const sb = pb[i] === undefined ? "" : pb[i];
3578
+ const na = parseInt(sa, 10), nb = parseInt(sb, 10);
3579
+ const bothNum = String(na) === sa && String(nb) === sb;
3580
+ if (bothNum) {
3581
+ if (na !== nb) return na - nb;
3582
+ } else if (sa !== sb) {
3583
+ return sa < sb ? -1 : 1;
3584
+ }
3585
+ }
3586
+ return 0;
3587
+ }
3588
+
3589
+ const tickets = {
3590
+ /**
3591
+ * SM-58: history of test-execution tickets for a given test-definition,
3592
+ * sorted by `runAt` descending (newest first). Reads the definition's
3593
+ * inbound "executes" links from every test-execution ticket. Optional
3594
+ * `limit` caps the result list.
3595
+ */
3596
+ testExecHistory(snapshot, definitionId, opts) {
3597
+ if (!snapshot || !Array.isArray(snapshot.tickets) || typeof definitionId !== "string") {
3598
+ return [];
3599
+ }
3600
+ const out = [];
3601
+ for (const t of snapshot.tickets) {
3602
+ if (t.isDeleted || t.type !== "test-execution") continue;
3603
+ if (!Array.isArray(t.links)) continue;
3604
+ const has = t.links.some(l =>
3605
+ (l.linkTypeId || l.type) === "executes" && l.targetTicketId === definitionId);
3606
+ if (has || t.referencedTestDefinitionId === definitionId) out.push(t);
3607
+ }
3608
+ out.sort((a, b) => (b.runAt || 0) - (a.runAt || 0));
3609
+ const limit = opts && typeof opts.limit === "number" ? opts.limit : null;
3610
+ return limit != null ? out.slice(0, Math.max(0, limit)) : out;
3611
+ },
3612
+
3613
+ /** Epic-Karten in einer konkreten (releaseId, processStepId)-Zelle. */
3614
+ epicsInCell(snapshot, releaseId, processStepId) {
3615
+ return (snapshot.tickets || [])
3616
+ .filter(_byTypeEpic)
3617
+ .filter(t => t.position
3618
+ && t.position.releaseId === releaseId
3619
+ && t.position.processStepId === processStepId)
3620
+ .slice()
3621
+ .sort(_sortBySortOrder);
3622
+ },
3623
+
3624
+ /**
3625
+ * SM-248 — every epic positioned in a process step ACROSS ALL RELEASES,
3626
+ * each annotated with its release (id+name) and contained-story count.
3627
+ * Powers the Process-Step editor's "epic hull" + the split-dialog epic
3628
+ * picker. Sorted by release sortOrder then epic sortOrder so the list
3629
+ * reads top-to-bottom like the story-map column.
3630
+ */
3631
+ epicsInProcessStep(snapshot, processStepId) {
3632
+ const releaseById = new Map(
3633
+ (snapshot.releases || []).map(r => [r.id, r]));
3634
+ return (snapshot.tickets || [])
3635
+ .filter(_byTypeEpic)
3636
+ .filter(t => t.position && t.position.processStepId === processStepId)
3637
+ .slice()
3638
+ .sort((a, b) => {
3639
+ const ra = releaseById.get(a.position && a.position.releaseId);
3640
+ const rb = releaseById.get(b.position && b.position.releaseId);
3641
+ const rd = ((ra && ra.sortOrder) || 0) - ((rb && rb.sortOrder) || 0);
3642
+ return rd !== 0 ? rd : _sortBySortOrder(a, b);
3643
+ })
3644
+ .map(epic => {
3645
+ const rel = releaseById.get(epic.position && epic.position.releaseId) || null;
3646
+ return {
3647
+ epic: epic,
3648
+ releaseId: rel ? rel.id : null,
3649
+ releaseName: rel ? rel.name : null,
3650
+ storyCount: tickets.storiesInEpic(snapshot, epic.id).length
3651
+ };
3652
+ });
3653
+ },
3654
+
3655
+ /** Story-Karten, die zu einem bestimmten Epic gehören. SM-52: containment
3656
+ * is canonical via the epic's outgoing `contains` links. */
3657
+ storiesInEpic(snapshot, epicId) {
3658
+ const epic = (snapshot.tickets || []).find(t => t.id === epicId);
3659
+ if (!epic || !epic.links || !epic.links.length) return [];
3660
+ const containedIds = new Set();
3661
+ for (const l of epic.links) {
3662
+ const lt = l.linkTypeId || l.type;
3663
+ if (lt === CONTAINS_LINK_TYPE_ID) containedIds.add(l.targetTicketId);
3664
+ }
3665
+ return (snapshot.tickets || [])
3666
+ .filter(_byTypeStory)
3667
+ .filter(t => containedIds.has(t.id))
3668
+ .slice()
3669
+ .sort(_sortBySortOrder);
3670
+ },
3671
+
3672
+ /** Return the epic that currently contains the given story (via inbound
3673
+ * contains-link), or null. SM-52. */
3674
+ epicForStory(snapshot, storyId) {
3675
+ for (const t of (snapshot.tickets || [])) {
3676
+ if (t.isDeleted || t.type !== "epic" || !t.links) continue;
3677
+ for (const l of t.links) {
3678
+ const lt = l.linkTypeId || l.type;
3679
+ if (lt === CONTAINS_LINK_TYPE_ID && l.targetTicketId === storyId) return t;
3680
+ }
3681
+ }
3682
+ return null;
3683
+ },
3684
+
3685
+ /**
3686
+ * Lose Tickets in einer (releaseId, processStepId)-Zelle: alle Nicht-Epic-
3687
+ * Tickets, die in der Zelle platziert sind, aber KEINEM Epic angehören.
3688
+ * SM-52: containment is read from the contains-link, not position.epicId.
3689
+ */
3690
+ looseTicketsInCell(snapshot, releaseId, processStepId) {
3691
+ const containerByStory = _containerEpicByStoryId(snapshot);
3692
+ return (snapshot.tickets || [])
3693
+ .filter(_byTypeStory)
3694
+ .filter(t => t.position
3695
+ && t.position.releaseId === releaseId
3696
+ && t.position.processStepId === processStepId
3697
+ && !containerByStory.has(t.id))
3698
+ .slice()
3699
+ .sort(_sortBySortOrder);
3700
+ },
3701
+
3702
+ /** Epics, die noch keiner Release zugeordnet sind — gehören in die
3703
+ * Backlog-Section am Ende der Story-Map (Sub-Gruppe "Unscheduled"). */
3704
+ backlogEpics(snapshot) {
3705
+ return (snapshot.tickets || [])
3706
+ .filter(_byTypeEpic)
3707
+ .filter(t => !t.position || t.position.releaseId == null)
3708
+ .slice()
3709
+ .sort(_sortBySortOrder);
3710
+ },
3711
+
3712
+ /** Stories ohne Release UND ohne Epic — orphan-Tickets (Backlog
3713
+ * "Unscheduled"-Gruppe). SM-52: containment via link. */
3714
+ orphanStories(snapshot) {
3715
+ const containerByStory = _containerEpicByStoryId(snapshot);
3716
+ return (snapshot.tickets || [])
3717
+ .filter(_byTypeStory)
3718
+ .filter(t => (!t.position || t.position.releaseId == null) && !containerByStory.has(t.id))
3719
+ .slice()
3720
+ .sort(_sortBySortOrder);
3721
+ },
3722
+
3723
+ /**
3724
+ * Tickets die EINER Release zugeordnet sind, aber noch nicht voll in der
3725
+ * Matrix platziert (Epic ohne processStepId, Story ohne epicId). Werden
3726
+ * im Backlog unter der jeweiligen Release als "Scheduled for X" gruppiert.
3727
+ *
3728
+ * Returnt eine Map<releaseId, {epics: Ticket[], stories: Ticket[]}>.
3729
+ */
3730
+ partiallyAssignedByRelease(snapshot) {
3731
+ const containerByStory = _containerEpicByStoryId(snapshot);
3732
+ const out = new Map();
3733
+ for (const t of (snapshot.tickets || [])) {
3734
+ if (t.isDeleted) continue;
3735
+ if (isSpecType(t.type)) continue; // SM-196: spec objects never sit on the board
3736
+ const pos = t.position || {};
3737
+ if (!pos.releaseId) continue; // unscheduled goes elsewhere
3738
+ if (t.type === "epic") {
3739
+ if (pos.processStepId) continue; // fully placed → in cell
3740
+ if (!out.has(pos.releaseId)) out.set(pos.releaseId, { epics: [], stories: [] });
3741
+ out.get(pos.releaseId).epics.push(t);
3742
+ } else {
3743
+ // SM-52: a story is "fully placed" if it has an epic container (via
3744
+ // contains-link) — not via the legacy position.epicId.
3745
+ if (containerByStory.has(t.id)) continue;
3746
+ if (pos.processStepId) continue; // loose ticket placed in cell — rendered there, not in backlog
3747
+ if (!out.has(pos.releaseId)) out.set(pos.releaseId, { epics: [], stories: [] });
3748
+ out.get(pos.releaseId).stories.push(t);
3749
+ }
3750
+ }
3751
+ for (const v of out.values()) {
3752
+ v.epics.sort(_sortBySortOrder);
3753
+ v.stories.sort(_sortBySortOrder);
3754
+ }
3755
+ return out;
3756
+ },
3757
+
3758
+ /** SM-196 R-2: all spec-module tickets, ordered by sortOrder. */
3759
+ specModules(snapshot) {
3760
+ return (snapshot.tickets || [])
3761
+ .filter(t => !t.isDeleted && t.type === "spec-module")
3762
+ .slice()
3763
+ .sort(_sortBySortOrder);
3764
+ },
3765
+
3766
+ /**
3767
+ * SM-196 R-2: the requirement-slices contained by a spec module, in stable
3768
+ * DOCUMENT order — by sectionPath first (the DOORS object id), sortOrder as
3769
+ * tiebreak. Containment is the module's outgoing `contains` links (same
3770
+ * canonical mechanism as epic→story). Returns [] for an unknown module.
3771
+ */
3772
+ requirementsInModule(snapshot, moduleId) {
3773
+ const mod = (snapshot.tickets || []).find(t => t.id === moduleId);
3774
+ if (!mod || !Array.isArray(mod.links)) return [];
3775
+ const contained = new Set();
3776
+ for (const l of mod.links) {
3777
+ if ((l.linkTypeId || l.type) === CONTAINS_LINK_TYPE_ID) contained.add(l.targetTicketId);
3778
+ }
3779
+ return (snapshot.tickets || [])
3780
+ .filter(t => !t.isDeleted && t.type === "requirement" && contained.has(t.id))
3781
+ .slice()
3782
+ .sort((a, b) => compareSectionPath(a.sectionPath, b.sectionPath) || _sortBySortOrder(a, b));
3783
+ }
3784
+ };
3785
+
3786
+ // ---------------------------------------------------------------------------
3787
+ // diffSnapshots
3788
+ // ---------------------------------------------------------------------------
3789
+
3790
+ function diffList(prev, next) {
3791
+ const prevById = new Map(prev.map(x => [x.id, x]));
3792
+ const nextById = new Map(next.map(x => [x.id, x]));
3793
+ const added = [], updated = [], removed = [];
3794
+ for (const [id, n] of nextById) {
3795
+ const p = prevById.get(id);
3796
+ if (!p) added.push(n);
3797
+ else if (JSON.stringify(p) !== JSON.stringify(n)) updated.push(n);
3798
+ }
3799
+ for (const [id, p] of prevById) {
3800
+ if (!nextById.has(id)) removed.push(p);
3801
+ }
3802
+ return { added, updated, removed };
3803
+ }
3804
+
3805
+ function diffSnapshots(prev, next) {
3806
+ prev = normalizeSnapshot(prev);
3807
+ next = normalizeSnapshot(next);
3808
+ return {
3809
+ tickets: diffList(prev.tickets, next.tickets),
3810
+ releases: diffList(prev.releases, next.releases),
3811
+ processSteps: diffList(prev.processSteps, next.processSteps)
3812
+ };
3813
+ }
3814
+
3815
+ // ---------------------------------------------------------------------------
3816
+ // Exports
3817
+ // ---------------------------------------------------------------------------
3818
+
3819
+ return {
3820
+ SCHEMA_VERSION,
3821
+ EPIC_BACKLOG_ID,
3822
+ STORY_BACKLOG_ID,
3823
+ DEFAULT_TICKET_TYPES,
3824
+ // SM-196 R-1 — spec-layer type helpers.
3825
+ SPEC_TYPES,
3826
+ isSpecType,
3827
+ isBoardWorkItem,
3828
+ normalizeSourceAnchor,
3829
+ compareSectionPath,
3830
+ DEFAULT_STATUSES,
3831
+ DEFAULT_RELEASE_STATUSES,
3832
+ DEFAULT_RELEASE_NAME,
3833
+ DEFAULT_PROCESS_STEP_NAME,
3834
+ seedDefaultScaffold,
3835
+ STORYMAPPER_DEFAULT_DEFINITIONS,
3836
+ STORYMAPPER_DEFAULT_WORKFLOW,
3837
+ STATUS_CATEGORIES,
3838
+ defaultCategoryForStatusId,
3839
+ humanizeStatusId,
3840
+ normalizeStatusItem,
3841
+ LIMITS,
3842
+ uid,
3843
+ now,
3844
+ normalizeActor,
3845
+ normalizeProject,
3846
+ getEntityTypeConfig,
3847
+ getWorkflowForType,
3848
+ normalizeTicket,
3849
+ // SM-53 / SM-56 — test-type helpers (pure)
3850
+ normalizeTestStep,
3851
+ normalizeTestPrerequisite,
3852
+ normalizeTestExecStep,
3853
+ deriveOutcome,
3854
+ getEffectiveOutcome,
3855
+ TEST_EXEC_STEP_STATUSES,
3856
+ TEST_EXEC_OUTCOMES,
3857
+ normalizeRelease,
3858
+ normalizeProcessStep,
3859
+ normalizeSnapshot,
3860
+ normalizeDefinitions,
3861
+ normalizeWorkflow,
3862
+ resolveDefinitions,
3863
+ buildTicketChecklists,
3864
+ ops,
3865
+ tickets,
3866
+ // SM-236 — derived epic status (roll-up)
3867
+ deriveEpicStatus,
3868
+ epicChildStats,
3869
+ recomputeEpicStatuses,
3870
+ releaseProgress,
3871
+ // SM-242 — cancel workflow helpers
3872
+ firstStatusOfCategory,
3873
+ statusCategoryOf,
3874
+ isTerminalStatus,
3875
+ validateStatusTransition,
3876
+ // SM-102 / SM-103 / SM-104 / SM-105 / SM-106 — declarative transition-rule catalog + walker
3877
+ TRANSITION_RULES,
3878
+ GLOBAL_TRANSITION_RULES,
3879
+ evaluateTransitionRules,
3880
+ transitionRuleIds,
3881
+ diffSnapshots,
3882
+ // SM-44
3883
+ CYCLE_CHECKED_LINK_TYPES,
3884
+ normalizeLink,
3885
+ validateLink,
3886
+ wouldCreateCycle,
3887
+ // SM-45
3888
+ LINK_SEMANTICS,
3889
+ CYCLE_CHECKED_SEMANTICS,
3890
+ STORYMAPPER_DEFAULT_LINK_TYPES,
3891
+ normalizeLinkType,
3892
+ // SM-52 / SM-54-followup
3893
+ CONTAINS_LINK_TYPE_ID,
3894
+ TEST_TARGET_LINK_TYPE_ID,
3895
+ containerEpicIdOf,
3896
+ // SM-93 — Governance layer (predicates + evaluator + templater + defaults)
3897
+ TEST_DEFINITION_LIFECYCLES,
3898
+ TEST_DEFINITION_HEALTH_STATES,
3899
+ STORYMAPPER_DEFAULT_GOVERNANCE,
3900
+ normalizeGovernance,
3901
+ GOVERNANCE_PREDICATES,
3902
+ evaluateGates,
3903
+ evaluateWarnings,
3904
+ renderMessage,
3905
+ computeDerivedHealth
3906
+ };
3907
+
3908
+ }));