@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,15 @@
1
+ #!/usr/bin/env bash
2
+ # Thin bash entry point for the story-map renderer, so the ADR-049/ADR-080
3
+ # shim machinery (which resolves `scripts/<NAME>.sh`) reaches the Node
4
+ # implementation without the generator needing to learn about .mjs.
5
+ #
6
+ # Usage: wr-itil-render-story-map <map.html>
7
+ #
8
+ # One mode: renders a map in place from its own data island. To create a map,
9
+ # write a file containing just the island and render it.
10
+ #
11
+ # @adr ADR-102 (story maps render from JSON through a canonical template)
12
+ # @adr ADR-049 (plugin scripts resolve via bin/ on $PATH)
13
+
14
+ set -euo pipefail
15
+ exec node "$(cd "$(dirname "$0")" && pwd)/render-story-map.mjs" "$@"
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+ // Card-level editing for a story map, so nobody hand-edits the data island.
3
+ //
4
+ // A map's data lives in a JSON island inside a several-hundred-line HTML file,
5
+ // with `<` escaped as <. Editing that with an exact-match string tool is
6
+ // error-prone enough that it does not get done: during ADR-102's own authoring
7
+ // every island change went through a throwaway load-mutate-save script rather
8
+ // than the editing tool an agent normally reaches for. This command is that
9
+ // script, made a first-class surface — state the operation, never open the JSON.
10
+ //
11
+ // Every operation validates against the map's own backbone and bands before
12
+ // writing, and re-renders afterwards, so the grid and the data cannot disagree.
13
+ // A rejected operation leaves the file untouched.
14
+ //
15
+ // Usage:
16
+ // story-map-edit.mjs <map.html> add-card --story ID --activity ID --release ID
17
+ // --title T [--ref R] [--rfc ID] [--jtbd ID]
18
+ // story-map-edit.mjs <map.html> move-card --story ID [--activity ID] [--release ID]
19
+ // story-map-edit.mjs <map.html> remove-card --story ID
20
+ // story-map-edit.mjs <map.html> add-band --id ID --name N [--rfc RFC-NNN] [--note N]
21
+ // story-map-edit.mjs <map.html> add-activity --id ID --title T [--note N]
22
+ //
23
+ // Nothing the story file already says is settable here — status, the value
24
+ // statement, and the problems a story closes are all read from the story at
25
+ // render time (ADR-104). A card carries what identifies it and where it sits;
26
+ // a row carries its RFC identity and its name. There are no --status, --value
27
+ // or --badge flags, and a row's status and problems are derived from the
28
+ // stories in it.
29
+ //
30
+ // @adr ADR-102 (story maps render from JSON through a canonical template)
31
+ // @adr ADR-095 (story-map membership enforced at capture — drives add-card)
32
+
33
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
34
+ import { dirname, join, resolve } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { execFileSync } from 'node:child_process';
37
+
38
+ const HERE = dirname(fileURLToPath(import.meta.url));
39
+ const RENDERER = join(HERE, 'render-story-map.mjs');
40
+ const ISLAND_OPEN = '<script id="story-map-data" type="application/json">';
41
+
42
+ function parseFlags(argv) {
43
+ const out = {};
44
+ for (let i = 0; i < argv.length; i += 1) {
45
+ const a = argv[i];
46
+ if (!a.startsWith('--')) continue;
47
+ const key = a.slice(2);
48
+ const next = argv[i + 1];
49
+ out[key] = next && !next.startsWith('--') ? next : true;
50
+ if (out[key] !== true) i += 1;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function readIsland(path) {
56
+ const html = readFileSync(path, 'utf8');
57
+ const start = html.indexOf(ISLAND_OPEN);
58
+ if (start === -1) {
59
+ throw new Error(
60
+ `${path} carries no <script id="story-map-data"> block — not a story map.`
61
+ );
62
+ }
63
+ const from = start + ISLAND_OPEN.length;
64
+ const end = html.indexOf('</script>', from);
65
+ if (end === -1) throw new Error(`${path}: data block is not closed.`);
66
+ return { html, from, end, data: JSON.parse(html.slice(from, end).replace(/\\u003c/g, '<')) };
67
+ }
68
+
69
+ function writeIsland(path, { html, from, end }, data) {
70
+ const island = JSON.stringify(data, null, 2).replace(/</g, '\\u003c');
71
+ writeFileSync(path, html.slice(0, from) + '\n' + island + '\n ' + html.slice(end));
72
+ }
73
+
74
+ function need(flags, name, op) {
75
+ const v = flags[name];
76
+ if (!v || v === true) throw new Error(`${op} needs --${name}`);
77
+ return String(v);
78
+ }
79
+
80
+ /** Reject an id that is not on the map, naming what IS available — a bare
81
+ * "invalid activity" sends the caller back to open the island, which is the
82
+ * thing this command exists to avoid. */
83
+ function requireId(list, id, what) {
84
+ const ids = list.map((x) => x.id);
85
+ if (!ids.includes(id)) {
86
+ throw new Error(`no ${what} "${id}" on this map. Available: ${ids.join(', ')}`);
87
+ }
88
+ }
89
+
90
+ const OPS = {
91
+ 'add-card'(data, flags) {
92
+ const story = need(flags, 'story', 'add-card');
93
+ const activity = need(flags, 'activity', 'add-card');
94
+ const release = need(flags, 'release', 'add-card');
95
+ const title = need(flags, 'title', 'add-card');
96
+ requireId(data.backbone ?? [], activity, 'activity');
97
+ requireId(data.releases ?? [], release, 'release band');
98
+ data.tasks = data.tasks ?? [];
99
+ if (data.tasks.some((t) => t.storyId === story)) {
100
+ throw new Error(`${story} is already on this map — use move-card to relocate it.`);
101
+ }
102
+ const card = { activity, release, title };
103
+ if (flags.rfc && flags.rfc !== true) card.rfc = String(flags.rfc);
104
+ if (flags.jtbd && flags.jtbd !== true) card.jtbd = String(flags.jtbd);
105
+ card.storyId = story;
106
+ if (flags.ref && flags.ref !== true) card.ref = String(flags.ref);
107
+ data.tasks.push(card);
108
+ return `added ${story} at ${activity} × ${release}`;
109
+ },
110
+
111
+ 'move-card'(data, flags) {
112
+ const story = need(flags, 'story', 'move-card');
113
+ const card = (data.tasks ?? []).find((t) => t.storyId === story);
114
+ if (!card) throw new Error(`${story} is not on this map.`);
115
+ if (flags.activity && flags.activity !== true) {
116
+ requireId(data.backbone ?? [], String(flags.activity), 'activity');
117
+ card.activity = String(flags.activity);
118
+ }
119
+ if (flags.release && flags.release !== true) {
120
+ requireId(data.releases ?? [], String(flags.release), 'release band');
121
+ card.release = String(flags.release);
122
+ }
123
+ return `moved ${story} to ${card.activity} × ${card.release}`;
124
+ },
125
+
126
+ 'remove-card'(data, flags) {
127
+ const story = need(flags, 'story', 'remove-card');
128
+ const before = (data.tasks ?? []).length;
129
+ data.tasks = (data.tasks ?? []).filter((t) => t.storyId !== story);
130
+ if (data.tasks.length === before) throw new Error(`${story} is not on this map.`);
131
+ return `removed ${story}`;
132
+ },
133
+
134
+ 'add-band'(data, flags) {
135
+ const id = need(flags, 'id', 'add-band');
136
+ const name = need(flags, 'name', 'add-band');
137
+ data.releases = data.releases ?? [];
138
+ if (data.releases.some((r) => r.id === id)) {
139
+ throw new Error(`release band "${id}" already exists on this map.`);
140
+ }
141
+ const band = { id, name };
142
+ // A row IS an RFC (ADR-103). Where a problem has proposed the row it carries
143
+ // that identity; where nothing has, it renders as speculative and says so.
144
+ if (flags.rfc && flags.rfc !== true) band.rfc = String(flags.rfc);
145
+ if (flags.note && flags.note !== true) band.note = String(flags.note);
146
+ data.releases.push(band);
147
+ return `added release band ${id}`;
148
+ },
149
+
150
+ 'add-activity'(data, flags) {
151
+ const id = need(flags, 'id', 'add-activity');
152
+ const title = need(flags, 'title', 'add-activity');
153
+ data.backbone = data.backbone ?? [];
154
+ if (data.backbone.some((a) => a.id === id)) {
155
+ throw new Error(`activity "${id}" already exists on this map.`);
156
+ }
157
+ const act = { id, title };
158
+ if (flags.note && flags.note !== true) act.note = String(flags.note);
159
+ data.backbone.push(act);
160
+ return `added activity ${id}`;
161
+ },
162
+ };
163
+
164
+ function usage() {
165
+ console.error('usage: story-map-edit.mjs <map.html> <operation> [flags]');
166
+ console.error('');
167
+ console.error(' add-card --story ID --activity ID --release ID --title T');
168
+ console.error(' [--ref R] [--rfc ID] [--jtbd ID]');
169
+ console.error(' move-card --story ID [--activity ID] [--release ID]');
170
+ console.error(' remove-card --story ID');
171
+ console.error(' add-band --id ID --name N [--rfc RFC-NNN] [--note N]');
172
+ console.error(' add-activity --id ID --title T [--note N]');
173
+ console.error('');
174
+ console.error('Or pass the operation as JSON on stdin, which needs no shell escaping:');
175
+ console.error(' echo \'{"op":"add-card","story":"STORY-1","activity":"a","release":"r1","title":"He said \\"go\\" — then left"}\' \\');
176
+ console.error(' | story-map-edit.mjs <map.html> --json -');
177
+ console.error('');
178
+ console.error('Status, value and problems are derived from the story files at');
179
+ console.error('render time and are not settable here (ADR-104).');
180
+ }
181
+
182
+ /** Read an operation as a JSON object on stdin.
183
+ *
184
+ * The escaping problem this closes: a card title or value carrying quotes, an
185
+ * em-dash or a newline is painful to pass through a shell command line and
186
+ * trivial to pass as JSON. Validation is the same code path as the flag form —
187
+ * the object is simply converted to the flag map the operations already take.
188
+ *
189
+ * NOTE: the sibling story-map-query uses stdin for the opposite purpose — it
190
+ * consumes facts fed in by its own wrapper. Do not assume symmetry. */
191
+ function flagsFromStdin() {
192
+ let raw;
193
+ try {
194
+ raw = readFileSync(0, 'utf8');
195
+ } catch {
196
+ throw new Error('--json - expects the operation as a JSON object on stdin');
197
+ }
198
+ let obj;
199
+ try {
200
+ obj = JSON.parse(raw);
201
+ } catch (err) {
202
+ throw new Error(`stdin is not valid JSON — ${err.message}`);
203
+ }
204
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
205
+ throw new Error('stdin must be a JSON object, e.g. {"op":"add-card","story":"STORY-1",...}');
206
+ }
207
+ const { op, ...flags } = obj;
208
+ if (!op) throw new Error('the JSON object needs an "op", e.g. "add-card"');
209
+ return { op: String(op), flags };
210
+ }
211
+
212
+ function main(argv) {
213
+ let [target, op, ...rest] = argv;
214
+
215
+ // `--json -` replaces the operation and flags with a JSON object on stdin.
216
+ if (argv.includes('--json')) {
217
+ const path = argv[0];
218
+ let parsed;
219
+ try {
220
+ parsed = flagsFromStdin();
221
+ } catch (err) {
222
+ console.error(`story-map-edit: ${err.message}`);
223
+ return 1;
224
+ }
225
+ return run(path, parsed.op, parsed.flags);
226
+ }
227
+
228
+ if (!target || !op) {
229
+ usage();
230
+ return 2;
231
+ }
232
+ return run(target, op, parseFlags(rest));
233
+ }
234
+
235
+ /** The single execution path. Both the flag form and the stdin form land here
236
+ * with an operation name and a flag map, so validation cannot diverge between
237
+ * them — an equivalence asserted in prose drifts the first time a branch is
238
+ * added to one form only. */
239
+ function run(target, op, flags) {
240
+ if (!OPS[op]) {
241
+ console.error(`story-map-edit: unknown operation "${op}"`);
242
+ usage();
243
+ return 2;
244
+ }
245
+ const path = resolve(target);
246
+ if (!existsSync(path)) {
247
+ console.error(`story-map-edit: not found: ${target}`);
248
+ return 1;
249
+ }
250
+
251
+ let island;
252
+ try {
253
+ island = readIsland(path);
254
+ } catch (err) {
255
+ console.error(`story-map-edit: ${err.message}`);
256
+ return 1;
257
+ }
258
+
259
+ // Mutate a copy, so a rejected operation cannot leave the file half-edited.
260
+ const draft = JSON.parse(JSON.stringify(island.data));
261
+ let summary;
262
+ try {
263
+ summary = OPS[op](draft, flags);
264
+ } catch (err) {
265
+ console.error(`story-map-edit: ${err.message}`);
266
+ return 1;
267
+ }
268
+
269
+ writeIsland(path, island, draft);
270
+ try {
271
+ execFileSync(process.execPath, [RENDERER, path], { stdio: 'pipe' });
272
+ } catch (err) {
273
+ console.error(`story-map-edit: wrote the data but re-render failed — ${err.message}`);
274
+ return 1;
275
+ }
276
+ console.error(`story-map-edit: ${summary}`);
277
+ return 0;
278
+ }
279
+
280
+ process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ # Bash entry point for the story-map card editor, so the ADR-049/ADR-080 shim
3
+ # machinery (which resolves `scripts/<NAME>.sh`) reaches the Node implementation.
4
+ #
5
+ # Usage: wr-itil-story-map-edit <map.html> <operation> [flags]
6
+ #
7
+ # @adr ADR-102 (story maps render from JSON through a canonical template)
8
+ # @adr ADR-049 (plugin scripts resolve via bin/ on $PATH)
9
+ set -euo pipefail
10
+ exec node "$(cd "$(dirname "$0")" && pwd)/story-map-edit.mjs" "$@"
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ // Read-only JSON query over the story-map corpus. Presentation and island
3
+ // parsing only — oversight facts arrive pre-computed on stdin from
4
+ // story-map-query.sh, which owns the single hash definition. Nothing here
5
+ // hashes anything; see that wrapper's header for why.
6
+ //
7
+ // Reads the data island, never the rendered grid. Under ADR-102 the grid is a
8
+ // projection regenerated from the island, so a map edited but not yet
9
+ // re-rendered would answer with stale content if this scraped the markup — and
10
+ // would disagree with the oversight hash, which is island-scoped.
11
+ //
12
+ // Operations:
13
+ // list every map: id, title, status, jobs, problems,
14
+ // RFC row identities, ratification
15
+ // get <MAP-ID> one map: backbone, releases, tasks
16
+ // find-story <STORY-ID> which maps hold a story, and in which cell
17
+ // find-rfc <RFC-ID> which release rows carry an RFC and their stories
18
+ // unratified only the maps needing ratification, with a reason
19
+ //
20
+ // @adr ADR-102 (story maps render from JSON through a canonical template)
21
+ // @adr ADR-090 (drift-invalidated human-oversight marker)
22
+
23
+ import { readFileSync } from 'node:fs';
24
+
25
+ const ISLAND_OPEN = '<script id="story-map-data" type="application/json">';
26
+
27
+ /** Read the authored data. Falls through to null for a pre-ADR-102 map, which
28
+ * has no island — mirroring _oversight_hashable's own fallthrough rather than
29
+ * inventing a second rule about what counts as a map. */
30
+ function island(path) {
31
+ let html;
32
+ try {
33
+ html = readFileSync(path, 'utf8');
34
+ } catch {
35
+ return null;
36
+ }
37
+ const start = html.indexOf(ISLAND_OPEN);
38
+ if (start === -1) return null;
39
+ const from = start + ISLAND_OPEN.length;
40
+ const end = html.indexOf('</script>', from);
41
+ if (end === -1) return null;
42
+ try {
43
+ return JSON.parse(html.slice(from, end).replace(/\\u003c/g, '<'));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** Everything the renderer derived from outside the island — story statuses and
50
+ * values, row statuses, and resolved hrefs. Emitted alongside the data island
51
+ * when the renderer runs in a repository; outside one it is absent. */
52
+ function derivedIsland(mapPath) {
53
+ let html;
54
+ try {
55
+ html = readFileSync(mapPath, 'utf8');
56
+ } catch {
57
+ return {};
58
+ }
59
+ const OPEN = '<script id="story-map-status" type="application/json">';
60
+ const start = html.indexOf(OPEN);
61
+ if (start === -1) return {};
62
+ const from = start + OPEN.length;
63
+ const end = html.indexOf('</script>', from);
64
+ if (end === -1) return {};
65
+ try {
66
+ const d = JSON.parse(html.slice(from, end).replace(/\\u003c/g, '<'));
67
+ // The derived island grew from a flat {storyId: status} map into
68
+ // {rows, status, values, hrefs}. Normalise the older shape so a map
69
+ // rendered by a previous version is still readable — this reader is not the
70
+ // place to force a re-render.
71
+ if (!d || typeof d !== 'object') return {};
72
+ return d.status || d.rows || d.values || d.hrefs ? d : { status: d };
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+
78
+ /** Oversight facts from the bash wrapper: path, ratified, reason. */
79
+ function readFacts() {
80
+ let raw = '';
81
+ try {
82
+ raw = readFileSync(0, 'utf8');
83
+ } catch {
84
+ return [];
85
+ }
86
+ return raw
87
+ .split('\n')
88
+ .filter(Boolean)
89
+ .map((line) => {
90
+ const [path, ratified, reason] = line.split('\t');
91
+ return { path, ratified: ratified === 'true', reason };
92
+ });
93
+ }
94
+
95
+ /** Row status is DERIVED, and this reads the value the renderer emitted rather
96
+ * than recomputing it. There was a second copy of the derivation here; two
97
+ * definitions of one fact is the drift class this map format has already
98
+ * removed four times over. The renderer owns it because it is the only layer
99
+ * that can read story files.
100
+ *
101
+ * A map edited but not re-rendered answers `stale` for every row — a value the
102
+ * renderer never emits, so it reads as "no derived answer" rather than
103
+ * impersonating a verdict. It is the same staleness the story statuses and card
104
+ * values already carry, and `story-map-edit` re-renders after every operation
105
+ * so it does not arise in practice.
106
+ */
107
+ function rowStatus(row, derived) {
108
+ return (derived.rows ?? {})[row.id] ?? 'stale';
109
+ }
110
+
111
+ function corpus() {
112
+ return readFacts()
113
+ .map((f) => ({ ...f, data: island(f.path), derived: derivedIsland(f.path) }))
114
+ .filter((m) => m.data)
115
+ .sort((a, b) => String(a.data.storyMapId).localeCompare(String(b.data.storyMapId)));
116
+ }
117
+
118
+ function summary(m) {
119
+ return {
120
+ storyMapId: m.data.storyMapId,
121
+ title: m.data.title ?? null,
122
+ status: m.data.status ?? null,
123
+ persona: m.data.persona ?? null,
124
+ // `traces` holds ONE authored key — the jobs the map is drawn for. Spelled
125
+ // out rather than passed through, so a stale island carrying the removed
126
+ // `adrs`/`rfcs` cannot leak them back into the query's answer.
127
+ traces: { jtbd: m.data.traces?.jtbd ?? [] },
128
+ // Derived, and read from what the renderer emitted rather than recomputed —
129
+ // the renderer is the definition, and two definitions of one fact is the
130
+ // drift class this format has removed repeatedly. Row RFC identities are
131
+ // AUTHORED, so they come from the island; only problems are derived.
132
+ problems: m.derived?.mapProblems ?? [],
133
+ rfcs: [...new Set((m.data.releases ?? []).map((r) => r.rfc).filter(Boolean))],
134
+ ratified: m.ratified,
135
+ reason: m.reason,
136
+ activities: (m.data.backbone ?? []).length,
137
+ releases: (m.data.releases ?? []).length,
138
+ cards: (m.data.tasks ?? []).length,
139
+ path: m.path,
140
+ };
141
+ }
142
+
143
+ const OPS = {
144
+ list: (maps) => maps.map(summary),
145
+
146
+ get(maps, [id]) {
147
+ if (!id) throw new Error('get needs a story-map id, e.g. STORY-MAP-002');
148
+ const m = maps.find((x) => x.data.storyMapId === id);
149
+ if (!m) throw new Error(`no story map ${id} in this corpus`);
150
+ return {
151
+ ...summary(m),
152
+ backbone: m.data.backbone ?? [],
153
+ releasesDetail: (m.data.releases ?? []).map((r) => ({
154
+ ...r,
155
+ status: rowStatus(r, m.derived ?? {}),
156
+ })),
157
+ tasks: m.data.tasks ?? [],
158
+ };
159
+ },
160
+
161
+ 'find-story': (maps, [storyId]) => {
162
+ if (!storyId) throw new Error('find-story needs a story id, e.g. STORY-047');
163
+ const hits = [];
164
+ for (const m of maps) {
165
+ for (const t of m.data.tasks ?? []) {
166
+ if (t.storyId !== storyId) continue;
167
+ hits.push({
168
+ storyMapId: m.data.storyMapId,
169
+ activity: t.activity,
170
+ release: t.release,
171
+ title: t.title ?? null,
172
+ ratified: m.ratified,
173
+ path: m.path,
174
+ });
175
+ }
176
+ }
177
+ return hits;
178
+ },
179
+
180
+ 'find-rfc': (maps, [rfcId]) => {
181
+ if (!rfcId) throw new Error('find-rfc needs an RFC id, e.g. RFC-062');
182
+ const hits = [];
183
+ for (const m of maps) {
184
+ for (const row of m.data.releases ?? []) {
185
+ if (row.rfc !== rfcId) continue;
186
+ hits.push({
187
+ storyMapId: m.data.storyMapId,
188
+ rowId: row.id,
189
+ status: rowStatus(row, m.derived ?? {}),
190
+ stories: [...new Set((m.data.tasks ?? [])
191
+ .filter((task) => task.release === row.id)
192
+ .map((task) => task.storyId)
193
+ .filter(Boolean))],
194
+ path: m.path,
195
+ });
196
+ }
197
+ }
198
+ return hits;
199
+ },
200
+
201
+ unratified: (maps) => maps.filter((m) => !m.ratified).map(summary),
202
+ };
203
+
204
+ function main(argv) {
205
+ const [op, ...rest] = argv;
206
+ if (!op || !OPS[op]) {
207
+ console.error('usage: story-map-query <list|get|find-story|find-rfc|unratified> [args] [--maps-dir DIR]');
208
+ console.error('');
209
+ console.error(' list every map: status, jobs, problems, RFC rows, ratification');
210
+ console.error(' get <MAP-ID> one map: backbone, release bands, cards');
211
+ console.error(' find-story <STORY-ID> which maps hold a story, and in which cell');
212
+ console.error(' find-rfc <RFC-ID> which release rows carry an RFC and their stories');
213
+ console.error(' unratified maps needing ratification, each with a reason');
214
+ return 2;
215
+ }
216
+ let out;
217
+ try {
218
+ out = OPS[op](corpus(), rest);
219
+ } catch (err) {
220
+ console.error(`story-map-query: ${err.message}`);
221
+ return 1;
222
+ }
223
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
224
+ return 0;
225
+ }
226
+
227
+ process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # Read-only JSON query over the story-map corpus.
3
+ #
4
+ # Usage: wr-itil-story-map-query <list|get|find-story|find-rfc|unratified> [args] [--maps-dir DIR]
5
+ #
6
+ # WHY THIS IS A BASH ENTRY POINT AND NOT JUST A .mjs. Ratification state is
7
+ # drift-invalidated and must come from ONE hash definition — the one in
8
+ # lib/story-oversight.sh that detect-unratified-stories-maps.sh,
9
+ # check-rfc-stories-ratified.sh and mark-story-oversight-confirmed.sh all share.
10
+ # A JavaScript re-implementation would be a fourth definition, and the pair
11
+ # would disagree the first time either drifted. So this wrapper sources the lib,
12
+ # computes the oversight facts per map in bash, and pipes them to the Node half,
13
+ # which does presentation and island parsing only. No hashing in JavaScript.
14
+ #
15
+ # NOTE ON STDIN: this tool consumes stdin for facts fed in by this wrapper, so
16
+ # stdin is not available as a user input channel here. Its sibling
17
+ # story-map-edit reads an operation FROM stdin under `--json -`. The asymmetry
18
+ # is deliberate; do not assume symmetry between the two.
19
+ #
20
+ # @adr ADR-102 (story maps render from JSON through a canonical template)
21
+ # @adr ADR-090 (drift-invalidated human-oversight marker)
22
+ # @adr ADR-049 (plugin scripts resolve via bin/ on $PATH)
23
+
24
+ set -uo pipefail
25
+
26
+ HERE="$(cd "$(dirname "$0")" && pwd)"
27
+ # shellcheck source=/dev/null
28
+ . "$HERE/../lib/story-oversight.sh"
29
+
30
+ MAPS_DIR="docs/story-maps"
31
+ ARGS=()
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ --maps-dir) MAPS_DIR="${2:-}"; shift 2 ;;
35
+ *) ARGS+=("$1"); shift ;;
36
+ esac
37
+ done
38
+
39
+ # Same two globs as detect-unratified-stories-maps.sh, and the same default.
40
+ # A different corpus would make the two surfaces disagree about which maps
41
+ # exist while agreeing perfectly on every per-map verdict — the more confusing
42
+ # failure, and one this repo has already hit once via a subdirectory-glob miss.
43
+ shopt -s nullglob
44
+ MAPS=("$MAPS_DIR"/*.html "$MAPS_DIR"/*/*.html)
45
+ shopt -u nullglob
46
+
47
+ # One facts line per map. `reason` is decided HERE, not in JS: separating
48
+ # drift-reopened from never-ratified needs a hash comparison, and doing that on
49
+ # the JS side is exactly how a fourth hash definition appears.
50
+ facts() {
51
+ local f confirmed stored fresh ratified reason
52
+ for f in "${MAPS[@]}"; do
53
+ [ -f "$f" ] || continue
54
+ confirmed=false; stored=""; ratified=false; reason="never-ratified"
55
+ if oversight_is_confirmed "$f" 2>/dev/null; then confirmed=true; fi
56
+ stored="$(oversight_stored_hash "$f" 2>/dev/null || true)"
57
+ if [ "$confirmed" = true ] && [ -z "$stored" ]; then
58
+ reason="legacy-confirmed-without-fingerprint"
59
+ elif [ "$confirmed" = true ]; then
60
+ fresh="$(oversight_content_hash "$f" 2>/dev/null || true)"
61
+ if [ "$stored" = "$fresh" ]; then ratified=true; reason="ratified"
62
+ else reason="drift-reopened"; fi
63
+ fi
64
+ printf '%s\t%s\t%s\n' "$f" "$ratified" "$reason"
65
+ done
66
+ }
67
+
68
+ facts | exec node "$HERE/story-map-query.mjs" "${ARGS[@]+"${ARGS[@]}"}"
@@ -51,7 +51,12 @@ extract_from_html_data_story_id() {
51
51
  local file="$1"
52
52
  # Story maps reference stories via <a data-story-id="STORY-NNN"> per ADR-060
53
53
  # amendment schema; grep on the literal attribute match.
54
- grep -qE "data-story-id=\"${story_id}\"" "$file"
54
+ # Two spellings carry the same fact, and a rendered map carries BOTH: the
55
+ # card as data-story-id="X", and the authored island entry as "storyId": "X".
56
+ # Pre-ADR-102 maps have only the first; a map edited but not yet re-rendered
57
+ # has only the second. Matching both covers every state. The alternation is
58
+ # boolean per file, so a map carrying both spellings is not double-listed.
59
+ grep -qE "data-story-id=\"${story_id}\"|\"storyId\"[[:space:]]*:[[:space:]]*\"${story_id}\"" "$file"
55
60
  }
56
61
 
57
62
  extract_id_from_filename() { basename "$1" | grep -oE "$id_pattern" | head -1; }
@@ -295,7 +295,7 @@ After the commit, report:
295
295
 
296
296
  The trailing pointer is **not optional** — on the user-aside path it is the user-visible signal that the RFC is intentionally skeleton-only and how to advance it; on the `--fix-time` path it signals the RFC is already authored and points at the ratification drain.
297
297
 
298
- **Oversight marker discipline (ADR-066 + ADR-068 amendments 2026-06-02 / P348).** The skeleton frontmatter MUST include `human-oversight: unconfirmed`. capture-rfc is the AFK-friendly aside surface; there is no substance-confirm `AskUserQuestion` pass in this flow (deferred to `/wr-itil:manage-rfc accepted`), so `confirmed` would be a hollow marker (the P348 bug class). The architect-side hook (`architect-oversight-marker-discipline.sh`) does NOT gate `docs/rfcs/`, but downstream paths that promote a captured RFC to `confirmed` MUST do so via a proper substance-confirm AskUserQuestion (e.g. `/wr-itil:manage-rfc accepted` for genuine ratification) — agents authoring RFCs at capture-time MUST NOT write `confirmed`. The drain pattern mirrors the ADR drain: an RFC's `unconfirmed` state surfaces interactively during the `accepted` transition.
298
+ **Oversight marker discipline (ADR-110 / P348).** The skeleton frontmatter MUST include `human-oversight: unconfirmed`. capture-rfc is the AFK-friendly aside surface; there is no substance-confirm `AskUserQuestion` pass in this flow (deferred to `/wr-itil:manage-rfc accepted`), so `confirmed` would be a hollow marker (the P348 bug class). The architect-side hook (`architect-oversight-marker-discipline.sh`) does NOT gate `docs/rfcs/`, but downstream paths that promote a captured RFC to `confirmed` MUST do so via a proper substance-confirm AskUserQuestion (e.g. `/wr-itil:manage-rfc accepted` for genuine ratification) — agents authoring RFCs at capture-time MUST NOT write `confirmed`. The drain pattern mirrors the ADR drain: an RFC's `unconfirmed` state surfaces interactively during the `accepted` transition.
299
299
 
300
300
  ## Composition with manage-rfc
301
301