@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,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
|
-
|
|
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-
|
|
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
|
|