@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -14
- package/bin/{wr-itil-check-afk-accept-eligible → wr-itil-render-story-map} +2 -2
- package/bin/wr-itil-story-map-edit +51 -0
- package/bin/wr-itil-story-map-query +51 -0
- package/hooks/itil-no-implement-draft-gate.sh +19 -28
- package/lib/story-oversight.sh +154 -60
- package/package.json +2 -1
- package/scripts/check-rfc-stories-ratified.sh +15 -9
- package/scripts/detect-unratified-stories-maps.sh +15 -32
- package/scripts/mark-story-oversight-confirmed.sh +80 -31
- package/scripts/migrate-story-status-mirror.sh +7 -3
- package/scripts/reconcile-stories.sh +26 -5
- package/scripts/render-story-map.mjs +1057 -0
- package/scripts/render-story-map.sh +15 -0
- package/scripts/story-map-edit.mjs +280 -0
- package/scripts/story-map-edit.sh +10 -0
- package/scripts/story-map-query.mjs +227 -0
- package/scripts/story-map-query.sh +68 -0
- package/scripts/update-story-references-section.sh +6 -1
- package/skills/capture-rfc/SKILL.md +1 -1
- package/skills/capture-story/SKILL.md +16 -8
- package/skills/capture-story-map/SKILL.md +102 -59
- package/skills/list-stories/SKILL.md +16 -10
- package/skills/list-story-maps/SKILL.md +1 -1
- package/skills/manage-rfc/SKILL.md +1 -1
- package/skills/manage-story/SKILL.md +19 -26
- package/skills/manage-story-map/SKILL.md +19 -17
- package/skills/reconcile-stories/SKILL.md +5 -2
- package/skills/work-problems/SKILL.md +2 -2
- package/templates/story-map.css +195 -0
- package/templates/story-map.html +23 -0
- 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, '&')
|
|
47
|
+
.replace(/</g, '<')
|
|
48
|
+
.replace(/>/g, '>')
|
|
49
|
+
.replace(/"/g, '"');
|
|
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)));
|