@windyroad/itil 2.2.1 → 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.
@@ -497,5 +497,5 @@
497
497
  }
498
498
  },
499
499
  "name": "wr-itil",
500
- "version": "2.2.1"
500
+ "version": "2.3.0"
501
501
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wr-itil",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "ITIL problem-management workflows for AI coding agents",
5
5
  "author": {
6
6
  "name": "Windy Road Technology",
@@ -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" "$@"
@@ -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: the island keys a story map's ratification hangs on.
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 keys only. Unlike oversight_excluded_keys, which strips keys from
70
- # every artefact in three encodings, this set is meaningless for anything with no
71
- # island stories and pre-story-maps-render-from-json-through-a-canonical-template-architecture-rule maps fall through to whole-file bytes. The two
72
- # are not complements; `excluded substance` is not the key space.
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
- print(json.dumps({k: d[k] for k in SUBSTANCE if k in d}, sort_keys=True, indent=2))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "2.2.1",
3
+ "version": "2.3.0-preview.1211",
4
4
  "description": "ITIL-aligned IT service management for Claude Code and Codex",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -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`);
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ exec node "$(cd "$(dirname "$0")" && pwd)/migrate-story-map.mjs" "$@"
@@ -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 filled = backbone.map((act) =>
728
- tasks.filter((t) => t.activity === act.id && t.release === rel.id));
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 stories in this release band.</span></td>`;
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 stories for ${esc(backbone[i]?.title || 'this activity')} in ${esc(rel.name || rel.id || 'this release')}.</span></td>`;
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>' + cardHtml(t, statuses[t.storyId], values[t.storyId], hrefs) + '</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
- return JSON.parse(html.slice(from, end).replace(/\\u003c/g, '<'));
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 only 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 new row: it is a statement about history, not a way to skip allocating an RFC. It appears in no example above because a row that has an `rfc` does not need it, and the example shows the normal case.
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, and what it traces to. `oversight_map_substance_keys()` is the authoritative field list; this page deliberately does not restate it. Release rows, the cards in them, story-body edits and template restyling all sit outside the fingerprint basis and change nothing. This is the ": Ratify the story map and its stories after any change" delivery story surface.
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 only 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 new row: it is a statement about history, not a way to skip allocating an RFC. It appears in no example above because a row that has an `rfc` does not need it, and the example shows the normal case.
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, and what it traces to. `oversight_map_substance_keys()` is the authoritative field list; this page deliberately does not restate it. Release rows, the cards in them, story-body edits and template restyling all sit outside the fingerprint basis and change nothing. This is the ": Ratify the story map and its stories after any change" delivery story surface.
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
@@ -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`