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,361 @@
1
+ // SM-203 R-7: Requirements view — a DOORS-style module view.
2
+ //
3
+ // A scrollable document rendered as a multi-column table:
4
+ // SOURCE (the PRD, in document order — heading + the original prose) |
5
+ // ID (requirement key) | REQUIREMENT (the extracted statement) | COVERAGE.
6
+ // The SOURCE and REQUIREMENT text columns stay PURE so you can read the
7
+ // document and compare each slice against its source; all derived metadata
8
+ // (id, coverage, counts) lives in separate attribute columns. A source section
9
+ // that produced NO requirement is flagged `gap` (slicing-completeness).
10
+ //
11
+ // Per requirement you can expand the incoming traceability (clickable tickets
12
+ // that realise / test it) and act: an `orphan` offers "create implementing
13
+ // ticket"; a `gap` source section offers "create requirement".
14
+ //
15
+ // The pure `buildDocumentModel(snapshot, moduleId, sections)` is unit-tested;
16
+ // the DOM render + the async source fetch (injected via ctx.fetchIngest) are
17
+ // thin. ctx callbacks: fetchIngest, onOpenTicket, onCreateImplementer,
18
+ // onCreateRequirement.
19
+ //
20
+ // UMD-wrapped: same source runs in the browser AND require()s in Node tests.
21
+ (function (root, factory) {
22
+ if (typeof module === "object" && module.exports) {
23
+ module.exports = factory(require("./core.js"), require("./core/graph.js"));
24
+ } else {
25
+ (root.STORYMAP = root.STORYMAP || {}).viewRequirements = factory(
26
+ root.STORYMAP && root.STORYMAP.core,
27
+ root.STORYMAP && root.STORYMAP.coreGraph
28
+ );
29
+ }
30
+ }(typeof self !== "undefined" ? self : this, function (core, coreGraph) {
31
+ "use strict";
32
+
33
+ const STATUSES = ["covered", "over-covered", "orphan", "suspect"];
34
+
35
+ // SM-206: persisted Source/Requirement split (fraction of the flexible width
36
+ // the SOURCE column takes; the rest goes to Requirement). ID + Coverage are
37
+ // fixed attribute columns. Default favours the Source so the document reads
38
+ // wide. Resizable via a divider on the Source header.
39
+ const VR_ID_W = 52, VR_COV_W = 112; // fixed attribute columns (px)
40
+ const VR_FRAC_KEY = "storymap-vr-source-frac";
41
+ function readSourceFrac() {
42
+ try { const v = parseFloat(localStorage.getItem(VR_FRAC_KEY)); if (v >= 0.2 && v <= 0.85) return v; } catch (_e) {}
43
+ return 0.6;
44
+ }
45
+ function writeSourceFrac(f) { try { localStorage.setItem(VR_FRAC_KEY, String(f)); } catch (_e) {} }
46
+ function clampFrac(f) { return Math.max(0.2, Math.min(0.85, f)); }
47
+ function docGridCols(frac) {
48
+ const f = clampFrac(frac);
49
+ return f.toFixed(3) + "fr " + VR_ID_W + "px " + (1 - f).toFixed(3) + "fr " + VR_COV_W + "px";
50
+ }
51
+
52
+ function el(tag, attrs, text) {
53
+ const d = document.createElement(tag);
54
+ if (attrs) for (const k in attrs) {
55
+ if (k === "class") d.className = attrs[k];
56
+ else if (k === "dataset") for (const dk in attrs[k]) d.dataset[dk] = attrs[k][dk];
57
+ else d.setAttribute(k, attrs[k]);
58
+ }
59
+ if (text != null) d.textContent = text;
60
+ return d;
61
+ }
62
+
63
+ // ---- PURE: assemble the document model -----------------------------------
64
+ // With source sections → mode "doc": an ordered list of sections, each with
65
+ // its prose + the requirement(s) sliced from it (gap when none). Without
66
+ // source → mode "list": the requirement chain alone (sectionPath order).
67
+ // Incoming trace links are resolved to ticket summaries so they're clickable.
68
+ function buildDocumentModel(snapshot, moduleId, sections) {
69
+ const reqs = (moduleId && core.tickets) ? core.tickets.requirementsInModule(snapshot, moduleId) : [];
70
+ const cov = coreGraph.traceCoverage(snapshot, moduleId ? { moduleId } : {});
71
+ const statusById = new Map(cov.requirements.map(r => [r.id, r]));
72
+ const byId = new Map((snapshot.tickets || []).map(t => [t.id, t]));
73
+ const resolve = (ids) => (ids || []).map(id => {
74
+ const t = byId.get(id);
75
+ return t ? { id: t.id, ticketKey: t.ticketKey, title: t.title, type: t.type, status: t.status }
76
+ : { id: id, ticketKey: id, title: "", type: "", status: "" };
77
+ });
78
+ const requirements = reqs.map(r => {
79
+ const c = statusById.get(r.id) || { status: "orphan", realisedBy: [], testedBy: [] };
80
+ return {
81
+ id: r.id, ticketKey: r.ticketKey, sectionPath: r.sectionPath, title: r.title,
82
+ status: c.status, realisedBy: resolve(c.realisedBy), testedBy: resolve(c.testedBy),
83
+ anchorSectionId: (r.sourceAnchor && r.sourceAnchor.sectionId) || ""
84
+ };
85
+ });
86
+ // Group requirements under their document section by `sectionPath` — that is
87
+ // the field the section rows are matched on below. This is what makes the
88
+ // two-stage flow work (SM-207): one coarse slice → n atomic requirements, all
89
+ // sharing the slice's sectionPath but each carrying its own source char-span
90
+ // (sourceAnchor). Grouping on the anchor's sectionId instead would scatter
91
+ // those n requirements off their section whenever sectionId ≠ sectionPath,
92
+ // making them vanish from the doc view. Fall back to anchorSectionId only
93
+ // when a requirement has no sectionPath at all.
94
+ const bySection = new Map();
95
+ for (const r of requirements) {
96
+ const key = r.sectionPath || r.anchorSectionId;
97
+ if (!bySection.has(key)) bySection.set(key, []);
98
+ bySection.get(key).push(r);
99
+ }
100
+
101
+ if (sections && sections.length) {
102
+ let sliced = 0, gaps = 0;
103
+ const secRows = sections.map(s => {
104
+ const rs = bySection.get(s.sectionPath) || [];
105
+ if (rs.length) sliced++; else gaps++;
106
+ return { sectionPath: s.sectionPath, level: s.level || 1, heading: s.heading || "(preamble)",
107
+ body: s.body || "", gap: rs.length === 0, requirements: rs };
108
+ });
109
+ return { mode: "doc", sections: secRows, summary: cov.summary,
110
+ slicing: { sections: sections.length, sliced: sliced, gaps: gaps } };
111
+ }
112
+ return { mode: "list", requirements: requirements, summary: cov.summary, slicing: null };
113
+ }
114
+
115
+ function summaryChips(summary) {
116
+ const wrap = el("div", { class: "vr-summary" });
117
+ wrap.appendChild(el("span", { class: "vr-chip vr-chip-total" }, "Σ " + summary.total));
118
+ const map = { covered: summary.covered, "over-covered": summary.overCovered,
119
+ orphan: summary.orphan, suspect: summary.suspect };
120
+ for (const st of STATUSES) {
121
+ if (!map[st]) continue;
122
+ wrap.appendChild(el("span", { class: "vr-chip vr-badge-" + st }, st + " " + map[st]));
123
+ }
124
+ return wrap;
125
+ }
126
+
127
+ // The expandable detail under a requirement: its traceability + actions.
128
+ function requirementDetail(req, handlers) {
129
+ const detail = el("div", { class: "vr-detail" });
130
+ const chipLine = (links, rel) => {
131
+ const line = el("div", { class: "vr-trace-line" });
132
+ line.appendChild(el("span", { class: "vr-trace-rel" }, rel));
133
+ for (const t of links) {
134
+ const chip = el("button", { class: "vr-trace-chip", type: "button", title: "Open " + t.ticketKey });
135
+ chip.appendChild(el("span", { class: "vr-trace-key" }, t.ticketKey));
136
+ if (t.title) chip.appendChild(el("span", { class: "vr-trace-title" }, t.title));
137
+ chip.addEventListener("click", (ev) => { ev.stopPropagation(); if (handlers.onOpenTicket) handlers.onOpenTicket(t.id); });
138
+ line.appendChild(chip);
139
+ }
140
+ return line;
141
+ };
142
+ if (req.realisedBy.length) detail.appendChild(chipLine(req.realisedBy, "realised by"));
143
+ if (req.testedBy.length) detail.appendChild(chipLine(req.testedBy, "tested by"));
144
+ if (req.status === "orphan" && handlers.onCreateImplementer) {
145
+ const act = el("button", { class: "vr-action", type: "button" }, "+ implementing ticket");
146
+ act.addEventListener("click", (ev) => { ev.stopPropagation(); handlers.onCreateImplementer(req); });
147
+ detail.appendChild(act);
148
+ }
149
+ if (req.status === "suspect" && handlers.onCreateImplementer) {
150
+ const act = el("button", { class: "vr-action", type: "button" }, "+ implementing ticket");
151
+ act.addEventListener("click", (ev) => { ev.stopPropagation(); handlers.onCreateImplementer(req); });
152
+ detail.appendChild(act);
153
+ }
154
+ return detail;
155
+ }
156
+
157
+ // ---- DOM: the multi-column document --------------------------------------
158
+ function renderDoc(container, model, handlers) {
159
+ container.innerHTML = "";
160
+ container.appendChild(summaryChips(model.summary));
161
+
162
+ const grid = el("div", { class: "vr-grid vr-grid-" + model.mode });
163
+ container.appendChild(grid);
164
+
165
+ const th = (txt) => el("div", { class: "vr-cell vr-th" }, txt);
166
+ if (model.mode === "doc") {
167
+ // Source header carries a draggable divider that re-splits Source vs
168
+ // Requirement (the two flexible columns). The chosen ratio persists.
169
+ const srcTh = th("Source"); srcTh.classList.add("vr-th-resizable");
170
+ const resizer = el("div", { class: "vr-resizer", title: "Drag to resize" });
171
+ srcTh.appendChild(resizer);
172
+ grid.appendChild(srcTh);
173
+ grid.appendChild(th("ID")); grid.appendChild(th("Requirement")); grid.appendChild(th("Coverage"));
174
+ grid.style.gridTemplateColumns = docGridCols(readSourceFrac());
175
+ let dragging = false;
176
+ const onMove = (ev) => {
177
+ if (!dragging) return;
178
+ const rect = grid.getBoundingClientRect();
179
+ const flexible = rect.width - VR_ID_W - VR_COV_W;
180
+ if (flexible <= 0) return;
181
+ const frac = clampFrac((ev.clientX - rect.left) / flexible);
182
+ grid.style.gridTemplateColumns = docGridCols(frac);
183
+ grid._vrFrac = frac;
184
+ };
185
+ const onUp = () => {
186
+ if (!dragging) return; dragging = false;
187
+ document.removeEventListener("pointermove", onMove);
188
+ document.removeEventListener("pointerup", onUp);
189
+ if (typeof grid._vrFrac === "number") writeSourceFrac(grid._vrFrac);
190
+ };
191
+ resizer.addEventListener("pointerdown", (ev) => {
192
+ ev.preventDefault(); ev.stopPropagation(); dragging = true;
193
+ document.addEventListener("pointermove", onMove);
194
+ document.addEventListener("pointerup", onUp);
195
+ });
196
+ } else {
197
+ grid.appendChild(th("ID")); grid.appendChild(th("Requirement")); grid.appendChild(th("Coverage"));
198
+ }
199
+
200
+ const reqCells = (r, startCls) => {
201
+ grid.appendChild(el("div", { class: "vr-cell vr-cell-id " + startCls }, r.ticketKey));
202
+ const reqCell = el("div", { class: "vr-cell vr-cell-req " + startCls });
203
+ // The requirement statement is editable in place; on commit it mirrors
204
+ // straight into its requirement TICKET (the requirement IS a ticket).
205
+ const textEl = el("div", { class: "vr-req-text", contenteditable: "true", spellcheck: "false" }, r.title);
206
+ textEl.addEventListener("mousedown", (ev) => ev.stopPropagation()); // don't toggle detail; place caret
207
+ textEl.addEventListener("keydown", (ev) => {
208
+ if (ev.key === "Enter") { ev.preventDefault(); textEl.blur(); }
209
+ if (ev.key === "Escape") { textEl.textContent = r.title; textEl.blur(); }
210
+ });
211
+ textEl.addEventListener("blur", () => {
212
+ const v = textEl.textContent.replace(/\s+/g, " ").trim();
213
+ if (v && v !== r.title && handlers.onEditRequirement) handlers.onEditRequirement(r.id, v);
214
+ else if (!v) textEl.textContent = r.title; // don't allow empty
215
+ });
216
+ reqCell.appendChild(textEl);
217
+ const detail = requirementDetail(r, handlers);
218
+ detail.style.display = "none";
219
+ reqCell.appendChild(detail);
220
+ grid.appendChild(reqCell);
221
+ // Coverage badge cell doubles as the expand/collapse control for the
222
+ // traceability detail (clicking the editable text must NOT toggle).
223
+ const cov = el("div", { class: "vr-cell vr-cell-cov vr-clickable " + startCls });
224
+ cov.appendChild(el("span", { class: "vr-badge vr-badge-" + r.status }, r.status));
225
+ cov.addEventListener("click", () => {
226
+ const open = detail.style.display === "none";
227
+ detail.style.display = open ? "" : "none";
228
+ reqCell.classList.toggle("vr-open", open);
229
+ });
230
+ grid.appendChild(cov);
231
+ };
232
+
233
+ if (model.mode === "doc") {
234
+ if (!model.sections.length) {
235
+ grid.appendChild(el("div", { class: "vr-reqs-empty", style: "grid-column:1/-1" }, "Empty document."));
236
+ return;
237
+ }
238
+ for (const sec of model.sections) {
239
+ const lines = sec.requirements.length ? sec.requirements : [null];
240
+ lines.forEach((r, idx) => {
241
+ const startCls = idx === 0 ? "vr-section-start" : "";
242
+ // SOURCE cell — only on the first line of a section (heading + prose).
243
+ const src = el("div", { class: "vr-cell vr-cell-source " + startCls, dataset: { sectionPath: sec.sectionPath } });
244
+ if (idx === 0) {
245
+ const h = el("div", { class: "vr-h vr-h" + Math.min(6, sec.level) });
246
+ h.appendChild(el("span", { class: "vr-h-path" }, sec.sectionPath));
247
+ h.appendChild(el("span", { class: "vr-h-text" }, sec.heading));
248
+ src.appendChild(h);
249
+ if (sec.body) src.appendChild(el("div", { class: "vr-src-prose selectable" }, sec.body));
250
+ }
251
+ grid.appendChild(src);
252
+ if (r) {
253
+ reqCells(r, startCls);
254
+ } else {
255
+ // gap: no requirement extracted from this source section
256
+ grid.appendChild(el("div", { class: "vr-cell vr-cell-id " + startCls }, ""));
257
+ const gapCell = el("div", { class: "vr-cell vr-cell-req vr-gap-cell " + startCls });
258
+ gapCell.appendChild(el("span", { class: "vr-gap-text" }, "— no requirement —"));
259
+ if (handlers.onCreateRequirement) {
260
+ const act = el("button", { class: "vr-action", type: "button" }, "+ requirement");
261
+ act.addEventListener("click", () => handlers.onCreateRequirement(sec));
262
+ gapCell.appendChild(act);
263
+ }
264
+ grid.appendChild(gapCell);
265
+ const cov = el("div", { class: "vr-cell vr-cell-cov " + startCls });
266
+ cov.appendChild(el("span", { class: "vr-badge vr-badge-orphan" }, "gap"));
267
+ grid.appendChild(cov);
268
+ }
269
+ });
270
+ }
271
+ } else {
272
+ if (!model.requirements.length) {
273
+ grid.appendChild(el("div", { class: "vr-reqs-empty", style: "grid-column:1/-1" },
274
+ handlers.emptyNote || "No requirements in this module yet."));
275
+ return;
276
+ }
277
+ for (const r of model.requirements) reqCells(r, "vr-section-start");
278
+ }
279
+ }
280
+
281
+ // ---- mount ---------------------------------------------------------------
282
+ function mount(host, store, ctx) {
283
+ ctx = ctx || {};
284
+ const rootEl = el("div", { class: "vr-root" });
285
+ host.appendChild(rootEl);
286
+
287
+ const state = { moduleId: null, sectionsByModule: {}, sourceErr: null, fetching: {} };
288
+
289
+ function render() {
290
+ // Focus-guard: never stomp an in-progress inline edit (a contentEditable
291
+ // requirement statement). Defer the re-render until the field blurs.
292
+ const ae = typeof document !== "undefined" && document.activeElement;
293
+ if (ae && rootEl.contains(ae) && ae.isContentEditable) return;
294
+ const snap = store.get();
295
+ const modules = (core.tickets ? core.tickets.specModules(snap) : []);
296
+ rootEl.innerHTML = "";
297
+
298
+ const bar = el("div", { class: "vr-bar" });
299
+ bar.appendChild(el("span", { class: "vr-title" }, "Requirements"));
300
+ rootEl.appendChild(bar);
301
+
302
+ if (!modules.length) {
303
+ rootEl.appendChild(el("div", { class: "vr-empty" },
304
+ "No spec modules yet. Upload a PRD attachment and decompose it into requirements (via the agent / MCP)."));
305
+ return;
306
+ }
307
+ if (!state.moduleId || !modules.some(m => m.id === state.moduleId)) state.moduleId = modules[0].id;
308
+ const mod = modules.find(m => m.id === state.moduleId);
309
+
310
+ const picker = el("select", { class: "vr-module-picker" });
311
+ for (const m of modules) {
312
+ const o = el("option", { value: m.id }, m.title || m.ticketKey);
313
+ if (m.id === state.moduleId) o.selected = true;
314
+ picker.appendChild(o);
315
+ }
316
+ picker.addEventListener("change", () => { state.moduleId = picker.value; render(); });
317
+ bar.appendChild(picker);
318
+ if (!mod.sourceAttachmentId) bar.appendChild(el("span", { class: "vr-note" }, "no source bound"));
319
+ else if (state.sourceErr) bar.appendChild(el("span", { class: "vr-note vr-note-warn" }, "source error: " + state.sourceErr));
320
+ else if (!state.sectionsByModule[mod.id]) bar.appendChild(el("span", { class: "vr-note" }, ctx.fetchIngest ? "loading source…" : "source needs server"));
321
+
322
+ const body = el("div", { class: "vr-body" });
323
+ rootEl.appendChild(body);
324
+
325
+ const sections = state.sectionsByModule[mod.id] || [];
326
+ const model = buildDocumentModel(snap, mod.id, sections);
327
+ if (model.slicing) {
328
+ const sl = model.slicing;
329
+ bar.appendChild(el("span", { class: "vr-note " + (sl.gaps ? "vr-note-warn" : "vr-note-ok") },
330
+ sl.gaps ? (sl.gaps + " of " + sl.sections + " sections un-sliced") : ("all " + sl.sections + " sections sliced")));
331
+ }
332
+ renderDoc(body, model, {
333
+ onOpenTicket: ctx.onOpenTicket,
334
+ onEditRequirement: ctx.onEditRequirement,
335
+ onCreateImplementer: ctx.onCreateImplementer ? (req) => ctx.onCreateImplementer(req) : null,
336
+ onCreateRequirement: ctx.onCreateRequirement ? (sec) => ctx.onCreateRequirement(mod.id, sec) : null
337
+ });
338
+
339
+ if (mod.sourceAttachmentId && ctx.fetchIngest
340
+ && !state.sectionsByModule[mod.id] && !state.fetching[mod.id]) {
341
+ state.fetching[mod.id] = true;
342
+ state.sourceErr = null;
343
+ ctx.fetchIngest(mod.sourceAttachmentId).then(res => {
344
+ state.sectionsByModule[mod.id] = (res && res.sections) || [];
345
+ state.fetching[mod.id] = false;
346
+ if (state.moduleId === mod.id) render();
347
+ }).catch(e => {
348
+ state.fetching[mod.id] = false;
349
+ state.sourceErr = (e && e.message) || "ingest failed";
350
+ if (state.moduleId === mod.id) render();
351
+ });
352
+ }
353
+ }
354
+
355
+ const unsub = store.subscribe ? store.subscribe(() => render()) : null;
356
+ render();
357
+ return { unmount() { if (typeof unsub === "function") unsub(); host.innerHTML = ""; } };
358
+ }
359
+
360
+ return { mount: mount, buildDocumentModel: buildDocumentModel, docGridCols: docGridCols, clampFrac: clampFrac };
361
+ }));