@windyroad/itil 0.61.2 → 1.0.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 (33) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +2 -14
  3. package/bin/{wr-itil-check-afk-accept-eligible → wr-itil-render-story-map} +2 -2
  4. package/bin/wr-itil-story-map-edit +51 -0
  5. package/bin/wr-itil-story-map-query +51 -0
  6. package/hooks/itil-no-implement-draft-gate.sh +19 -28
  7. package/lib/story-oversight.sh +154 -60
  8. package/package.json +2 -1
  9. package/scripts/check-rfc-stories-ratified.sh +15 -9
  10. package/scripts/detect-unratified-stories-maps.sh +15 -32
  11. package/scripts/mark-story-oversight-confirmed.sh +80 -31
  12. package/scripts/migrate-story-status-mirror.sh +7 -3
  13. package/scripts/reconcile-stories.sh +26 -5
  14. package/scripts/render-story-map.mjs +1057 -0
  15. package/scripts/render-story-map.sh +15 -0
  16. package/scripts/story-map-edit.mjs +280 -0
  17. package/scripts/story-map-edit.sh +10 -0
  18. package/scripts/story-map-query.mjs +227 -0
  19. package/scripts/story-map-query.sh +68 -0
  20. package/scripts/update-story-references-section.sh +6 -1
  21. package/skills/capture-rfc/SKILL.md +1 -1
  22. package/skills/capture-story/SKILL.md +16 -8
  23. package/skills/capture-story-map/SKILL.md +102 -59
  24. package/skills/list-stories/SKILL.md +16 -10
  25. package/skills/list-story-maps/SKILL.md +1 -1
  26. package/skills/manage-rfc/SKILL.md +1 -1
  27. package/skills/manage-story/SKILL.md +19 -26
  28. package/skills/manage-story-map/SKILL.md +19 -17
  29. package/skills/reconcile-stories/SKILL.md +5 -2
  30. package/skills/work-problems/SKILL.md +2 -2
  31. package/templates/story-map.css +195 -0
  32. package/templates/story-map.html +23 -0
  33. package/scripts/check-afk-accept-eligible.sh +0 -230
