@windyroad/itil 2.2.1-preview.1195 → 2.3.0-preview.1211
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/.codex-plugin/plugin.json +1 -1
- package/bin/wr-itil-migrate-story-map +51 -0
- package/lib/story-oversight.sh +13 -7
- package/package.json +1 -1
- package/scripts/migrate-story-map.mjs +76 -0
- package/scripts/migrate-story-map.sh +3 -0
- package/scripts/render-story-map.mjs +28 -6
- package/scripts/story-map-history.mjs +132 -0
- package/scripts/story-map-query.mjs +5 -1
- package/skills/capture-story-map/SKILL.md +1 -1
- package/skills/manage-story-map/SKILL.md +1 -1
- package/skills/migrate-story-map/SKILL.md +21 -0
- package/skills-codex/capture-story-map/SKILL.md +1 -1
- package/skills-codex/manage-story-map/SKILL.md +1 -1
- package/skills-codex/migrate-story-map/SKILL.md +32 -0
- package/templates/story-map.css +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Generated by scripts/sync-shim-wrappers.sh from
|
|
3
|
+
# packages/shared/lib/shim-wrapper-template.sh. DO NOT EDIT individual
|
|
4
|
+
# shim files in packages/*/bin/wr-* directly; edit the template + run
|
|
5
|
+
# `npm run sync:shim-wrappers` to regenerate.
|
|
6
|
+
#
|
|
7
|
+
# Resolution (highest-version-wins-shim-wrapper-for-plugin-scaffold-template-shims-architecture-rule):
|
|
8
|
+
# 1. If the wrapper's parent dir is semver-shaped, treat as installed-
|
|
9
|
+
# cache execution and resolve to the highest-version sibling's
|
|
10
|
+
# scripts/ entry below.
|
|
11
|
+
# 2. Otherwise (parent dir is e.g. `architect`), treat as source-
|
|
12
|
+
# monorepo execution and dispatch to own scripts/. The source-repo-
|
|
13
|
+
# guard `exec` is the anchor parsed by
|
|
14
|
+
# packages/retrospective/scripts/check-tarball-shipped-shims.sh.
|
|
15
|
+
# 3. If the cache parent contains zero semver-shaped siblings, exit
|
|
16
|
+
# 127 with a stderr message naming the cache parent (per SQ-080-2).
|
|
17
|
+
#
|
|
18
|
+
# @adr highest-version-wins-shim-wrapper-for-plugin-scaffold-template-shims-architecture-rule (highest-version-wins shim wrapper plugin scaffold)
|
|
19
|
+
# @adr plugin-bundled-scripts-invoked-from-skill-md-resolve-via-bin-on-path-architecture-rule (plugin-bundled scripts resolve via bin/ on $PATH — amended)
|
|
20
|
+
# @problem install-updates-refreshes-the-global-plugin-cache-but-does-not-fix-path-ordering-stale-plugin-version-shims-stay-first-on-path-so-subsequent-shim-invocations-run-old-code-problem (mid-session staleness window)
|
|
21
|
+
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
25
|
+
OWN_VERSION_DIR="$(dirname "$SHIM_DIR")"
|
|
26
|
+
OWN_VERSION_NAME="$(basename "$OWN_VERSION_DIR")"
|
|
27
|
+
CACHE_PARENT="$(dirname "$OWN_VERSION_DIR")"
|
|
28
|
+
|
|
29
|
+
SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'
|
|
30
|
+
|
|
31
|
+
# Source-repo guard: own parent dir is NOT semver → dispatch to own scripts/.
|
|
32
|
+
if ! [[ "$OWN_VERSION_NAME" =~ $SEMVER_RE ]]; then
|
|
33
|
+
exec "$SHIM_DIR/../scripts/migrate-story-map.sh" "$@"
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Cache execution: pick the highest-semver sibling under CACHE_PARENT.
|
|
37
|
+
HIGHEST=""
|
|
38
|
+
while IFS= read -r dir; do
|
|
39
|
+
name="$(basename "$dir")"
|
|
40
|
+
[[ "$name" =~ $SEMVER_RE ]] || continue
|
|
41
|
+
if [[ -z "$HIGHEST" ]] || [[ "$(printf '%s\n%s\n' "$HIGHEST" "$name" | sort -V | tail -1)" == "$name" ]]; then
|
|
42
|
+
HIGHEST="$name"
|
|
43
|
+
fi
|
|
44
|
+
done < <(find "$CACHE_PARENT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
|
|
45
|
+
|
|
46
|
+
if [[ -z "$HIGHEST" ]]; then
|
|
47
|
+
printf 'wr-shim: no cached versions in %s\n' "$CACHE_PARENT" >&2
|
|
48
|
+
exit 127
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
exec "$CACHE_PARENT/$HIGHEST/scripts/migrate-story-map.sh" "$@"
|
package/lib/story-oversight.sh
CHANGED
|
@@ -57,7 +57,8 @@ oversight_excluded_keys() {
|
|
|
57
57
|
printf '%s\n' human-oversight oversight-hash oversight-basis status
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
# The MAP-SUBSTANCE key set:
|
|
60
|
+
# The MAP-SUBSTANCE key set: island keys plus the derived historical-row key a
|
|
61
|
+
# story map's ratification hangs on.
|
|
61
62
|
#
|
|
62
63
|
# Editing this set — here or in the SUBSTANCE tuple below — changes story-maps-and-stories-carry-a-drift-invalidated-human-oversight-marker-architecture-rule's
|
|
63
64
|
# Decision Outcome as amended by a-release-row-is-the-rfc-and-the-map-is-the-approval-surface-architecture-rule, not merely a mechanism. It IS the drift
|
|
@@ -66,10 +67,11 @@ oversight_excluded_keys() {
|
|
|
66
67
|
# whenever both move together, which is the shape of exactly that edit. The
|
|
67
68
|
# sibling notice on _oversight_filter says the same thing for the same reason.
|
|
68
69
|
#
|
|
69
|
-
# SCOPE: island
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
70
|
+
# SCOPE: data-island substance only. `historicalPreRfcRows` is derived from the
|
|
71
|
+
# island's manifested historical release rows rather than authored as a key.
|
|
72
|
+
# Unlike oversight_excluded_keys, this set is meaningless for anything with no
|
|
73
|
+
# island — stories and pre-story-maps-render-from-json-through-a-canonical-template-architecture-rule maps fall through to whole-file bytes. The
|
|
74
|
+
# two are not complements; `excluded ∪ substance` is not the key space.
|
|
73
75
|
#
|
|
74
76
|
# THIS IS THE ONLY ENUMERATION. Six documents used to restate it (story-maps-and-stories-carry-a-drift-invalidated-human-oversight-marker-architecture-rule,
|
|
75
77
|
# a-release-row-is-the-rfc-and-the-map-is-the-approval-surface-architecture-rule, the-grid-ships-in-the-file-a-map-is-readable-with-no-script-engine-architecture-rule, capture-story-map, and manage-story-map twice) and by
|
|
@@ -83,7 +85,7 @@ oversight_excluded_keys() {
|
|
|
83
85
|
# derives its pattern from oversight_excluded_keys. A consumer that hardcodes the
|
|
84
86
|
# list starts a seventh enumeration.
|
|
85
87
|
oversight_map_substance_keys() {
|
|
86
|
-
printf '%s\n' storyMapId title persona secondaryPersona traces backbone caption
|
|
88
|
+
printf '%s\n' storyMapId title persona secondaryPersona traces backbone caption historicalPreRfcRows
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
# Emit the bytes a fingerprint should cover.
|
|
@@ -130,7 +132,11 @@ except Exception:
|
|
|
130
132
|
# nothing documents would still have been ratification-bearing.
|
|
131
133
|
SUBSTANCE = ("storyMapId", "title", "persona", "secondaryPersona",
|
|
132
134
|
"traces", "backbone", "caption")
|
|
133
|
-
|
|
135
|
+
basis = {k: d[k] for k in SUBSTANCE if k in d}
|
|
136
|
+
historical = [r for r in d.get("releases", []) if "historicalProjection" in r]
|
|
137
|
+
if historical:
|
|
138
|
+
basis["historicalPreRfcRows"] = historical
|
|
139
|
+
print(json.dumps(basis, sort_keys=True, indent=2))
|
|
134
140
|
'
|
|
135
141
|
else
|
|
136
142
|
cat "$f"
|
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { historicalProjectionHash, validateHistoricalContent, validateHistoricalMapping } from './story-map-history.mjs';
|
|
8
|
+
|
|
9
|
+
const [targetArg, mappingArg] = process.argv.slice(2);
|
|
10
|
+
if (!targetArg || !mappingArg) {
|
|
11
|
+
console.error('usage: migrate-story-map.mjs <legacy-map.html> <mapping.json>');
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const unresolvedTarget = resolve(targetArg);
|
|
16
|
+
if (!existsSync(unresolvedTarget)) process.exit(0);
|
|
17
|
+
const target = realpathSync(unresolvedTarget);
|
|
18
|
+
const source = readFileSync(target);
|
|
19
|
+
if (source.includes(Buffer.from('<script id="story-map-data"'))) process.exit(0);
|
|
20
|
+
|
|
21
|
+
const mapping = JSON.parse(readFileSync(resolve(mappingArg), 'utf8'));
|
|
22
|
+
const allowed = new Set(['mapId', 'path', 'authorityAdr', 'sourceCommit', 'sourceSha256', 'map']);
|
|
23
|
+
if (!mapping || typeof mapping !== 'object' || Object.keys(mapping).some((key) => !allowed.has(key)) ||
|
|
24
|
+
Object.keys(mapping).length !== allowed.size) {
|
|
25
|
+
throw new Error('mapping must contain exactly mapId, path, authorityAdr, sourceCommit, sourceSha256, and map');
|
|
26
|
+
}
|
|
27
|
+
const actualPath = relative(process.cwd(), target).split(sep).join('/');
|
|
28
|
+
const sourceSha256 = createHash('sha256').update(source).digest('hex');
|
|
29
|
+
if (mapping.path !== actualPath || mapping.mapId !== mapping.map?.storyMapId ||
|
|
30
|
+
mapping.sourceSha256 !== sourceSha256 || !mapping.sourceCommit) {
|
|
31
|
+
throw new Error('mapping identity, path, source commit, or source fingerprint does not match the selected map');
|
|
32
|
+
}
|
|
33
|
+
if (!validateHistoricalContent(mapping.map)) throw new Error('mapping contains no historical pre-RFC rows');
|
|
34
|
+
|
|
35
|
+
const manifestPath = resolve('docs/story-maps/legacy-projection-manifest.json');
|
|
36
|
+
const previousManifest = existsSync(manifestPath) ? readFileSync(manifestPath) : null;
|
|
37
|
+
const manifest = previousManifest ? JSON.parse(previousManifest) : { schemaVersion: 1, maps: [] };
|
|
38
|
+
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.maps) ||
|
|
39
|
+
manifest.maps.some((entry) => entry?.mapId === mapping.mapId || entry?.path === mapping.path)) {
|
|
40
|
+
throw new Error('legacy projection manifest is invalid or already contains the selected map');
|
|
41
|
+
}
|
|
42
|
+
const entry = {
|
|
43
|
+
mapId: mapping.mapId,
|
|
44
|
+
path: mapping.path,
|
|
45
|
+
authorityAdr: mapping.authorityAdr,
|
|
46
|
+
sourceCommit: mapping.sourceCommit,
|
|
47
|
+
sourceSha256,
|
|
48
|
+
historicalSha256: historicalProjectionHash(mapping.map),
|
|
49
|
+
canonicalSha256: '0'.repeat(64),
|
|
50
|
+
};
|
|
51
|
+
validateHistoricalMapping(mapping.map, target, entry);
|
|
52
|
+
manifest.maps.push(entry);
|
|
53
|
+
|
|
54
|
+
const render = () => {
|
|
55
|
+
const result = spawnSync(process.execPath, [resolve(import.meta.dirname, 'render-story-map.mjs'), target], { encoding: 'utf8' });
|
|
56
|
+
if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'story-map render failed');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const island = `<script id="story-map-data" type="application/json">\n${JSON.stringify(mapping.map, null, 2).replaceAll('<', '\\u003c')}\n</script>\n`;
|
|
61
|
+
writeFileSync(target, island);
|
|
62
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
63
|
+
render();
|
|
64
|
+
entry.canonicalSha256 = createHash('sha256').update(readFileSync(target)).digest('hex');
|
|
65
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
66
|
+
render();
|
|
67
|
+
const verified = createHash('sha256').update(readFileSync(target)).digest('hex');
|
|
68
|
+
if (verified !== entry.canonicalSha256) throw new Error('canonical story-map fingerprint changed during verification');
|
|
69
|
+
} catch (error) {
|
|
70
|
+
writeFileSync(target, source);
|
|
71
|
+
if (previousManifest) writeFileSync(manifestPath, previousManifest);
|
|
72
|
+
else rmSync(manifestPath, { force: true });
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(`migrated ${mapping.mapId} to the canonical story-map format`);
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
37
37
|
import { dirname, join, resolve, relative, sep } from 'node:path';
|
|
38
38
|
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { validateHistoricalRows } from './story-map-history.mjs';
|
|
39
40
|
|
|
40
41
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
41
42
|
const TEMPLATE = join(HERE, '..', 'templates', 'story-map.html');
|
|
@@ -397,6 +398,7 @@ function resolveHref(mapPath, id) {
|
|
|
397
398
|
* when card status was stored.
|
|
398
399
|
*/
|
|
399
400
|
function rowStatus(row, tasks, statuses) {
|
|
401
|
+
if (row.historicalProjection) return 'historical';
|
|
400
402
|
const mine = tasks.filter((t) => t.release === row.id);
|
|
401
403
|
const terminal = (s) => s === 'done' || s === 'archived';
|
|
402
404
|
const shipped = mine.length && mine.every((t) => terminal(statuses[t.storyId]));
|
|
@@ -511,7 +513,7 @@ function renderStatus(map, storiesDir, mapPath) {
|
|
|
511
513
|
* alongside colour.
|
|
512
514
|
* ------------------------------------------------------------------------- */
|
|
513
515
|
|
|
514
|
-
const BADGE_GLYPH = { 'b-live': '\u2713', 'b-next': '\u2192', 'b-defect': '\u26a0' };
|
|
516
|
+
const BADGE_GLYPH = { 'b-live': '\u2713', 'b-next': '\u2192', 'b-history': '\u25c7', 'b-defect': '\u26a0' };
|
|
515
517
|
|
|
516
518
|
/** Status as a class. Derived, never an authored badge — a hand-written R1/R2
|
|
517
519
|
* ordinal duplicated the RFC identity and collided with it. */
|
|
@@ -519,6 +521,7 @@ function badgeClass(rel) {
|
|
|
519
521
|
switch (String(rel.status || '').toLowerCase()) {
|
|
520
522
|
case 'delivered': return 'b-live';
|
|
521
523
|
case 'proposed': return rel.rfc ? 'b-next' : 'b-defect';
|
|
524
|
+
case 'historical': return 'b-history';
|
|
522
525
|
default: return 'b-defect';
|
|
523
526
|
}
|
|
524
527
|
}
|
|
@@ -543,6 +546,10 @@ function badgeClass(rel) {
|
|
|
543
546
|
*/
|
|
544
547
|
function rowLabel(rel) {
|
|
545
548
|
const status = String(rel.status || '').toLowerCase();
|
|
549
|
+
if (status === 'historical') {
|
|
550
|
+
const projection = rel.historicalProjection;
|
|
551
|
+
return `Historical context: ${projection.sourceLabel} as of ${projection.reported}`;
|
|
552
|
+
}
|
|
546
553
|
if (rel.rfc) {
|
|
547
554
|
if (status === 'delivered') return `Delivered: ${rel.rfc}`;
|
|
548
555
|
if (status === 'proposed') return `Proposed: ${rel.rfc}`;
|
|
@@ -647,6 +654,15 @@ function cardHtml(task, status, value, hrefs) {
|
|
|
647
654
|
return out + '</div>';
|
|
648
655
|
}
|
|
649
656
|
|
|
657
|
+
function historicalCardHtml(card) {
|
|
658
|
+
let out = '<div class="task historical-card">';
|
|
659
|
+
out += '<div class="t-status ts-arch">Historical context</div>';
|
|
660
|
+
out += `<span class="t-title">${esc(card.title)}</span>`;
|
|
661
|
+
out += `<div class="t-value">${esc(card.text)}</div>`;
|
|
662
|
+
out += `<div class="t-ref">Recorded status: ${esc(card.statusLabel)}</div>`;
|
|
663
|
+
return out + '</div>';
|
|
664
|
+
}
|
|
665
|
+
|
|
650
666
|
/** The whole grid: caption, both header axes, and one row per release. */
|
|
651
667
|
/** Read back the payload we just serialised, so the grid and the island share
|
|
652
668
|
* one resolution rather than computing it twice. */
|
|
@@ -724,23 +740,28 @@ function renderGrid(map, derived) {
|
|
|
724
740
|
}
|
|
725
741
|
out += '</th>';
|
|
726
742
|
|
|
727
|
-
const
|
|
728
|
-
|
|
743
|
+
const historicalCards = rel.historicalProjection?.cards ?? [];
|
|
744
|
+
const filled = backbone.map((act) => rel.historicalProjection
|
|
745
|
+
? historicalCards.filter((card) => card.activity === act.id)
|
|
746
|
+
: tasks.filter((t) => t.activity === act.id && t.release === rel.id));
|
|
747
|
+
const noun = rel.historicalProjection ? 'historical cards' : 'stories';
|
|
729
748
|
|
|
730
749
|
if (filled.every((h) => h.length === 0)) {
|
|
731
750
|
// A wholly empty band is silent in a screen reader's browse mode while
|
|
732
751
|
// being a loud full-width hatch visually. One spanning cell states it
|
|
733
752
|
// once — per-cell text would bury a sparse map's few cards.
|
|
734
|
-
out += `<td class="cell empty" colspan="${backbone.length || 1}"><span class="vh">No
|
|
753
|
+
out += `<td class="cell empty" colspan="${backbone.length || 1}"><span class="vh">No ${noun} in this release band.</span></td>`;
|
|
735
754
|
} else {
|
|
736
755
|
for (const [i, here] of filled.entries()) {
|
|
737
756
|
if (!here.length) {
|
|
738
|
-
out += `<td class="cell empty"><span class="vh">No
|
|
757
|
+
out += `<td class="cell empty"><span class="vh">No ${noun} for ${esc(backbone[i]?.title || 'this activity')} in ${esc(rel.name || rel.id || 'this release')}.</span></td>`;
|
|
739
758
|
continue;
|
|
740
759
|
}
|
|
741
760
|
out += '<td class="cell"><ul class="tasks" role="list">';
|
|
742
761
|
for (const t of here) {
|
|
743
|
-
out += '<li>' +
|
|
762
|
+
out += '<li>' + (rel.historicalProjection
|
|
763
|
+
? historicalCardHtml(t)
|
|
764
|
+
: cardHtml(t, statuses[t.storyId], values[t.storyId], hrefs)) + '</li>';
|
|
744
765
|
}
|
|
745
766
|
out += '</ul></td>';
|
|
746
767
|
}
|
|
@@ -966,6 +987,7 @@ function render(map, storiesDir, mapPath) {
|
|
|
966
987
|
throw new Error('story map needs a non-empty "releases" array (the horizontal slices)');
|
|
967
988
|
}
|
|
968
989
|
|
|
990
|
+
validateHistoricalRows(map, mapPath);
|
|
969
991
|
assertEveryCardHasAStory(map, storiesDir);
|
|
970
992
|
assertArchivedIsSeparated(map, storiesDir);
|
|
971
993
|
assertOneReleasePerStory(map);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
7
|
+
const keysAre = (value, allowed) => Object.keys(value).every((key) => allowed.has(key));
|
|
8
|
+
|
|
9
|
+
function stable(value) {
|
|
10
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
11
|
+
if (!value || typeof value !== 'object') return value;
|
|
12
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sha256(value) {
|
|
16
|
+
return createHash('sha256').update(value).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function historicalProjectionHash(map) {
|
|
20
|
+
const rows = (map.releases ?? [])
|
|
21
|
+
.filter((row) => row?.historicalProjection !== undefined)
|
|
22
|
+
.map(({ id, name, note, preRfc, historicalProjection }) =>
|
|
23
|
+
stable({ id, name, note, preRfc, historicalProjection }));
|
|
24
|
+
return sha256(JSON.stringify(rows));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function authorityProjectionHash(body, mapId) {
|
|
28
|
+
if (!/^human-oversight:\s*confirmed\s*$/m.test(body)) return null;
|
|
29
|
+
const lines = body.split('\n');
|
|
30
|
+
const start = lines.findIndex((line) => /^legacy-projections:\s*$/.test(line));
|
|
31
|
+
if (start === -1) return null;
|
|
32
|
+
const found = new Map();
|
|
33
|
+
for (const line of lines.slice(start + 1)) {
|
|
34
|
+
if (!line.trim()) continue;
|
|
35
|
+
if (!/^\s+/.test(line)) break;
|
|
36
|
+
const match = line.match(/^\s+(STORY-MAP-\d+):\s*([a-f0-9]{64})\s*$/);
|
|
37
|
+
if (!match || found.has(match[1])) return null;
|
|
38
|
+
found.set(match[1], match[2]);
|
|
39
|
+
}
|
|
40
|
+
return found.get(mapId) ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decisionFile(docsRoot, authorityAdr) {
|
|
44
|
+
const match = String(authorityAdr ?? '').match(/^ADR-(\d+)$/);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
const prefix = `${String(Number(match[1])).padStart(3, '0')}-`;
|
|
47
|
+
const dir = join(docsRoot, 'decisions');
|
|
48
|
+
try {
|
|
49
|
+
const hits = readdirSync(dir).filter((name) => name.startsWith(prefix) && name.endsWith('.md'));
|
|
50
|
+
return hits.length === 1 ? join(dir, hits[0]) : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateHistoricalContent(map) {
|
|
57
|
+
const historical = (map.releases ?? []).filter((row) => row?.historicalProjection !== undefined);
|
|
58
|
+
if (!historical.length) return false;
|
|
59
|
+
|
|
60
|
+
const activities = new Set((map.backbone ?? []).map((activity) => activity.id));
|
|
61
|
+
for (const row of historical) {
|
|
62
|
+
const projection = row.historicalProjection;
|
|
63
|
+
if (row.preRfc !== true || row.rfc || !projection || typeof projection !== 'object' ||
|
|
64
|
+
!keysAre(row, new Set(['id', 'name', 'note', 'preRfc', 'historicalProjection']))) {
|
|
65
|
+
throw new Error(`historical row ${row.id ?? '(missing id)'} must use preRfc: true, contain historicalProjection, and contain no rfc`);
|
|
66
|
+
}
|
|
67
|
+
if (!keysAre(projection, new Set(['reported', 'sourceBandId', 'sourceLabel', 'sourceNote', 'cards'])) ||
|
|
68
|
+
!projection.reported || !projection.sourceBandId || !projection.sourceLabel ||
|
|
69
|
+
typeof projection.sourceNote !== 'string' || !Array.isArray(projection.cards)) {
|
|
70
|
+
throw new Error(`historical row ${row.id ?? '(missing id)'} has an incomplete historicalProjection`);
|
|
71
|
+
}
|
|
72
|
+
if ((map.tasks ?? []).some((task) => task.release === row.id)) {
|
|
73
|
+
throw new Error(`historical row ${row.id} cannot contain ordinary tasks`);
|
|
74
|
+
}
|
|
75
|
+
for (const card of projection.cards) {
|
|
76
|
+
if (!card || !keysAre(card, new Set(['activity', 'title', 'text', 'statusLabel'])) ||
|
|
77
|
+
!activities.has(card.activity) || !card.title || !card.text || !card.statusLabel ||
|
|
78
|
+
card.storyId || card.release || card.rfc) {
|
|
79
|
+
throw new Error(`historical row ${row.id} has an invalid historical card`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateHistoricalMapping(map, mapPath, entry) {
|
|
88
|
+
if (!validateHistoricalContent(map)) return;
|
|
89
|
+
if (!mapPath) throw new Error('historical pre-RFC rows require a repository path');
|
|
90
|
+
|
|
91
|
+
let storyMapsRoot = dirname(mapPath);
|
|
92
|
+
if (basename(storyMapsRoot) !== 'story-maps') storyMapsRoot = dirname(storyMapsRoot);
|
|
93
|
+
if (basename(storyMapsRoot) !== 'story-maps') throw new Error('historical story map must live in docs/story-maps');
|
|
94
|
+
const docsRoot = dirname(storyMapsRoot);
|
|
95
|
+
const repoRoot = dirname(docsRoot);
|
|
96
|
+
const expectedPath = relative(repoRoot, mapPath).split(sep).join('/');
|
|
97
|
+
const hash = historicalProjectionHash(map);
|
|
98
|
+
if (entry.path !== expectedPath || entry.historicalSha256 !== hash ||
|
|
99
|
+
!SHA256.test(entry.sourceSha256 ?? '') || !SHA256.test(entry.canonicalSha256 ?? '') ||
|
|
100
|
+
!/^[a-f0-9]{7,64}$/.test(entry.sourceCommit ?? '')) {
|
|
101
|
+
throw new Error(`legacy projection manifest entry for ${map.storyMapId} does not match the map`);
|
|
102
|
+
}
|
|
103
|
+
const retained = spawnSync('git', ['-C', repoRoot, 'show', `${entry.sourceCommit}:${entry.path}`]);
|
|
104
|
+
if (retained.status !== 0 || sha256(retained.stdout) !== entry.sourceSha256) {
|
|
105
|
+
throw new Error(`legacy projection source fingerprint for ${map.storyMapId} does not match retained history`);
|
|
106
|
+
}
|
|
107
|
+
const authority = decisionFile(docsRoot, entry.authorityAdr);
|
|
108
|
+
if (!authority || !existsSync(authority)) {
|
|
109
|
+
throw new Error(`legacy projection authority ${entry.authorityAdr ?? '(missing)'} cannot be resolved`);
|
|
110
|
+
}
|
|
111
|
+
const authorisedHash = authorityProjectionHash(readFileSync(authority, 'utf8'), map.storyMapId);
|
|
112
|
+
if (authorisedHash !== hash) throw new Error(`legacy projection authority ${entry.authorityAdr} is unconfirmed or mismatched`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function validateHistoricalRows(map, mapPath) {
|
|
116
|
+
if (!validateHistoricalContent(map)) return;
|
|
117
|
+
let storyMapsRoot = dirname(mapPath);
|
|
118
|
+
if (basename(storyMapsRoot) !== 'story-maps') storyMapsRoot = dirname(storyMapsRoot);
|
|
119
|
+
const manifestPath = join(storyMapsRoot, 'legacy-projection-manifest.json');
|
|
120
|
+
let manifest;
|
|
121
|
+
try {
|
|
122
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
123
|
+
} catch {
|
|
124
|
+
throw new Error('historical pre-RFC rows require a valid legacy-projection-manifest.json');
|
|
125
|
+
}
|
|
126
|
+
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.maps)) {
|
|
127
|
+
throw new Error('legacy projection manifest has an unsupported schema');
|
|
128
|
+
}
|
|
129
|
+
const matches = manifest.maps.filter((entry) => entry?.mapId === map.storyMapId);
|
|
130
|
+
if (matches.length !== 1) throw new Error(`legacy projection manifest must contain exactly one entry for ${map.storyMapId}`);
|
|
131
|
+
validateHistoricalMapping(map, mapPath, matches[0]);
|
|
132
|
+
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// @adr story-maps-and-stories-carry-a-drift-invalidated-human-oversight-marker-architecture-rule (drift-invalidated human-oversight marker)
|
|
24
24
|
|
|
25
25
|
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { validateHistoricalRows } from './story-map-history.mjs';
|
|
26
27
|
|
|
27
28
|
const ISLAND_OPEN = '<script id="story-map-data" type="application/json">';
|
|
28
29
|
|
|
@@ -41,11 +42,14 @@ function island(path) {
|
|
|
41
42
|
const from = start + ISLAND_OPEN.length;
|
|
42
43
|
const end = html.indexOf('</script>', from);
|
|
43
44
|
if (end === -1) return null;
|
|
45
|
+
let data;
|
|
44
46
|
try {
|
|
45
|
-
|
|
47
|
+
data = JSON.parse(html.slice(from, end).replace(/\\u003c/g, '<'));
|
|
46
48
|
} catch {
|
|
47
49
|
return null;
|
|
48
50
|
}
|
|
51
|
+
validateHistoricalRows(data, path);
|
|
52
|
+
return data;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
/** Everything the renderer derived from outside the island — story statuses and
|
|
@@ -188,7 +188,7 @@ appear here changes what that test asserts, and that binding is deliberate.
|
|
|
188
188
|
- **A row IS an RFC (the "A release row is the RFC, and the map is the approval surface" architecture rule).** A row a problem has proposed carries its `rfc`; drawing the row is what allocates the identity. There is no separate "not yet allocated" state and no `badge` field — a row's status is derived from its stories, and its label is its RFC id.
|
|
189
189
|
- **Every row carries an identity, and finishing one earns no exemption (the "A story map's RFC list is derived from its release rows" architecture rule).** A row with no `rfc` renders as a defect — a red "Untraced" badge — whether or not its stories are done. Delivery cannot excuse a missing identity, because every row is delivered eventually; that reading would let work nobody proposed become legitimate by being finished.
|
|
190
190
|
|
|
191
|
-
The
|
|
191
|
+
The ordinary exception is a row holding work that shipped **before rows carried identities**, and such a row says so explicitly with `"preRfc": true`. That set is closed. Do not add the marker to a newly captured row: it is a statement about history, not a way to skip allocating an RFC. A separate manifest-bound historical subtype may be written only by `/wr-itil:migrate-story-map` from a retained legacy map under exact confirmed adopter authority; capture never authors it. It appears in no example above because the example shows the normal case.
|
|
192
192
|
- **A map carries no `traces.rfcs` (the "A story map's RFC list is derived from its release rows" architecture rule).** The map's RFC list is the union of its row identities, so authoring it restates the rows and drifts from them the moment one changes.
|
|
193
193
|
- `storyId` / `rfc` / `jtbd` are optional per task and emit the `data-*` reference layer that reverse-trace and the story-map queries consume. Omit them until stories exist; add them as stories are captured onto the map.
|
|
194
194
|
- **Author nothing a story file already says (the "A story map card stores no value a story file already carries" architecture rule).** A story's lifecycle state, its value statement, and the problems it closes are all read from the story when the map renders — so a transition needs no map edit and does not re-open the map's ratification. There are no `storyStatus`, `value` or row-level `problems` fields, and no `--status` or `--value` flags. A row's problems are the union of its stories'; a map's are the union of its rows'.
|
|
@@ -116,7 +116,7 @@ Per architect amend finding 2 on Slice 7: story-map HTML files do NOT carry an a
|
|
|
116
116
|
|
|
117
117
|
### 7.5. Ratification flow (`ratify`) — the "Story maps and stories carry a drift-invalidated human-oversight marker" architecture rule / the "A release row is the RFC, and the map is the approval surface" architecture rule / the ": Ratify the story map and its stories after any change" delivery story
|
|
118
118
|
|
|
119
|
-
`ratify` is **orthogonal to the status lifecycle** — a map can be ratified at any status. It confirms human oversight of **the map**, and under the "A release row is the RFC, and the map is the approval surface" architecture rule that approves every story on it, including stories added later; stories are never ratified individually. Ratification is **drift-invalidated** (the "Gate Marker Lifecycle: TTL + Drift, Not Stop-Hook Reset" architecture rule lineage, NOT the ": Human-oversight marker + `/wr-architect:review-decisions` drain for recorded decisions" architecture rule write-once), but only a **substance** edit re-opens it: the map's own substance as the "Story maps and stories carry a drift-invalidated human-oversight marker" architecture rule defines it — its journey, its identity,
|
|
119
|
+
`ratify` is **orthogonal to the status lifecycle** — a map can be ratified at any status. It confirms human oversight of **the map**, and under the "A release row is the RFC, and the map is the approval surface" architecture rule that approves every ordinary story on it, including stories added later; stories are never ratified individually. Ratification is **drift-invalidated** (the "Gate Marker Lifecycle: TTL + Drift, Not Stop-Hook Reset" architecture rule lineage, NOT the ": Human-oversight marker + `/wr-architect:review-decisions` drain for recorded decisions" architecture rule write-once), but only a **substance** edit re-opens it: the map's own substance as the "Story maps and stories carry a drift-invalidated human-oversight marker" architecture rule defines it — its journey, its identity, what it traces to, and any manifest-bound historical projection. `oversight_map_substance_keys()` is the authoritative field list; this page deliberately does not restate it. Ordinary release rows and cards, story-body edits and template restyling sit outside the fingerprint basis and change nothing. Historical projections are the narrow exception and may be added only through `/wr-itil:migrate-story-map` under exact confirmed adopter authority. This is the ": Ratify the story map and its stories after any change" delivery story surface.
|
|
120
120
|
|
|
121
121
|
**Born-confirmed discipline (the "iter subprocesses set `human-oversight: confirmed` marker on ADRs / personas / JTBDs without an actual user-confirmation event" problem).** Before any marker write, `export CLAUDE_SESSION_ID` from the transcript path — the marker shim silently no-ops on an empty SID. Every `confirmed` marker MUST be backed by a same-turn human confirm event; never write `confirmed` without the `AskUserQuestion` below (a hollow marker is the "iter subprocesses set `human-oversight: confirmed` marker on ADRs / personas / JTBDs without an actual user-confirmation event" problem bug).
|
|
122
122
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wr-itil:migrate-story-map
|
|
3
|
+
description: "Replace one eligible legacy story map with a manifest-bound canonical map from a complete adopter-ratified mapping."
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Grep, Glob
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Migrate Story Map
|
|
8
|
+
|
|
9
|
+
Use this only for a retained legacy map whose exact historical projection is authorised by a confirmed adopter decision under the manifest-bound historical-row architecture rule.
|
|
10
|
+
|
|
11
|
+
## Steps
|
|
12
|
+
|
|
13
|
+
1. Read the complete legacy map and the supplied structured mapping. Confirm every historical card and placement comes from the retained source; do not infer or add content.
|
|
14
|
+
2. Confirm the cited decision has `human-oversight: confirmed` and maps the selected story-map identifier to the mapping's historical SHA-256 fingerprint under `legacy-projections`.
|
|
15
|
+
3. Run `wr-itil-migrate-story-map <legacy-map.html> <mapping.json>` from the adopter repository root. The command fails before writing on incomplete or mismatched input, and restores the source and manifest if rendering fails. It exits successfully without changes when the map is absent or already canonical.
|
|
16
|
+
4. Run the repository's story-map query, renderer, accessibility, and governance checks.
|
|
17
|
+
5. Run the repository's required risk review, then commit the replaced map and manifest as one auditable migration.
|
|
18
|
+
|
|
19
|
+
Do not use this skill to create new history, approve an RFC, create stories, or interpret arbitrary legacy HTML automatically.
|
|
20
|
+
|
|
21
|
+
$ARGUMENTS
|
|
@@ -199,7 +199,7 @@ appear here changes what that test asserts, and that binding is deliberate.
|
|
|
199
199
|
- **A row IS an RFC (the "A release row is the RFC, and the map is the approval surface" architecture rule).** A row a problem has proposed carries its `rfc`; drawing the row is what allocates the identity. There is no separate "not yet allocated" state and no `badge` field — a row's status is derived from its stories, and its label is its RFC id.
|
|
200
200
|
- **Every row carries an identity, and finishing one earns no exemption (the "A story map's RFC list is derived from its release rows" architecture rule).** A row with no `rfc` renders as a defect — a red "Untraced" badge — whether or not its stories are done. Delivery cannot excuse a missing identity, because every row is delivered eventually; that reading would let work nobody proposed become legitimate by being finished.
|
|
201
201
|
|
|
202
|
-
The
|
|
202
|
+
The ordinary exception is a row holding work that shipped **before rows carried identities**, and such a row says so explicitly with `"preRfc": true`. That set is closed. Do not add the marker to a newly captured row: it is a statement about history, not a way to skip allocating an RFC. A separate manifest-bound historical subtype may be written only by `/wr-itil:migrate-story-map` from a retained legacy map under exact confirmed adopter authority; capture never authors it. It appears in no example above because the example shows the normal case.
|
|
203
203
|
- **A map carries no `traces.rfcs` (the "A story map's RFC list is derived from its release rows" architecture rule).** The map's RFC list is the union of its row identities, so authoring it restates the rows and drifts from them the moment one changes.
|
|
204
204
|
- `storyId` / `rfc` / `jtbd` are optional per task and emit the `data-*` reference layer that reverse-trace and the story-map queries consume. Omit them until stories exist; add them as stories are captured onto the map.
|
|
205
205
|
- **Author nothing a story file already says (the "A story map card stores no value a story file already carries" architecture rule).** A story's lifecycle state, its value statement, and the problems it closes are all read from the story when the map renders — so a transition needs no map edit and does not re-open the map's ratification. There are no `storyStatus`, `value` or row-level `problems` fields, and no `--status` or `--value` flags. A row's problems are the union of its stories'; a map's are the union of its rows'.
|
|
@@ -127,7 +127,7 @@ Per architect amend finding 2 on Slice 7: story-map HTML files do NOT carry an a
|
|
|
127
127
|
|
|
128
128
|
### 7.5. Ratification flow (`ratify`) — the "Story maps and stories carry a drift-invalidated human-oversight marker" architecture rule / the "A release row is the RFC, and the map is the approval surface" architecture rule / the ": Ratify the story map and its stories after any change" delivery story
|
|
129
129
|
|
|
130
|
-
`ratify` is **orthogonal to the status lifecycle** — a map can be ratified at any status. It confirms human oversight of **the map**, and under the "A release row is the RFC, and the map is the approval surface" architecture rule that approves every story on it, including stories added later; stories are never ratified individually. Ratification is **drift-invalidated** (the "Gate Marker Lifecycle: TTL + Drift, Not Stop-Hook Reset" architecture rule lineage, NOT the ": Human-oversight marker + `/wr-architect:review-decisions` drain for recorded decisions" architecture rule write-once), but only a **substance** edit re-opens it: the map's own substance as the "Story maps and stories carry a drift-invalidated human-oversight marker" architecture rule defines it — its journey, its identity,
|
|
130
|
+
`ratify` is **orthogonal to the status lifecycle** — a map can be ratified at any status. It confirms human oversight of **the map**, and under the "A release row is the RFC, and the map is the approval surface" architecture rule that approves every ordinary story on it, including stories added later; stories are never ratified individually. Ratification is **drift-invalidated** (the "Gate Marker Lifecycle: TTL + Drift, Not Stop-Hook Reset" architecture rule lineage, NOT the ": Human-oversight marker + `/wr-architect:review-decisions` drain for recorded decisions" architecture rule write-once), but only a **substance** edit re-opens it: the map's own substance as the "Story maps and stories carry a drift-invalidated human-oversight marker" architecture rule defines it — its journey, its identity, what it traces to, and any manifest-bound historical projection. `oversight_map_substance_keys()` is the authoritative field list; this page deliberately does not restate it. Ordinary release rows and cards, story-body edits and template restyling sit outside the fingerprint basis and change nothing. Historical projections are the narrow exception and may be added only through `/wr-itil:migrate-story-map` under exact confirmed adopter authority. This is the ": Ratify the story map and its stories after any change" delivery story surface.
|
|
131
131
|
|
|
132
132
|
**Born-confirmed discipline (the "iter subprocesses set `human-oversight: confirmed` marker on ADRs / personas / JTBDs without an actual user-confirmation event" problem).** Before any marker write, `export CODEX_THREAD_ID` from the transcript path — the marker shim silently no-ops on an empty SID. Every `confirmed` marker MUST be backed by a same-turn human confirm event; never write `confirmed` without the `request_user_input` below (a hollow marker is the "iter subprocesses set `human-oversight: confirmed` marker on ADRs / personas / JTBDs without an actual user-confirmation event" problem bug).
|
|
133
133
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: migrate-story-map
|
|
3
|
+
description: "Replace one eligible legacy story map with a manifest-bound canonical map from a complete adopter-ratified mapping."
|
|
4
|
+
allowed-tools: Read, Write, Edit, Bash, Grep, Glob
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- Generated from the runtime-neutral skill source. Do not edit. -->
|
|
8
|
+
|
|
9
|
+
> Codex runtime note: use `request_user_input` only where this contract
|
|
10
|
+
> explicitly requires a human decision. Resolve `<itil-plugin-root>` from
|
|
11
|
+
> this installed `SKILL.md`: it is two directories above the skill
|
|
12
|
+
> directory. Run bundled commands from `<itil-plugin-root>/bin/`; do not
|
|
13
|
+
> search the adopter repository or rely on those commands being on `PATH`.
|
|
14
|
+
> Spawn `wr-itil:hang-off-check` with the native Codex subagent tool, wait
|
|
15
|
+
> for it, and close that same agent.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Migrate Story Map
|
|
19
|
+
|
|
20
|
+
Use this only for a retained legacy map whose exact historical projection is authorised by a confirmed adopter decision under the manifest-bound historical-row architecture rule.
|
|
21
|
+
|
|
22
|
+
## Steps
|
|
23
|
+
|
|
24
|
+
1. Read the complete legacy map and the supplied structured mapping. Confirm every historical card and placement comes from the retained source; do not infer or add content.
|
|
25
|
+
2. Confirm the cited decision has `human-oversight: confirmed` and maps the selected story-map identifier to the mapping's historical SHA-256 fingerprint under `legacy-projections`.
|
|
26
|
+
3. Run `<itil-plugin-root>/bin/wr-itil-migrate-story-map <legacy-map.html> <mapping.json>` from the adopter repository root. The command fails before writing on incomplete or mismatched input, and restores the source and manifest if rendering fails. It exits successfully without changes when the map is absent or already canonical.
|
|
27
|
+
4. Run the repository's story-map query, renderer, accessibility, and governance checks.
|
|
28
|
+
5. Run the repository's required risk review, then commit the replaced map and manifest as one auditable migration.
|
|
29
|
+
|
|
30
|
+
Do not use this skill to create new history, approve an RFC, create stories, or interpret arbitrary legacy HTML automatically.
|
|
31
|
+
|
|
32
|
+
$ARGUMENTS
|
package/templates/story-map.css
CHANGED
|
@@ -81,6 +81,7 @@ button:focus-visible,
|
|
|
81
81
|
thing distinguishing the three states. */
|
|
82
82
|
.b-live { color: var(--live-fg); background: var(--live-bg); border-color: var(--live-line); }
|
|
83
83
|
.b-next { color: var(--next-fg); background: var(--next-bg); border-color: var(--next-line); }
|
|
84
|
+
.b-history { color: var(--fg); background: var(--card); border-color: var(--line); }
|
|
84
85
|
/* A row with no RFC identity, or whose stories close no problem. Deliberately
|
|
85
86
|
red rather than amber: this is not "later", it is wrong, and it needs an edit
|
|
86
87
|
before the map is ratified. There is no amber resting state — the `.b-later`
|