@@ -0,0 +1,1057 @@
1
+ #!/usr/bin/env node
2
+ // Render a story map from its JSON source into the canonical HTML grid.
3
+ //
4
+ // A story map is a two-dimensional grid: backbone activities run across the
5
+ // top as columns (the user journey), release slices run down as rows, and
6
+ // task cards sit in the cells. Reading a row left to right is everything
7
+ // that ships together.
8
+ //
9
+ // The shape lives in templates/story-map.html and nowhere else. This script
10
+ // knows what a story map is; it never inspects an existing map to infer it.
11
+ // That inference is what let every map in the corpus drift into a vertical
12
+ // stack together.
13
+ //
14
+ // A map is ONE file. Its data lives inside it, in a
15
+ // <script id="story-map-data" type="application/json"> island; the renderer
16
+ // rewrites the presentation around that island in place. There is no separate
17
+ // source file to diverge from the rendered output, and because the island is
18
+ // separable, a ratification fingerprint can be scoped to the data alone —
19
+ // so restyling every map in a corpus can never revoke a human approval.
20
+ //
21
+ // Usage: render-story-map.mjs <map.html>
22
+ //
23
+ // There is one mode, and it is idempotent. To CREATE a map, write a file
24
+ // containing nothing but the data island and render it — the renderer fills in
25
+ // everything around it. To CHANGE a map, edit the island in place and render
26
+ // again. Creation and editing are the same operation on the same file, so
27
+ // there is no seed file, no bootstrap flag, and no second code path to keep
28
+ // in step with the first.
29
+ //
30
+ // @adr ADR-102 (story maps render from JSON through a canonical template)
31
+ // @adr ADR-060 (Problem-RFC-Story framework — Phase 2 encoding, amended)
32
+ // @adr ADR-103 (story cards single-line — a readability convention now, not a
33
+ // correctness constraint: ADR-101's whole-line filter that made it
34
+ // load-bearing is retired, and cards sit outside the fingerprint basis)
35
+
36
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
37
+ import { dirname, join, resolve, relative, sep } from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const HERE = dirname(fileURLToPath(import.meta.url));
41
+ const TEMPLATE = join(HERE, '..', 'templates', 'story-map.html');
42
+
43
+ /** Escape text destined for HTML body or attribute context. */
44
+ function esc(value) {
45
+ return String(value ?? '')
46
+ .replace(/&/g, '&amp;')
47
+ .replace(/</g, '&lt;')
48
+ .replace(/>/g, '&gt;')
49
+ .replace(/"/g, '&quot;');
50
+ }
51
+
52
+ /** Resolve a story's CURRENT lifecycle state from its own file.
53
+ *
54
+ * Status is never authored on a card. Storing it would duplicate the story
55
+ * file, which means a sync obligation on every transition, a drift class that
56
+ * really did put three of eight maps out of date, and ratification churn —
57
+ * ticking a story to done is progress, not a revision of what a human
58
+ * approved, yet a stored value would drift the map's fingerprint.
59
+ *
60
+ * Returns null when there is no stories tree (rendering outside a repository,
61
+ * e.g. from the published package) or no matching story. Callers omit the
62
+ * attribute rather than guessing.
63
+ */
64
+ /** Locate a story by id and return its body, or null. Shared by the status and
65
+ * value resolvers so there is one definition of how a story file is found. */
66
+ function readStoryBody(storiesDir, storyId) {
67
+ if (!storiesDir || !existsSync(storiesDir)) return null;
68
+ const num = String(storyId).split('-')[1];
69
+ if (!num) return null;
70
+ let states;
71
+ try {
72
+ states = readdirSync(storiesDir);
73
+ } catch {
74
+ return null;
75
+ }
76
+ for (const state of states) {
77
+ const dir = join(storiesDir, state);
78
+ // The stories tree holds README files alongside the state directories.
79
+ try {
80
+ if (!statSync(dir).isDirectory()) continue;
81
+ } catch {
82
+ continue;
83
+ }
84
+ let hit;
85
+ try {
86
+ hit = readdirSync(dir).find((n) => n.startsWith(`STORY-${num}-`));
87
+ } catch {
88
+ continue;
89
+ }
90
+ if (!hit) continue;
91
+ try {
92
+ return { body: readFileSync(join(dir, hit), 'utf8'), state };
93
+ } catch {
94
+ return { body: null, state };
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function resolveStoryStatus(storiesDir, storyId) {
101
+ const hit = readStoryBody(storiesDir, storyId);
102
+ if (!hit) return null;
103
+ if (!hit.body) return hit.state;
104
+ const m = hit.body.match(/^status:\s*(.+)$/m);
105
+ // Frontmatter is authoritative; the directory is the fallback, and the two
106
+ // disagreeing is itself a defect worth seeing rather than papering over.
107
+ return m ? m[1].trim() : hit.state;
108
+ }
109
+
110
+ /** The problems a story closes, from its `problems:` frontmatter.
111
+ *
112
+ * Derived like everything else that already exists elsewhere. Three places
113
+ * were naming problems — the map's `traces.problems`, an authored list on each
114
+ * row, and the story files — and all three disagreed: the map cited two
115
+ * problems no story mentioned and omitted three that stories did. The story is
116
+ * the only one of the three that can be right, because it is where the work
117
+ * and the problem meet.
118
+ *
119
+ * A row's problems are the union of its stories'; the map's are the union of
120
+ * its rows'. A card with no story file contributes nothing, which is the
121
+ * honest answer — an untraced row should look untraced rather than inherit a
122
+ * trace from a neighbour.
123
+ */
124
+ function resolveStoryProblems(storiesDir, storyId) {
125
+ const hit = readStoryBody(storiesDir, storyId);
126
+ if (!hit || !hit.body) return [];
127
+ const m = hit.body.match(/^problems:\s*\[(.*?)\]\s*$/m);
128
+ if (!m) return [];
129
+ return m[1].split(',').map((s) => s.trim()).filter(Boolean);
130
+ }
131
+
132
+ /** A story's value statement, read from its `## User value` section.
133
+ *
134
+ * Derived, never stored on the card — the fifth application of the same rule
135
+ * that removed storyStatus, card titles, the RFC story list and row status. A
136
+ * hand-written card value duplicates the story and degrades it: every one on
137
+ * STORY-MAP-002 had drifted into a "Value: ..." paraphrase while the stories
138
+ * themselves carried proper value-first statements. Deriving keeps the map
139
+ * showing what the story actually says.
140
+ *
141
+ * Returns null outside a repository or when the story has no value section;
142
+ * callers omit the line rather than inventing one.
143
+ */
144
+ function resolveStoryValue(storiesDir, storyId) {
145
+ const hit = readStoryBody(storiesDir, storyId);
146
+ if (!hit || !hit.body) return null;
147
+ // PREFIX match: corpus headings carry trailing qualifiers — `## User value
148
+ // (INVEST Valuable)` — so an end-anchored pattern silently finds nothing and
149
+ // every card loses its value. Same trap the acceptance-criteria section hit.
150
+ const lines = hit.body.split('\n');
151
+ const start = lines.findIndex((l) => /^##\s+User value\b/.test(l));
152
+ if (start === -1) return null;
153
+ let end = lines.findIndex((l, i) => i > start && /^##\s/.test(l));
154
+ if (end === -1) end = lines.length;
155
+ // The value statement is the FIRST paragraph. Filtering blanks out of the
156
+ // whole section glued a following paragraph onto the "I want" clause: on a
157
+ // phone STORY-052's card ran to 169 words, most of it the evidence prose the
158
+ // author had deliberately put below the statement. A story is free to
159
+ // explain itself under its value; the card carries the statement.
160
+ const para = [];
161
+ for (const line of lines.slice(start + 1, end)) {
162
+ const t = line.trim();
163
+ if (!t) { if (para.length) break; continue; }
164
+ para.push(t);
165
+ }
166
+ const text = para.join(' ').trim();
167
+ return text ? splitValue(text) : null;
168
+ }
169
+
170
+ /** Break a value-first statement into its three clauses, preserving emphasis.
171
+ *
172
+ * Stories are written "In order to <value>, as a <persona>, I want
173
+ * <capability>". Rendered as one paragraph it is a wall of text; the shape
174
+ * that makes it readable is the shape it was written in, so the split happens
175
+ * here and the client gives each clause its own line.
176
+ *
177
+ * Emphasis is the AUTHOR's, never guessed. An earlier version bolded a persona
178
+ * head-noun it extracted itself — but across the corpus 12 of 50 values carry
179
+ * `**...**` and not one of them marks the persona: authors emphasise the
180
+ * capability. Inventing emphasis overrode what they had already said was
181
+ * important, so each clause comes back as a run list and the client renders
182
+ * exactly the marks the story carries.
183
+ *
184
+ * Anything that does not match the pattern comes back as `{ raw }` and renders
185
+ * as a single block — a story written another way is not mangled to fit.
186
+ */
187
+ function splitValue(text) {
188
+ // Tolerant of how the clause actually ends in the corpus. "In order that" is
189
+ // as common as "In order to"; "as the developer" as common as "as a"; and a
190
+ // clause carrying a parenthetical closes on an em-dash rather than a comma —
191
+ // "…toward fixed — as a developer…", "…away from the keyboard — I want…".
192
+ // An earlier stricter pattern rejected 15 correctly-written statements and
193
+ // rendered them as walls of text, which reads as a story defect when it is a
194
+ // parser defect. Be strict about the SHAPE (value, who, want, in that order)
195
+ // and loose about everything else — the punctuation between the clauses, and
196
+ // how the persona opens.
197
+ //
198
+ // The article is NOT required. "as whoever picks the ticket up" is the shape
199
+ // written correctly; demanding a/an/the was a guess about wording, and the
200
+ // guess cost STORY-060 its three lines with nothing reporting why. Widening
201
+ // does not close that class on its own — see assertValueStatementsSplit,
202
+ // which is what makes the next unanticipated phrasing loud instead of silent.
203
+ // The connectives are CAPTURED, not re-emitted from memory. A story written
204
+ // "In order that ..." was being relabelled "In order to ...", and dropping
205
+ // the article from the persona group turned "as a developer" into a card
206
+ // reading "as a a developer". The renderer does not get to decide how the
207
+ // author opened a clause; it only decides where the line breaks.
208
+ const m = text.match(
209
+ /^(In order (?:to|that))\s+([\s\S]+?)\s*[,—–-]\s*(as)\s+([\s\S]+?)\s*[,—–-]\s*(I want)\s+([\s\S]+)$/i
210
+ );
211
+ if (!m) return { raw: runs(text) };
212
+ return {
213
+ leads: [m[1], m[3], m[5]],
214
+ value: runs(m[2]), who: runs(m[4]), want: runs(m[6]),
215
+ };
216
+ }
217
+
218
+ /** Split a markdown fragment into plain and emphasised runs.
219
+ *
220
+ * Only `**strong**` — the one mark the corpus uses. Anything else stays
221
+ * literal rather than half-supported, and a lone `*` is left alone so prose
222
+ * containing one is not mangled. Returned as data, not HTML: the client builds
223
+ * text nodes and <strong> elements, so nothing here can inject markup.
224
+ */
225
+ function runs(text) {
226
+ const out = [];
227
+ const re = /\*\*(.+?)\*\*/g;
228
+ let last = 0, m;
229
+ while ((m = re.exec(text)) !== null) {
230
+ if (m.index > last) out.push({ t: text.slice(last, m.index) });
231
+ out.push({ t: m[1], em: true });
232
+ last = m.index + m[0].length;
233
+ }
234
+ if (last < text.length) out.push({ t: text.slice(last) });
235
+ return out.length ? out : [{ t: text }];
236
+ }
237
+
238
+ /** Where the stories tree sits relative to a map at docs/story-maps/<state>/. */
239
+ function storiesDirFor(mapPath) {
240
+ return join(dirname(dirname(mapPath)), '..', 'stories');
241
+ }
242
+
243
+ /** The data island's opening tag, matched when reading a map back in. */
244
+ const ISLAND_OPEN = '<script id="story-map-data" type="application/json">';
245
+
246
+ /** Pull the authored data back out of a map. This is the only thing the
247
+ * renderer ever reads from an existing map — never its shape. */
248
+ export function extractIsland(html) {
249
+ const start = html.indexOf(ISLAND_OPEN);
250
+ if (start === -1) {
251
+ throw new Error(
252
+ 'no <script id="story-map-data"> block found. A story map is defined by ' +
253
+ 'that block; to create one, write a file containing just the block and render it.'
254
+ );
255
+ }
256
+ const from = start + ISLAND_OPEN.length;
257
+ const end = html.indexOf('</script>', from);
258
+ if (end === -1) throw new Error('data block is not closed');
259
+ return JSON.parse(html.slice(from, end).replace(/\\u003c/g, '<'));
260
+ }
261
+
262
+ /** Serialise the island deterministically, so re-rendering an unchanged map is
263
+ * byte-identical and a content fingerprint over it is stable. `<` is escaped
264
+ * so a title containing `</script>` cannot break out of the block. */
265
+ function serialiseIsland(map) {
266
+ return JSON.stringify(map, null, 2).replace(/</g, '\\u003c');
267
+ }
268
+
269
+ /** What the reader is looking at, in prose, before any scrolling.
270
+ *
271
+ * Both facts that make a map a decision — that it is a draft, and that nobody
272
+ * has agreed it yet — lived only in `<meta>`, which does not render. A reader
273
+ * opening the file on a phone got a title, then five screens of grid, and had
274
+ * to infer the ask from the genre. The scale is the other half: the caption
275
+ * carries "N activities across M releases" but is clipped for screen readers
276
+ * only, so the one reader who cannot see how much is left is the one scrolling
277
+ * through it.
278
+ */
279
+ function renderOrient(map) {
280
+ const rows = (map.releases ?? []).length;
281
+ const cards = (map.tasks ?? []).length;
282
+ const agreed = (map.humanOversight ?? 'unconfirmed') === 'confirmed';
283
+ const lead = agreed
284
+ ? 'Agreed.'
285
+ : map.status === 'draft' ? 'Draft — not yet agreed.' : 'Proposed — not yet agreed.';
286
+ const shape = `${rows} release${rows === 1 ? '' : 's'}, ${cards} ` +
287
+ `${cards === 1 ? 'story' : 'stories'}, one release per row.`;
288
+ const ask = agreed
289
+ ? ''
290
+ : '<p class="orient">Read the release names down the left. Say yes to agree ' +
291
+ 'it, or name the row that is wrong or missing. The detail inside the cards ' +
292
+ 'is there if you want it, not because you have to read it.</p>';
293
+ return ` <p class="orient"><strong>${lead}</strong> ${esc(shape)}</p>\n${ask ? ' ' + ask + '\n' : ''}`;
294
+ }
295
+
296
+ function renderMeta(map, derived) {
297
+ const t = map.traces ?? {};
298
+ // `problems` and `rfcs` are DERIVED, and the read is unconditional — no
299
+ // `?? t.problems` alternation, which would let a pre-migration island outvote
300
+ // the corpus and re-open the override ADR-104 forbids.
301
+ //
302
+ // The rfcs filter is load-bearing, not defensive: a row legitimately carries no
303
+ // RFC, in two spellings — `"rfc": null` on a pre-RFC row, and no `rfc` key at
304
+ // all. A raw join yields ",RFC-005,…" or ",,", and `content=",,"` satisfies the
305
+ // reverse-tracers' `content="[^"]+"` guard, so a map with no RFCs would stop
306
+ // looking like one.
307
+ const rfcs = [...new Set((map.releases ?? []).map((r) => r.rfc).filter(Boolean))];
308
+ const rows = [
309
+ ['story-map-id', map.storyMapId],
310
+ ['status', map.status],
311
+ ['persona', map.persona],
312
+ ['secondary-persona', map.secondaryPersona],
313
+ ['problems', (derived.mapProblems ?? []).join(',')],
314
+ ['rfcs', rfcs.join(',')],
315
+ ['jtbd', (t.jtbd ?? []).join(',')],
316
+ ['reported', map.reported],
317
+ ['decision-makers', map.decisionMakers],
318
+ ['human-oversight', map.humanOversight ?? 'unconfirmed'],
319
+ ['oversight-hash', map.oversightHash],
320
+ ['oversight-date', map.oversightDate],
321
+ ['oversight-note', map.oversightNote],
322
+ ];
323
+ return rows
324
+ .filter(([, v]) => v !== undefined && v !== null)
325
+ .map(([k, v]) => ` <meta name="${k}" content="${esc(v)}">`)
326
+ .join('\n');
327
+ }
328
+
329
+ /** Resolve an artefact id to a path relative to the map, or null.
330
+ *
331
+ * The renderer is the only layer with filesystem access, so link resolution
332
+ * belongs here — the client cannot know which lifecycle directory a problem
333
+ * currently sits in, and hard-coding one would rot the first time it moved.
334
+ * Maps live at docs/story-maps/<state>/, so `../..` reaches docs/.
335
+ */
336
+ function resolveHref(mapPath, id) {
337
+ const docs = join(dirname(dirname(mapPath)), '..');
338
+ // No RFC entry, deliberately. ADR-103 made the release row the RFC, so the
339
+ // 59 files under docs/rfcs/ are legacy records of delivered work. Linking one
340
+ // sends a reader to a superseded artefact and re-teaches the two-tier model
341
+ // this decision removed.
342
+ const kinds = [
343
+ [/^STORY-(\d+)$/, 'stories', (n) => `STORY-${n}-`, true],
344
+ [/^STORY-MAP-(\d+)$/, 'story-maps', (n) => `STORY-MAP-${n}-`, true],
345
+ [/^JTBD-(\d+)$/, 'jtbd', (n) => `JTBD-${n}-`, true],
346
+ [/^ADR-(\d+)$/, 'decisions', (n) => `${n}-`, false],
347
+ [/^P(\d+)$/, 'problems', (n) => `${n}-`, true],
348
+ ];
349
+ for (const [re, sub, prefix, nested] of kinds) {
350
+ const m = String(id).match(re);
351
+ if (!m) continue;
352
+ const root = join(docs, sub);
353
+ if (!existsSync(root)) return null;
354
+ const want = prefix(m[1]);
355
+ // Flat first, then one level down — problems, stories, maps and jobs are
356
+ // filed under a lifecycle or persona directory; RFCs and decisions are not.
357
+ const search = [root];
358
+ if (nested) {
359
+ try {
360
+ for (const d of readdirSync(root)) {
361
+ const sd = join(root, d);
362
+ try { if (statSync(sd).isDirectory()) search.push(sd); } catch { /* skip */ }
363
+ }
364
+ } catch { return null; }
365
+ }
366
+ for (const dir of search) {
367
+ let hit;
368
+ try {
369
+ hit = readdirSync(dir).find((n) => n.startsWith(want) && !n.startsWith('.'));
370
+ } catch { continue; }
371
+ if (hit) return relative(dirname(mapPath), join(dir, hit)).split(sep).join('/');
372
+ }
373
+ return null;
374
+ }
375
+ return null;
376
+ }
377
+
378
+ /** Everything derived from outside the island, emitted as one generated block:
379
+ * each story's lifecycle status and value statement, and an href for every
380
+ * artefact the map references.
381
+ *
382
+ * None of it is authored. Status and value are read from each story's own file
383
+ * and hrefs from the docs tree, here, where the filesystem is available, and
384
+ * consumed by the renderer below, not at view time. Omitted entirely when nothing
385
+ * resolves, which is the published-package case: a map opened outside a
386
+ * repository shows no status, no values and plain text instead of links.
387
+ */
388
+ /** A row's status, derived — never authored (ADR-103).
389
+ *
390
+ * delivered — every story in the row is done or archived
391
+ * proposed — a problem or an RFC names the row, and it is not delivered
392
+ * unproposed — nothing has asked for this release yet
393
+ *
394
+ * THE definition. `story-map-query` reads the value emitted here rather than
395
+ * recomputing it, so the grid a human looks at and the JSON a tool reads
396
+ * cannot disagree — the drift class that put three of eight maps out of date
397
+ * when card status was stored.
398
+ */
399
+ function rowStatus(row, tasks, statuses) {
400
+ const mine = tasks.filter((t) => t.release === row.id);
401
+ const terminal = (s) => s === 'done' || s === 'archived';
402
+ const shipped = mine.length && mine.every((t) => terminal(statuses[t.storyId]));
403
+ // A row carries an RFC identity. The exception is CLOSED: it covers rows
404
+ // holding work that shipped before rows carried identities, and those say so
405
+ // with `preRfc`. Delivery alone cannot earn the exception — every row is
406
+ // delivered eventually, so that reading would make shipping unproposed work
407
+ // legitimate by finishing it.
408
+ if (row.graveyard) return 'archived';
409
+ if (shipped && (row.rfc || row.preRfc)) return 'delivered';
410
+ if (row.rfc || (row.problems ?? []).length) return 'proposed';
411
+ // NOT a third resting state. A row whose stories close no problem is a defect:
412
+ // either the problem exists and the stories should trace it, or it needs
413
+ // documenting. This used to return `unproposed` and render as "Speculative",
414
+ // which gave a tidy name to work nobody had asked for and let it sit there.
415
+ return 'untraced';
416
+ }
417
+
418
+ function renderStatus(map, storiesDir, mapPath) {
419
+ const status = {};
420
+ const values = {};
421
+ const hrefs = {};
422
+
423
+ const ids = [...new Set((map.tasks ?? []).map((t) => t.storyId).filter(Boolean))];
424
+ const storyProblems = {};
425
+ for (const id of ids) {
426
+ const st = resolveStoryStatus(storiesDir, id);
427
+ if (st) status[id] = st;
428
+ const v = resolveStoryValue(storiesDir, id);
429
+ if (v) values[id] = v;
430
+ storyProblems[id] = resolveStoryProblems(storiesDir, id);
431
+ }
432
+
433
+ if (mapPath) {
434
+ // Every id the map mentions anywhere: cards, rows, traces and prose.
435
+ const mentioned = new Set(ids);
436
+ for (const t of map.tasks ?? []) {
437
+ for (const k of ['rfc', 'jtbd']) if (t[k]) mentioned.add(t[k]);
438
+ for (const m of String(t.ref ?? '').match(/\b(?:ADR|JTBD|RFC|STORY-MAP|STORY|P)-?\d+\b/g) ?? []) mentioned.add(m);
439
+ }
440
+ for (const r of map.releases ?? []) if (r.rfc) mentioned.add(r.rfc);
441
+ // Only `traces.jtbd` — the one authored trace left. Scanning the whole object
442
+ // would resolve an authored `traces.adrs` into `hrefs`, leaving it UNRENDERED
443
+ // but not inert; narrowing is what makes "inert" true.
444
+ for (const m of (map.traces?.jtbd ?? []).join(' ').match(/\b(?:ADR|JTBD|RFC|STORY-MAP|STORY|P)-?\d+\b/g) ?? []) mentioned.add(m);
445
+ // The problems each story closes, so they resolve to links on the rows.
446
+ for (const pr of Object.values(storyProblems).flat()) mentioned.add(pr);
447
+
448
+ for (const id of mentioned) {
449
+ const href = resolveHref(mapPath, id);
450
+ if (href) hrefs[id] = href;
451
+ }
452
+ }
453
+
454
+ // Row status depends on the story statuses resolved just above, so it is
455
+ // computed here and emitted rather than left for the client — the browser
456
+ // cannot read story files.
457
+ const rows = {};
458
+ const rowProblems = {};
459
+ const mapProblems = new Set();
460
+ for (const r of map.releases ?? []) {
461
+ const mine = (map.tasks ?? []).filter((t) => t.release === r.id);
462
+ const ps = new Set();
463
+ for (const t of mine) {
464
+ if (!t.storyId) continue;
465
+ for (const pr of storyProblems[t.storyId] ?? []) ps.add(pr);
466
+ }
467
+ const sorted = [...ps].sort();
468
+ if (sorted.length) rowProblems[r.id] = sorted;
469
+ sorted.forEach((pr) => mapProblems.add(pr));
470
+ rows[r.id] = rowStatus({ ...r, problems: sorted }, map.tasks ?? [], status);
471
+ }
472
+
473
+ const payload = {};
474
+ if (Object.keys(rows).length) payload.rows = rows;
475
+ if (Object.keys(rowProblems).length) payload.rowProblems = rowProblems;
476
+ if (mapProblems.size) payload.mapProblems = [...mapProblems].sort();
477
+ if (Object.keys(status).length) payload.status = status;
478
+ if (Object.keys(values).length) payload.values = values;
479
+ if (Object.keys(hrefs).length) payload.hrefs = hrefs;
480
+ if (Object.keys(payload).length === 0) return '';
481
+
482
+ const body = JSON.stringify(payload, null, 2).replace(/</g, '\\u003c');
483
+ return (
484
+ ' <script id="story-map-status" type="application/json">\n' +
485
+ body +
486
+ '\n <\/script>\n'
487
+ );
488
+ }
489
+
490
+
491
+ /* ---------------------------------------------------------------------------
492
+ * The grid, rendered HERE rather than in the browser.
493
+ *
494
+ * It used to be built client-side from the data island by a shared story-map.js.
495
+ * That made the file a thin shell: open it anywhere that does not run scripts —
496
+ * a phone's file preview, a sandboxed viewer, GitHub's HTML rendering, print —
497
+ * and you got the fallback message instead of the map. A map you cannot read
498
+ * without a live script engine is not a document.
499
+ *
500
+ * Rendering here costs nothing that mattered. The renderer already runs on every
501
+ * edit (story-map-edit re-renders after each operation), it already reads the
502
+ * story corpus for status, values and problems, and the fingerprint is scoped to
503
+ * the data island (ADR-102) — so generated markup in the file cannot drift a
504
+ * ratification. Presentation stays de-duplicated in the shared stylesheet, which
505
+ * is where the duplication actually was.
506
+ *
507
+ * Markup is byte-identical to what the script produced, including every
508
+ * accessibility property that was reviewed: scope on both header axes, the
509
+ * spanning empty-band cell with its visually-hidden sentence, role="list" on the
510
+ * card lists, and the aria-hidden glyph carrying status as a second channel
511
+ * alongside colour.
512
+ * ------------------------------------------------------------------------- */
513
+
514
+ const BADGE_GLYPH = { 'b-live': '\u2713', 'b-next': '\u2192', 'b-later': '\u25c7', 'b-defect': '\u26a0' };
515
+
516
+ /** Status as a class. Derived, never an authored badge — a hand-written R1/R2
517
+ * ordinal duplicated the RFC identity and collided with it. */
518
+ function badgeClass(rel) {
519
+ switch (String(rel.status || '').toLowerCase()) {
520
+ case 'delivered': return 'b-live';
521
+ case 'proposed': return rel.rfc ? 'b-next' : 'b-defect';
522
+ default: return 'b-defect';
523
+ }
524
+ }
525
+
526
+ /** What a row is CALLED. A row IS an RFC (ADR-103), so where a problem has
527
+ * proposed it, its id is the label. There is deliberately no "not yet
528
+ * allocated" state: drawing the row is what allocates the identity. */
529
+ /** What a row is CALLED. A row IS an RFC (ADR-103), so its id is the label.
530
+ *
531
+ * Without one, the label says what is MISSING rather than inventing a status.
532
+ * Two distinct gaps, and they need different work:
533
+ *
534
+ * proposed, no id — the stories close a problem, so the release is real and
535
+ * wants an RFC identity. Drawing the row is what allocates
536
+ * it; this row was drawn and never given one.
537
+ * untraced — no story here closes anything. Link an existing problem
538
+ * or document a new one.
539
+ *
540
+ * Neither is a resting state, so neither gets a comfortable name. "Speculative"
541
+ * was the comfortable name, and it rendered on a row whose own header read
542
+ * "closes P160, P443" — the label and the trace contradicting each other.
543
+ */
544
+ function rowLabel(rel) {
545
+ if (rel.rfc) return rel.rfc;
546
+ switch (String(rel.status || '').toLowerCase()) {
547
+ case 'delivered': return 'Delivered, pre-RFC';
548
+ // No glyph here \u2014 badge() prepends the one for the class, aria-hidden, as
549
+ // the non-colour channel. A second copy in the label put "warning" into the
550
+ // accessible name and rendered "\u26a0 \u26a0 Needs an RFC id".
551
+ case 'proposed': return 'Needs an RFC id';
552
+ default: return 'Untraced \u2014 needs a problem';
553
+ }
554
+ }
555
+
556
+ function badge(cls, text) {
557
+ return `<span class="badge ${cls}"><span class="b-glyph" aria-hidden="true">${esc(BADGE_GLYPH[cls] || '')}</span>${esc(text)}</span>`;
558
+ }
559
+
560
+ /** Wrap in a link when the renderer resolved one, else return the text. */
561
+ function link(hrefs, id, inner) {
562
+ const href = id && hrefs[id];
563
+ return href ? `<a href="${esc(href)}" class="ref-link">${inner}</a>` : inner;
564
+ }
565
+
566
+ /** Turn every artefact id in a run of prose into a link, leaving the rest be. */
567
+ function linkify(hrefs, text) {
568
+ const re = /\b(?:ADR|JTBD|RFC|STORY-MAP|STORY|P)-?\d+\b/g;
569
+ let out = '', last = 0, m;
570
+ while ((m = re.exec(text)) !== null) {
571
+ out += esc(text.slice(last, m.index));
572
+ out += link(hrefs, m[0], esc(m[0]));
573
+ last = m.index + m[0].length;
574
+ }
575
+ return out + esc(text.slice(last));
576
+ }
577
+
578
+ /** Emphasis runs from the story, as text and <strong>. Data in, markup out —
579
+ * nothing here interprets a story body as HTML. */
580
+ function runsHtml(list) {
581
+ return (list || []).map((r) => (r.em ? `<strong>${esc(r.t)}</strong>` : esc(r.t))).join('');
582
+ }
583
+
584
+ /** A value statement as three lines. Run together it is a wall of text at card
585
+ * width; the shape that makes it scannable is the shape it was written in. */
586
+ function valueHtml(v) {
587
+ if (!v) return '';
588
+ if (v.raw || !v.value) {
589
+ return `<div class="t-value"><div class="v-line">${runsHtml(v.raw)}</div></div>`;
590
+ }
591
+ const [l0, l1, l2] = v.leads ?? ['In order to', 'as', 'I want'];
592
+ const parts = [
593
+ [`${l0} `, v.value, 'v-inorder'],
594
+ [`${l1} `, v.who, 'v-asa'],
595
+ [`${l2} `, v.want, 'v-iwant'],
596
+ ];
597
+ return '<div class="t-value">' + parts.map(([lead, runs, cls]) =>
598
+ `<div class="v-line ${cls}"><span class="v-lead">${esc(lead)}</span>${runsHtml(runs)}</div>`
599
+ ).join('') + '</div>';
600
+ }
601
+
602
+ /** A card's own lifecycle state, as real text.
603
+ *
604
+ * A row only reads delivered when everything in it is done, so a single
605
+ * shipped story inside an in-flight row was invisible: the status reached the
606
+ * markup as `data-status` and no stylesheet rule ever referenced it. The same
607
+ * story was reported as live three times against a map that looked identical
608
+ * each time.
609
+ *
610
+ * The glyph is real text, never generated content — under forced colors the
611
+ * backgrounds collapse and it becomes the only discriminator. Draft is quiet
612
+ * but present; ABSENCE is reserved for a status that could not be resolved,
613
+ * which is a different fact and needs an edit rather than a nudge.
614
+ */
615
+ const CARD_STATUS = {
616
+ done: ['ts-done', '✓', 'Done'],
617
+ archived: ['ts-arch', '○', 'Archived'],
618
+ 'in-progress': ['ts-prog', '◑', 'In progress'],
619
+ accepted: ['ts-acc', '○', 'Accepted'],
620
+ draft: ['ts-draft', '', 'Draft'],
621
+ };
622
+
623
+ function statusHtml(status) {
624
+ const hit = CARD_STATUS[status];
625
+ if (!hit) return '';
626
+ const [cls, glyph, label] = hit;
627
+ const mark = glyph ? `<span class="ts-glyph" aria-hidden="true">${glyph}</span>` : '';
628
+ return `<div class="t-status ${cls}">${mark}${esc(label)}</div>`;
629
+ }
630
+
631
+ function cardHtml(task, status, value, hrefs) {
632
+ const attrs = ['class="task"'];
633
+ if (task.storyId) attrs.push(`data-story-id="${esc(task.storyId)}"`);
634
+ if (task.rfc) attrs.push(`data-rfc="${esc(task.rfc)}"`);
635
+ if (task.jtbd) attrs.push(`data-jtbd="${esc(task.jtbd)}"`);
636
+ if (status) attrs.push(`data-status="${esc(status)}"`);
637
+ let out = `<div ${attrs.join(' ')}>`;
638
+ out += statusHtml(status);
639
+ out += link(hrefs, task.storyId, `<span class="t-title">${esc(task.title || '')}</span>`);
640
+ out += valueHtml(value);
641
+ if (task.ref) out += `<div class="t-ref">Traces: ${linkify(hrefs, String(task.ref))}</div>`;
642
+ return out + '</div>';
643
+ }
644
+
645
+ /** The whole grid: caption, both header axes, and one row per release. */
646
+ /** Read back the payload we just serialised, so the grid and the island share
647
+ * one resolution rather than computing it twice. */
648
+ function parseDerived(statusBlock) {
649
+ const m = statusBlock.match(/<script id="story-map-status" type="application\/json">\n([\s\S]*?)\n\s*<\/script>/);
650
+ if (!m) return {};
651
+ try {
652
+ return JSON.parse(m[1].replace(/\\u003c/g, '<'));
653
+ } catch {
654
+ return {};
655
+ }
656
+ }
657
+
658
+ /* There is no lead paragraph. It was persona plus an observation, and both were
659
+ * already on the page: the persona appears in every card's value statement ("as
660
+ * a developer", 24 times on STORY-MAP-002), and any observation about the shape
661
+ * of the work — how many columns, how much is live, which cells are empty — is
662
+ * countable from the grid the reader is looking at.
663
+ *
664
+ * A map shows the work. Where a specific column or row needs a note, both carry
665
+ * a `note` field for exactly that, next to the thing it is about. Prose at the
666
+ * top that restates the picture below it is the ADR-104 rule applied to writing.
667
+ */
668
+
669
+ function renderGrid(map, derived) {
670
+ const backbone = map.backbone ?? [];
671
+ const tasks = map.tasks ?? [];
672
+ const hrefs = derived.hrefs ?? {};
673
+ const statuses = derived.status ?? {};
674
+ const values = derived.values ?? {};
675
+ const releases = (map.releases ?? []).map((r) => ({
676
+ ...r,
677
+ status: (derived.rows ?? {})[r.id] || 'untraced',
678
+ problems: (derived.rowProblems ?? {})[r.id] || [],
679
+ }));
680
+
681
+ // A caption NAMES the table; it does not teach the reader how to use one.
682
+ //
683
+ // This used to run: "N journey activities across the top; M release bands down
684
+ // the side. Read a row left to right for everything that ships in one release.
685
+ // A cell with no cards means that activity ships nothing in that release."
686
+ // Every clause of that was already somewhere better. The lead paragraph says
687
+ // how to read the map, in the map's own terms rather than in generic grid
688
+ // terms. Each empty cell already carries its own visually-hidden sentence, so
689
+ // the last clause explained something the reader meets in place.
690
+ //
691
+ // What survives is the part a caption is for: a name, and the dimensions —
692
+ // which a screen-reader user gets BEFORE entering the table, and which are not
693
+ // stated anywhere else.
694
+ const caption = map.caption ||
695
+ `${map.title ?? map.storyMapId} — ${backbone.length} journey activities across ${releases.length} releases`;
696
+
697
+ let out = '<div class="scroll" tabindex="0" role="region" aria-label="Story map grid">';
698
+ out += `<table class="map"><caption>${esc(caption)}</caption>`;
699
+ out += '<thead><tr><td class="corner"></td>';
700
+ for (const a of backbone) {
701
+ // Explicit boundary: without it the accessible name concatenates as
702
+ // "A. NoticeJTBD-008". The spaces the other sites rely on come from
703
+ // display:block, which the accessible-name spec does not mandate.
704
+ out += `<th class="act" scope="col">${esc(a.title || '')}`;
705
+ if (a.note) out += ` <span class="jtbd">${esc(a.note)}</span>`;
706
+ out += '</th>';
707
+ }
708
+ out += '</tr></thead><tbody>';
709
+
710
+ for (const rel of releases) {
711
+ const cls = badgeClass(rel);
712
+ out += '<tr><th class="slice" scope="row">';
713
+ out += badge(cls, rowLabel(rel));
714
+ out += ' ' + link(hrefs, rel.rfc, `<span class="s-name">${esc(rel.name || '')}</span>`);
715
+ if (rel.note) out += ` <span class="s-note">${esc(rel.note)}</span>`;
716
+ if (rel.problems.length) {
717
+ out += '<span class="s-problems">closes ' +
718
+ rel.problems.map((p) => link(hrefs, p, esc(p))).join(', ') + '</span>';
719
+ }
720
+ out += '</th>';
721
+
722
+ const filled = backbone.map((act) =>
723
+ tasks.filter((t) => t.activity === act.id && t.release === rel.id));
724
+
725
+ if (filled.every((h) => h.length === 0)) {
726
+ // A wholly empty band is silent in a screen reader's browse mode while
727
+ // being a loud full-width hatch visually. One spanning cell states it
728
+ // once — per-cell text would bury a sparse map's few cards.
729
+ out += `<td class="cell empty" colspan="${backbone.length || 1}"><span class="vh">No stories in this release band.</span></td>`;
730
+ } else {
731
+ for (const [i, here] of filled.entries()) {
732
+ if (!here.length) {
733
+ out += `<td class="cell empty"><span class="vh">No stories for ${esc(backbone[i]?.title || 'this activity')} in ${esc(rel.name || rel.id || 'this release')}.</span></td>`;
734
+ continue;
735
+ }
736
+ out += '<td class="cell"><ul class="tasks" role="list">';
737
+ for (const t of here) {
738
+ out += '<li>' + cardHtml(t, statuses[t.storyId], values[t.storyId], hrefs) + '</li>';
739
+ }
740
+ out += '</ul></td>';
741
+ }
742
+ }
743
+ out += '</tr>';
744
+ }
745
+ return out + '</tbody></table></div>';
746
+ }
747
+
748
+ /* The legend and the map-level problems line are BOTH gone, deliberately.
749
+ *
750
+ * The legend listed every row with its badge, name and note — which is exactly
751
+ * what the first column of the grid shows, in the same order, three lines
752
+ * further down. It had stopped being a legend (a key to the glyphs) and become
753
+ * a second index of the rows. The glyphs need no key: each badge carries its
754
+ * own text, and the glyph is the redundant non-colour channel beside it.
755
+ *
756
+ * The map-level problems line was the union of what every row already shows in
757
+ * its own header. It was added when problems became derived, and it duplicated
758
+ * the thing it was derived into.
759
+ *
760
+ * Both are the ADR-104 rule applied to presentation rather than to data: do not
761
+ * show in two places what one place already says. The maintainer caught this as
762
+ * the seventh instance, introduced while fixing the sixth.
763
+ */
764
+
765
+ /** The traces, as one line of links, built from the island's `traces` object.
766
+ *
767
+ * This replaces five paragraphs of hand-written `traceProse` — persona, jobs
768
+ * mapped, problems closed, decisions rested on, open questions — every one of
769
+ * which restated something already on the page or already in the island. The
770
+ * persona is the island's own field and the lead's first sentence; the jobs are
771
+ * glossed on the backbone columns; the problems are derived onto each row, so
772
+ * the authored version drifted from them by construction; the decisions are
773
+ * `traces.adrs`; and "open questions" was a changelog entry, not a trace.
774
+ *
775
+ * Rendering the ids directly means there is no prose to keep in step. It is the
776
+ * ADR-104 rule again: show what is authored once, derive the rest, write
777
+ * nothing twice.
778
+ */
779
+ function renderTrace(map, derived) {
780
+ const hrefs = derived.hrefs ?? {};
781
+ const tr = map.traces ?? {};
782
+ // Jobs only. `Decisions` went with `traces.adrs` (ADR-106 — a map carries no
783
+ // decision trace). `RFCs` went too rather than being derived: it would have
784
+ // restated the row badges immediately beside it, which is the duplication
785
+ // deleted from the map-level problems line for the same reason.
786
+ const groups = [
787
+ ['Jobs', tr.jtbd],
788
+ ].filter(([, v]) => Array.isArray(v) && v.length);
789
+ if (!groups.length) return '';
790
+ const body = groups.map(([label, ids]) =>
791
+ `<span class="tr-group"><strong>${esc(label)}:</strong> ` +
792
+ ids.map((id) => link(hrefs, id, esc(id))).join(', ') + '</span>'
793
+ ).join('');
794
+ return `<p class="traces" id="story-map-traces">${body}</p>`;
795
+ }
796
+
797
+ /** Refuse a map whose cards have no stories behind them.
798
+ *
799
+ * A card IS a story's position in a journey. Without one it is a sketch wearing
800
+ * map vocabulary — and nine rows across two maps were exactly that, rendering
801
+ * as a tidy status rather than as the defect they were. "If the story doesn't
802
+ * exist, then it shouldn't even be in the map."
803
+ *
804
+ * This is also what makes an untraced row unreachable on a valid map: every
805
+ * card has a story, and ADR-060 I6 hard-blocks a story that traces no problem,
806
+ * so every row traces something.
807
+ *
808
+ * Only fires when a story corpus is present. Rendering from the published
809
+ * package resolves nothing at all, and refusing there would reject every valid
810
+ * map for lacking a tree that was never there (ADR-104's degrade-gracefully
811
+ * consequence).
812
+ */
813
+ /** Archived work is separated from live work.
814
+ *
815
+ * `archived` means closed WITHOUT completion — scope shifted, or superseded.
816
+ * A card for one sitting among live rows reads as abandoned capability: map
817
+ * 003 showed a working throttle marked Archived, because the card pointed at
818
+ * the record of the deleted first implementation rather than at the story
819
+ * that shipped the replacement. The reader cannot tell "this was dropped"
820
+ * from "this shipped, under a different story".
821
+ *
822
+ * So an archived story is off the map, or it is in a row that says what it
823
+ * holds. The rule runs both ways: a graveyard row takes nothing else, or it
824
+ * becomes a quiet place to park live work.
825
+ */
826
+ /** A story ships in exactly one release.
827
+ *
828
+ * Two cards for one story across two ACTIVITIES is the grid working — one
829
+ * piece of work can serve two steps of a journey. Across two ROWS it is a
830
+ * contradiction, because a row is a release. This fired for real: a card was
831
+ * repointed at a story that already had one on the same map, putting it in
832
+ * both the pre-RFC row and an RFC row, and the map was ratified before anyone
833
+ * noticed.
834
+ */
835
+ /** Refuse a map carrying a value statement the renderer cannot split.
836
+ *
837
+ * The fallback path renders an unsplittable statement as one undifferentiated
838
+ * block. On a map where every other card shows three labelled clauses, that
839
+ * is indistinguishable from a story written badly — so the parser's failure
840
+ * gets read as the author's, by a reader who has no way to tell them apart.
841
+ *
842
+ * This is the second time the class has fired. The first cost 15 stories
843
+ * their three lines; the fix was to loosen the pattern, and the pattern will
844
+ * always be narrower than the ways people write, so the class stayed open and
845
+ * STORY-060 walked into it. Loosening cannot close it. Refusing can: the
846
+ * renderer is the last point where anyone still knows a split was attempted.
847
+ *
848
+ * Silent only when there is genuinely nothing to check — no stories tree, or
849
+ * a story with no value section. A story that HAS one and will not split is
850
+ * either written outside the house shape or has found the pattern's next
851
+ * edge, and both are worth an edit rather than a wall of text.
852
+ */
853
+ function assertValueStatementsSplit(map, storiesDir) {
854
+ if (!storiesDir || !existsSync(storiesDir)) return;
855
+ const bad = [];
856
+ for (const id of new Set((map.tasks ?? []).map((t) => t.storyId).filter(Boolean))) {
857
+ const v = resolveStoryValue(storiesDir, id);
858
+ if (v && v.raw) bad.push([id, v.raw.map((r) => r.t).join('')]);
859
+ }
860
+ if (!bad.length) return;
861
+ const lines = ['this map has a story whose value statement will not split into its clauses.'];
862
+ for (const [id, text] of bad) {
863
+ lines.push(` - ${id}: ${text.length > 120 ? text.slice(0, 117) + '...' : text}`);
864
+ }
865
+ lines.push('');
866
+ lines.push(' A card renders the statement as three lines — the value, who it is');
867
+ lines.push(' for, and what they want. One that will not split renders as a single');
868
+ lines.push(' block, which on a map of three-line cards reads as a badly written');
869
+ lines.push(' story rather than a parser that gave up.');
870
+ lines.push('');
871
+ lines.push(' Write it as: In order to <value>, as <who>, I want <capability>.');
872
+ lines.push(' If it IS in that shape, the pattern has found a new edge — widen it');
873
+ lines.push(' in splitValue rather than reword the story to suit the regex.');
874
+ throw new Error(lines.join('\n'));
875
+ }
876
+
877
+ function assertOneReleasePerStory(map) {
878
+ const rows = new Map();
879
+ for (const t of map.tasks ?? []) {
880
+ if (!t.storyId) continue;
881
+ if (!rows.has(t.storyId)) rows.set(t.storyId, new Set());
882
+ rows.get(t.storyId).add(t.release);
883
+ }
884
+ const split = [...rows].filter(([, r]) => r.size > 1);
885
+ if (!split.length) return;
886
+ const lines = ['this map ships a story in more than one release.'];
887
+ for (const [id, r] of split) lines.push(` - ${id} appears in rows: ${[...r].join(', ')}`);
888
+ lines.push('');
889
+ lines.push(' A story may span activities — one piece of work can serve two');
890
+ lines.push(' steps of a journey — but a row is a release, and a story ships');
891
+ lines.push(' once. Keep the card in the row that actually delivers it.');
892
+ throw new Error(lines.join('\n'));
893
+ }
894
+
895
+ function assertArchivedIsSeparated(map, storiesDir) {
896
+ if (!storiesDir || !existsSync(storiesDir)) return;
897
+ const graveyard = new Set((map.releases ?? []).filter((r) => r.graveyard).map((r) => r.id));
898
+ const strays = [];
899
+ const intruders = [];
900
+ for (const t of map.tasks ?? []) {
901
+ if (!t.storyId) continue;
902
+ const archived = resolveStoryStatus(storiesDir, t.storyId) === 'archived';
903
+ const inGraveyard = graveyard.has(t.release);
904
+ if (archived && !inGraveyard) strays.push(`${t.storyId} ("${t.title ?? ''}") in row "${t.release}"`);
905
+ if (!archived && inGraveyard) intruders.push(`${t.storyId} ("${t.title ?? ''}")`);
906
+ }
907
+ if (!strays.length && !intruders.length) return;
908
+
909
+ const lines = [];
910
+ if (strays.length) {
911
+ lines.push('this map places archived stories among live work.');
912
+ for (const x of strays) lines.push(` - ${x}`);
913
+ lines.push('');
914
+ lines.push(' Archived means closed without completion. Among live rows it reads');
915
+ lines.push(' as abandoned capability, and a reader cannot tell that from work');
916
+ lines.push(' that shipped under a different story. Either take the card off the');
917
+ lines.push(' map, or move it to a graveyard row — a release carrying');
918
+ lines.push(' "graveyard": true, which says what it holds.');
919
+ }
920
+ if (intruders.length) {
921
+ if (lines.length) lines.push('');
922
+ lines.push(' A graveyard row holds archived stories only. These are not archived:');
923
+ for (const x of intruders) lines.push(` - ${x}`);
924
+ lines.push(' Otherwise it becomes a quiet place to park live work.');
925
+ }
926
+ throw new Error(lines.join('\n'));
927
+ }
928
+
929
+ function assertEveryCardHasAStory(map, storiesDir) {
930
+ if (!storiesDir || !existsSync(storiesDir)) return;
931
+ const orphans = [];
932
+ const dangling = [];
933
+ for (const t of map.tasks ?? []) {
934
+ if (!t.storyId) { orphans.push(t.title ?? '(untitled card)'); continue; }
935
+ if (!readStoryBody(storiesDir, t.storyId)) dangling.push(`${t.storyId} ("${t.title ?? ''}")`);
936
+ }
937
+ if (!orphans.length && !dangling.length) return;
938
+
939
+ const lines = ['this map has cards with no story behind them.'];
940
+ if (orphans.length) {
941
+ lines.push(` ${orphans.length} card(s) name no story at all:`);
942
+ for (const o of orphans) lines.push(` - "${o}"`);
943
+ }
944
+ if (dangling.length) {
945
+ lines.push(` ${dangling.length} card(s) name a story that does not exist:`);
946
+ for (const d of dangling) lines.push(` - ${d}`);
947
+ }
948
+ lines.push('');
949
+ lines.push(' Fix each one: capture the story (/wr-itil:capture-story, which');
950
+ lines.push(' hard-blocks a story that traces no problem — ADR-060 I6), or');
951
+ lines.push(' remove the card. A card is a story\'s position in a journey; a');
952
+ lines.push(' card without one is a sketch, and the map should not carry it.');
953
+ throw new Error(lines.join('\n'));
954
+ }
955
+
956
+ function render(map, storiesDir, mapPath) {
957
+ if (!Array.isArray(map.backbone) || map.backbone.length === 0) {
958
+ throw new Error('story map needs a non-empty "backbone" array (the journey activities)');
959
+ }
960
+ if (!Array.isArray(map.releases) || map.releases.length === 0) {
961
+ throw new Error('story map needs a non-empty "releases" array (the horizontal slices)');
962
+ }
963
+
964
+ assertEveryCardHasAStory(map, storiesDir);
965
+ assertArchivedIsSeparated(map, storiesDir);
966
+ assertOneReleasePerStory(map);
967
+ assertValueStatementsSplit(map, storiesDir);
968
+
969
+ const title = map.title ?? map.storyMapId;
970
+ // Everything derived from outside the island is resolved ONCE and shared by
971
+ // the island payload and the grid, so the two cannot disagree about a status,
972
+ // a value or a link.
973
+ const statusBlock = renderStatus(map, storiesDir, mapPath);
974
+ const derived = parseDerived(statusBlock);
975
+ const tokens = {
976
+ TITLE: esc(title),
977
+ TITLE_FULL: esc(`${map.storyMapId}: ${title}`),
978
+ DATA: serialiseIsland(map),
979
+ META: renderMeta(map, derived),
980
+ STATUS: statusBlock,
981
+ ORIENT: renderOrient(map),
982
+ GRID: renderGrid(map, derived),
983
+ TRACE: renderTrace(map, derived),
984
+ };
985
+
986
+ let out = readFileSync(TEMPLATE, 'utf8');
987
+ for (const [key, value] of Object.entries(tokens)) {
988
+ out = out.split(`{{${key}}}`).join(value);
989
+ }
990
+ return out;
991
+ }
992
+
993
+ /** Keep the shared stylesheet beside the maps. It is one copy for a whole
994
+ * corpus, so a restyle touches one file rather than every map — which was the
995
+ * duplication worth removing. The grid itself IS committed into each map, so
996
+ * that a map can be read with no script engine; the stylesheet staying shared
997
+ * is what keeps that affordable. */
998
+ function ensureSharedAssets(mapPath) {
999
+ const dest = dirname(dirname(mapPath));
1000
+ // Stylesheet only. The client script is gone: the grid is rendered into the
1001
+ // file, so nothing needs to run at view time.
1002
+ for (const name of ['story-map.css']) {
1003
+ const from = join(HERE, '..', 'templates', name);
1004
+ const to = join(dest, name);
1005
+ if (!existsSync(from)) continue;
1006
+ const src = readFileSync(from, 'utf8');
1007
+ if (!existsSync(to) || readFileSync(to, 'utf8') !== src) writeFileSync(to, src);
1008
+ }
1009
+ }
1010
+
1011
+ function main(argv) {
1012
+ const [src] = argv;
1013
+ if (!src) {
1014
+ console.error('usage: render-story-map.mjs <map.html>');
1015
+ console.error('');
1016
+ console.error(' Renders a story map in place from its own data island.');
1017
+ console.error(' To create a map, write a file containing just the island:');
1018
+ console.error('');
1019
+ console.error(' <script id="story-map-data" type="application/json">');
1020
+ console.error(' { "storyMapId": "...", "backbone": [...], "releases": [...] }');
1021
+ console.error(' </script>');
1022
+ console.error('');
1023
+ console.error(' then render it. Creation and editing are the same command.');
1024
+ return 2;
1025
+ }
1026
+ const srcPath = resolve(src);
1027
+ if (!existsSync(srcPath)) {
1028
+ console.error(`render-story-map: source not found: ${src}`);
1029
+ return 1;
1030
+ }
1031
+ if (!existsSync(TEMPLATE)) {
1032
+ console.error(`render-story-map: template not found: ${TEMPLATE}`);
1033
+ return 1;
1034
+ }
1035
+
1036
+ let map;
1037
+ try {
1038
+ map = extractIsland(readFileSync(srcPath, 'utf8'));
1039
+ } catch (err) {
1040
+ console.error(`render-story-map: ${src} — ${err.message}`);
1041
+ return 1;
1042
+ }
1043
+
1044
+ let html;
1045
+ try {
1046
+ html = render(map, storiesDirFor(srcPath), srcPath);
1047
+ } catch (err) {
1048
+ console.error(`render-story-map: ${src} — ${err.message}`);
1049
+ return 1;
1050
+ }
1051
+
1052
+ writeFileSync(srcPath, html);
1053
+ ensureSharedAssets(srcPath);
1054
+ return 0;
1055
+ }
1056
+
1057
+ process.exit(main(process.argv.slice(2)));