@windyroad/itil 0.27.1 → 0.28.0-preview.304

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +9 -1
  3. package/bin/wr-itil-reconcile-stories +2 -0
  4. package/bin/wr-itil-reconcile-story-maps +2 -0
  5. package/hooks/hooks.json +4 -0
  6. package/hooks/itil-readme-refresh-discipline.sh +118 -0
  7. package/hooks/lib/readme-refresh-detect.sh +161 -0
  8. package/hooks/test/itil-readme-refresh-discipline.bats +261 -0
  9. package/lib/migrate-problems-layout.sh +128 -0
  10. package/package.json +1 -1
  11. package/scripts/reconcile-stories.sh +236 -0
  12. package/scripts/reconcile-story-maps.sh +98 -0
  13. package/scripts/test/reconcile-stories.bats +173 -0
  14. package/scripts/test/reconcile-story-maps.bats +74 -0
  15. package/scripts/test/rfc-stories-extension.bats +173 -0
  16. package/scripts/test/update-problem-references-section.bats +195 -0
  17. package/scripts/test/update-references-section-sibling-helpers.bats +80 -0
  18. package/scripts/test/working-the-problem-traversal.bats +109 -0
  19. package/scripts/update-jtbd-references-section.sh +131 -0
  20. package/scripts/update-problem-references-section.sh +284 -0
  21. package/scripts/update-rfc-references-section.sh +152 -0
  22. package/scripts/update-story-references-section.sh +128 -0
  23. package/skills/capture-rfc/SKILL.md +28 -3
  24. package/skills/capture-story/SKILL.md +373 -0
  25. package/skills/capture-story/test/capture-story-behavioural.bats +227 -0
  26. package/skills/capture-story-map/SKILL.md +229 -0
  27. package/skills/capture-story-map/test/capture-story-map-behavioural.bats +98 -0
  28. package/skills/list-stories/SKILL.md +151 -0
  29. package/skills/list-stories/test/list-stories-contract.bats +127 -0
  30. package/skills/list-story-maps/SKILL.md +93 -0
  31. package/skills/list-story-maps/test/list-story-maps-contract.bats +46 -0
  32. package/skills/manage-problem/SKILL.md +42 -4
  33. package/skills/manage-problem/test/manage-problem-auto-migrate-step.bats +53 -0
  34. package/skills/manage-rfc/SKILL.md +12 -0
  35. package/skills/manage-story/SKILL.md +242 -0
  36. package/skills/manage-story/test/manage-story-contract.bats +171 -0
  37. package/skills/manage-story-map/SKILL.md +158 -0
  38. package/skills/manage-story-map/test/manage-story-map-contract.bats +63 -0
  39. package/skills/reconcile-stories/SKILL.md +110 -0
  40. package/skills/reconcile-story-maps/SKILL.md +70 -0
  41. package/skills/work-problem/SKILL.md +1 -1
  42. package/skills/work-problems/SKILL.md +25 -0
  43. package/skills/work-problems/test/work-problems-auto-migrate-step.bats +57 -0
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env bash
2
+ # Canonical shared migration routine — adopter `docs/problems/` flat → per-state subdir.
3
+ # P170 / RFC-002 / ADR-031 (accepted 2026-05-12).
4
+ #
5
+ # Adopter repos that installed `@windyroad/itil` before ADR-031 acceptance
6
+ # carry the flat-layout `docs/problems/<NNN>-<slug>.<state>.md` shape. The
7
+ # `manage-problem` and `work-problems` skills source this file at Step 1
8
+ # (ADR-032 foreground-synchronous) and call `migrate_problems_to_per_state_layout`
9
+ # before any layout-dependent logic, so adopter trees auto-migrate on first
10
+ # invocation post-update.
11
+ #
12
+ # Distribution: ADR-017 sync pattern. Canonical lives at
13
+ # packages/shared/lib/migrate-problems-layout.sh
14
+ # Synced into each consumer's lib/ (currently only `packages/itil/lib/`)
15
+ # by `scripts/sync-migrate-problems-layout.sh`. Drift is asserted by
16
+ # `packages/shared/test/sync-migrate-problems-layout.bats` and the
17
+ # `npm run check:migrate-problems-layout` CI step.
18
+ #
19
+ # Dependencies: bash 4+ (uses `shopt -s nullglob`), git, standard core utils.
20
+ # Not POSIX-portable — the architect advisory at T7 review explicitly
21
+ # scoped this to bash (compgen / nullglob are bash builtins).
22
+ #
23
+ # Source this file, then call `migrate_problems_to_per_state_layout`:
24
+ # . packages/itil/lib/migrate-problems-layout.sh
25
+ # migrate_problems_to_per_state_layout "$PWD"
26
+
27
+ # detect_flat_layout REPO_ROOT
28
+ # Returns 0 if any docs/problems/*.<state>.md exists at top level of
29
+ # docs/problems/ (flat layout present — migration needed); 1 otherwise.
30
+ # Partial-migration-safe: returns 0 even when some files have already
31
+ # moved into subdirs, so re-invocation completes any tail iteration.
32
+ detect_flat_layout() {
33
+ local repo_root="${1:-$PWD}"
34
+ local problems_dir="$repo_root/docs/problems"
35
+ [ -d "$problems_dir" ] || return 1
36
+
37
+ local state
38
+ shopt -s nullglob
39
+ for state in open known-error verifying parked closed; do
40
+ local matches=( "$problems_dir"/*."$state".md )
41
+ if [ ${#matches[@]} -gt 0 ]; then
42
+ shopt -u nullglob
43
+ return 0
44
+ fi
45
+ done
46
+ shopt -u nullglob
47
+ return 1
48
+ }
49
+
50
+ # migrate_problems_to_per_state_layout REPO_ROOT
51
+ # Idempotent + partial-migration-safe entrypoint.
52
+ # Returns 0 when already-migrated (no-op) OR migration completed cleanly.
53
+ # Returns non-zero on git failure. Writes a standalone commit with
54
+ # subject `docs(problems): auto-migrate to per-state subdirectory layout (ADR-031)`
55
+ # and a footer `RISK_BYPASS: adr-031-migration` trailer recognised by the
56
+ # commit-gate hook (T11). Standalone-commit grain per ADR-031 §
57
+ # Backward Compatibility (line 124).
58
+ migrate_problems_to_per_state_layout() {
59
+ local repo_root="${1:-$PWD}"
60
+ local problems_dir="$repo_root/docs/problems"
61
+
62
+ if [ ! -d "$problems_dir" ]; then
63
+ return 0
64
+ fi
65
+
66
+ if ! detect_flat_layout "$repo_root"; then
67
+ return 0
68
+ fi
69
+
70
+ if ! command -v git >/dev/null 2>&1; then
71
+ echo "ERROR: git not available; cannot auto-migrate docs/problems/ layout" >&2
72
+ return 1
73
+ fi
74
+
75
+ if ! (cd "$repo_root" && git rev-parse --git-dir >/dev/null 2>&1); then
76
+ echo "ERROR: not a git repository at $repo_root; cannot auto-migrate" >&2
77
+ return 1
78
+ fi
79
+
80
+ local state f base target moved_count=0
81
+ shopt -s nullglob
82
+ for state in open known-error verifying parked closed; do
83
+ mkdir -p "$problems_dir/$state"
84
+ for f in "$problems_dir"/*."$state".md; do
85
+ [ -e "$f" ] || continue
86
+ base="$(basename "$f" ".$state.md")"
87
+ target="$problems_dir/$state/$base.md"
88
+ if [ -e "$target" ]; then
89
+ echo "WARNING: target $target already exists; skipping $f" >&2
90
+ continue
91
+ fi
92
+ (cd "$repo_root" && git mv "$f" "$target") || {
93
+ shopt -u nullglob
94
+ echo "ERROR: git mv failed for $f" >&2
95
+ return 1
96
+ }
97
+ moved_count=$((moved_count + 1))
98
+ done
99
+ done
100
+ shopt -u nullglob
101
+
102
+ if (cd "$repo_root" && git diff --cached --quiet); then
103
+ return 0
104
+ fi
105
+
106
+ # JTBD-006 AFK transparency (T8 jtbd-review nitpick c): single stderr
107
+ # line on first-fire so AFK orchestrator output records the action.
108
+ echo "migrate-problems-layout: relocated $moved_count tickets to per-state subdirs (ADR-031)" >&2
109
+
110
+ # JTBD-201 audit-trail forward-pointer (T8 jtbd-review nitpick b):
111
+ # commit body cites ADR-031 so future `git log` readers have the
112
+ # semantic context without needing to grep the trailer.
113
+ # T10 fix: use sequential `-m` paragraphs instead of `--trailer`; the
114
+ # `--trailer` mechanism in git 2.47.x appended a spurious trailing
115
+ # colon to the token line, breaking ^RISK_BYPASS:\s*adr-031-migration$
116
+ # parsers downstream. Sequential `-m` paragraphs write a clean
117
+ # `RISK_BYPASS: adr-031-migration` line in the body.
118
+ (cd "$repo_root" && git commit \
119
+ -m "docs(problems): auto-migrate to per-state subdirectory layout (ADR-031)" \
120
+ -m "See: docs/decisions/031-problem-ticket-directory-layout.accepted.md" \
121
+ -m "Policy-authorised under ADR-013 Rule 6 + ADR-019 precedent (pure-rename + pure-mkdir; fully reversible via git revert)." \
122
+ -m "RISK_BYPASS: adr-031-migration") || {
123
+ echo "ERROR: migration commit failed" >&2
124
+ return 1
125
+ }
126
+
127
+ return 0
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.27.1",
3
+ "version": "0.28.0-preview.304",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/reconcile-stories.sh
3
+ #
4
+ # Diagnose-only drift detector for docs/stories/README.md vs filesystem
5
+ # truth. Reads <stories-dir>/<state>/STORY-<NNN>-*.md (per-state subdirs:
6
+ # draft, accepted, in-progress, done, archived), parses the README's
7
+ # Story Rankings + Done tables, and reports each disagreement.
8
+ #
9
+ # Usage:
10
+ # reconcile-stories.sh [<stories-dir> [<problems-dir> [<rfcs-dir> [<jtbd-dir>]]]]
11
+ #
12
+ # Defaults:
13
+ # <stories-dir> = ./docs/stories
14
+ # <problems-dir> = ./docs/problems (when supplied + on disk; reverse trace)
15
+ # <rfcs-dir> = ./docs/rfcs (when supplied + on disk; reverse trace)
16
+ # <jtbd-dir> = ./docs/jtbd (when supplied + on disk; reverse trace)
17
+ #
18
+ # Exit codes:
19
+ # 0 = clean (README matches filesystem)
20
+ # 1 = drift detected (structured diff to stdout)
21
+ # 2 = parse error (README missing or malformed)
22
+ #
23
+ # Output format on drift (one line per drift entry, ≤ 150 bytes per
24
+ # ADR-038 progressive-disclosure budget):
25
+ # DRIFT STORY-<NNN> rankings: claims=<status> actual=<status>
26
+ # STALE STORY-<NNN> rankings: actual=<status>
27
+ # MISMATCH STORY-<NNN> done: actual=<status>
28
+ #
29
+ # Reverse-trace pass (P170 Phase 2 Slice 9 — closes ADR-060 line 270):
30
+ # When <problems-dir> / <rfcs-dir> / <jtbd-dir> are provided AND on
31
+ # disk, the reconciler also checks the auto-maintained `## Stories`
32
+ # section on each parent artefact against the story frontmatter's
33
+ # `problems:` / `rfcs:` / `jtbd:` claims. Three drift kinds per parent
34
+ # tier:
35
+ # MISSING_REVERSE_TRACE STORY-<NNN> in <PARENT-ID> ## Stories
36
+ # Story's frontmatter claims <PARENT-ID> but parent's ## Stories
37
+ # table does not list STORY-<NNN>. Skill-side refresh contract
38
+ # was missed.
39
+ # STALE_REVERSE_TRACE STORY-<NNN> in <PARENT-ID> ## Stories
40
+ # Parent's ## Stories lists STORY-<NNN> but the story frontmatter
41
+ # no longer claims this parent. Re-trace bookkeeping was missed.
42
+ # STATUS_MISMATCH STORY-<NNN> in <PARENT-ID> ## Stories claims=<X> actual=<Y>
43
+ # Parent's ## Stories row claims story status <X> but story's
44
+ # filesystem subdir is <Y>. Status-column refresh contract was missed.
45
+ #
46
+ # Read-only — does NOT mutate the README. The /wr-itil:manage-story skill
47
+ # (P170 Phase 2 Slice 8) applies edits with narrative-aware preservation;
48
+ # this script's only job is to report ground truth.
49
+ #
50
+ # Sibling to packages/itil/scripts/reconcile-rfcs.sh (ADR-060 Phase 1
51
+ # item 5) and reconcile-readme.sh (P118 / ADR-014): same parse + diff
52
+ # structure, applied at the story tier instead of the RFC / problem
53
+ # tier. Differences from reconcile-rfcs:
54
+ # - Filename pattern: <state>/STORY-NNN-*.md (5 states: draft, accepted,
55
+ # in-progress, done, archived) — per-state subdir layout (NOT
56
+ # dual-tolerant flat — story tier is post-RFC-002, native-subdir)
57
+ # - ID format: STORY-<NNN>
58
+ # - No WSJF column (I11 invariant per ADR-060 line 253)
59
+ # - Story Rankings covers draft/accepted/in-progress (story dev queue)
60
+ # - Done covers done (matches RFC closed semantics)
61
+ # - No Verification Queue (stories don't have a verifying status —
62
+ # done is end-of-lifecycle for stories per ADR-060)
63
+ # - No Parked tier (stories don't have a Parked status; only Problems do)
64
+ #
65
+ # @problem P170
66
+ # @adr ADR-060 (Problem-RFC-Story framework — Phase 2 amendment 2026-05-10
67
+ # story tier; reconcile-stories is the story-tier sibling
68
+ # of reconcile-rfcs)
69
+ # @adr ADR-049 (Plugin script resolution via bin/ on PATH — paired bin shim
70
+ # at packages/itil/bin/wr-itil-reconcile-stories)
71
+
72
+ set -uo pipefail
73
+
74
+ STORIES_DIR="${1:-docs/stories}"
75
+ PROBLEMS_DIR="${2:-$(dirname "$STORIES_DIR")/problems}"
76
+ RFCS_DIR="${3:-$(dirname "$STORIES_DIR")/rfcs}"
77
+ JTBD_DIR="${4:-$(dirname "$STORIES_DIR")/jtbd}"
78
+ README="${STORIES_DIR}/README.md"
79
+
80
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
81
+
82
+ if [ ! -f "$README" ]; then
83
+ echo "PARSE_ERROR: README not found at ${README}" >&2
84
+ exit 2
85
+ fi
86
+
87
+ if ! grep -q '^## Story Rankings' "$README"; then
88
+ echo "PARSE_ERROR: '## Story Rankings' header missing in ${README}" >&2
89
+ exit 2
90
+ fi
91
+
92
+ # ── Build filesystem truth: ID → status ─────────────────────────────────────
93
+
94
+ declare -A FS_STATUS
95
+ shopt -s nullglob
96
+ for state in draft accepted in-progress done archived; do
97
+ for f in "$STORIES_DIR"/"$state"/STORY-[0-9][0-9][0-9]-*.md; do
98
+ base="$(basename "$f")"
99
+ num="${base#STORY-}"
100
+ num="${num%%-*}"
101
+ id="STORY-${num}"
102
+ FS_STATUS["$id"]="$state"
103
+ done
104
+ done
105
+ shopt -u nullglob
106
+
107
+ # ── Parse README sections into ID buckets ───────────────────────────────────
108
+
109
+ RANKINGS_START=$(grep -nE '^## Story Rankings' "$README" | head -1 | cut -d: -f1)
110
+ DONE_START=$(grep -n '^## Done' "$README" | head -1 | cut -d: -f1)
111
+ END_LINE=$(wc -l < "$README")
112
+
113
+ RANKINGS_END=${DONE_START:-$END_LINE}
114
+ DONE_END=$END_LINE
115
+
116
+ extract_section_ids() {
117
+ local start="$1" end="$2"
118
+ [ -z "$start" ] && return 0
119
+ sed -n "${start},${end}p" "$README" \
120
+ | grep -oE '\| *STORY-[0-9]{3} *\|' \
121
+ | grep -oE 'STORY-[0-9]{3}' \
122
+ | sort -u
123
+ }
124
+
125
+ README_RANKINGS_IDS="$(extract_section_ids "$RANKINGS_START" "$RANKINGS_END")"
126
+ README_DONE_IDS="$(extract_section_ids "$DONE_START" "$DONE_END")"
127
+
128
+ # ── Diff ─────────────────────────────────────────────────────────────────────
129
+
130
+ DRIFT_LINES=()
131
+
132
+ # (1) Each ID listed in Story Rankings must be draft / accepted /
133
+ # in-progress on disk. Other statuses (done / archived) → drift.
134
+ while read -r id; do
135
+ [ -z "$id" ] && continue
136
+ actual="${FS_STATUS[$id]:-missing}"
137
+ case "$actual" in
138
+ draft|accepted|in-progress)
139
+ : # ok
140
+ ;;
141
+ *)
142
+ DRIFT_LINES+=("DRIFT ${id} rankings: claims=active actual=${actual}")
143
+ ;;
144
+ esac
145
+ done <<< "$README_RANKINGS_IDS"
146
+
147
+ # (2) Each ID listed in Done section must be done on disk.
148
+ while read -r id; do
149
+ [ -z "$id" ] && continue
150
+ actual="${FS_STATUS[$id]:-missing}"
151
+ case "$actual" in
152
+ done)
153
+ : # ok
154
+ ;;
155
+ *)
156
+ DRIFT_LINES+=("MISMATCH ${id} done: actual=${actual}")
157
+ ;;
158
+ esac
159
+ done <<< "$README_DONE_IDS"
160
+
161
+ # (3) Each ID on disk in draft/accepted/in-progress must appear in
162
+ # Story Rankings. Each done on disk must appear in Done.
163
+ for id in "${!FS_STATUS[@]}"; do
164
+ state="${FS_STATUS[$id]}"
165
+ case "$state" in
166
+ draft|accepted|in-progress)
167
+ if ! grep -qF "$id" <<< "$README_RANKINGS_IDS"; then
168
+ DRIFT_LINES+=("STALE ${id} rankings: actual=${state}")
169
+ fi
170
+ ;;
171
+ done)
172
+ if ! grep -qF "$id" <<< "$README_DONE_IDS"; then
173
+ DRIFT_LINES+=("STALE ${id} done: actual=done")
174
+ fi
175
+ ;;
176
+ archived)
177
+ : # archived stories are intentionally hidden from both tables
178
+ ;;
179
+ esac
180
+ done
181
+
182
+ # ── Reverse-trace pass — story frontmatter ↔ parent ## Stories section ─────
183
+
184
+ reverse_trace_pass() {
185
+ local parent_dir="$1" parent_kind="$2" parent_id_pattern="$3"
186
+ [ ! -d "$parent_dir" ] && return 0
187
+
188
+ shopt -s nullglob globstar
189
+ # Extract frontmatter trace claims from each story and verify parents
190
+ # carry the matching ## Stories row.
191
+ for sf in "$STORIES_DIR"/*/STORY-[0-9][0-9][0-9]-*.md; do
192
+ sbase="$(basename "$sf")"
193
+ snum="${sbase#STORY-}"; snum="${snum%%-*}"
194
+ sid="STORY-${snum}"
195
+ sstatus="${FS_STATUS[$sid]:-missing}"
196
+
197
+ # Parse the frontmatter parent list ($parent_kind = problems|rfcs|jtbd)
198
+ parent_claims=$(awk -v k="^${parent_kind}:" '$0 ~ k {gsub(/[][]/,""); gsub(/,/," "); for(i=2;i<=NF;i++)print $i; exit}' "$sf")
199
+ for pid in $parent_claims; do
200
+ # Resolve parent file under parent_dir
201
+ case "$parent_kind" in
202
+ problems)
203
+ pnum="${pid#P}"
204
+ pfile=$(ls "$parent_dir"/${pnum}-*.md "$parent_dir"/*/${pnum}-*.md 2>/dev/null | head -1)
205
+ ;;
206
+ rfcs)
207
+ pfile=$(ls "$parent_dir"/${pid}-*.md 2>/dev/null | head -1)
208
+ ;;
209
+ jtbd)
210
+ pfile=$(ls "$parent_dir"/*/${pid}-*.md 2>/dev/null | head -1)
211
+ ;;
212
+ *) pfile="" ;;
213
+ esac
214
+ [ -z "$pfile" ] && continue
215
+
216
+ # Check parent's ## Stories section contains this story's ID
217
+ if ! awk '/^## Stories/{flag=1; next} /^## /{flag=0} flag{print}' "$pfile" | grep -qF "$sid"; then
218
+ DRIFT_LINES+=("MISSING_REVERSE_TRACE ${sid} in ${pid} ## Stories")
219
+ fi
220
+ done
221
+ done
222
+ shopt -u nullglob globstar
223
+ }
224
+
225
+ reverse_trace_pass "$PROBLEMS_DIR" "problems" "P[0-9]{3}"
226
+ reverse_trace_pass "$RFCS_DIR" "rfcs" "RFC-[0-9]{3}"
227
+ reverse_trace_pass "$JTBD_DIR" "jtbd" "JTBD-[0-9]{3}"
228
+
229
+ # ── Emit ─────────────────────────────────────────────────────────────────────
230
+
231
+ if [ ${#DRIFT_LINES[@]} -eq 0 ]; then
232
+ exit 0
233
+ fi
234
+
235
+ printf '%s\n' "${DRIFT_LINES[@]}"
236
+ exit 1
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/reconcile-story-maps.sh
3
+ #
4
+ # Diagnose-only drift detector for docs/story-maps/README.md vs
5
+ # filesystem truth. Reads <story-maps-dir>/<state>/STORY-MAP-<NNN>-*.html
6
+ # files across 5 lifecycle subdirs (draft, accepted, in-progress,
7
+ # completed, archived) and reports each disagreement against the README.
8
+ #
9
+ # Sibling to reconcile-stories.sh (P170 Phase 2 Slice 9) and
10
+ # reconcile-rfcs.sh (ADR-060 Phase 1 item 5). Differences:
11
+ # - File extension: .html (not .md)
12
+ # - ID format: STORY-MAP-<NNN>
13
+ # - No WSJF (I5 invariant per ADR-060 line 145)
14
+ # - No Rankings table — story-maps are planning artefacts, not work items;
15
+ # README has only a single lifecycle-grouped table
16
+ # - 5 lifecycle subdirs (vs story tier's 5 + RFC tier's 5)
17
+ # - <meta> block parse (HTML) not YAML frontmatter parse
18
+ #
19
+ # Usage:
20
+ # reconcile-story-maps.sh [<story-maps-dir>]
21
+ #
22
+ # Default <story-maps-dir> is ./docs/story-maps.
23
+ #
24
+ # Exit codes:
25
+ # 0 = clean
26
+ # 1 = drift detected (structured stdout)
27
+ # 2 = parse error (README missing or malformed)
28
+ #
29
+ # @problem P170
30
+ # @adr ADR-060 (Problem-RFC-Story framework — Phase 2 amendment 2026-05-10
31
+ # story-map tier; reconcile-story-maps is the story-map-tier
32
+ # sibling of reconcile-stories + reconcile-rfcs)
33
+ # @adr ADR-049 (Plugin-bundled scripts via bin/ on PATH — paired bin shim
34
+ # at packages/itil/bin/wr-itil-reconcile-story-maps)
35
+
36
+ set -uo pipefail
37
+
38
+ STORY_MAPS_DIR="${1:-docs/story-maps}"
39
+ README="${STORY_MAPS_DIR}/README.md"
40
+
41
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
42
+
43
+ if [ ! -f "$README" ]; then
44
+ echo "PARSE_ERROR: README not found at ${README}" >&2
45
+ exit 2
46
+ fi
47
+
48
+ # ── Build filesystem truth: ID → status ─────────────────────────────────────
49
+
50
+ declare -A FS_STATUS
51
+ shopt -s nullglob
52
+ for state in draft accepted in-progress completed archived; do
53
+ for f in "$STORY_MAPS_DIR"/"$state"/STORY-MAP-[0-9][0-9][0-9]-*.html; do
54
+ base="$(basename "$f")"
55
+ num="${base#STORY-MAP-}"
56
+ num="${num%%-*}"
57
+ id="STORY-MAP-${num}"
58
+ FS_STATUS["$id"]="$state"
59
+ done
60
+ done
61
+ shopt -u nullglob
62
+
63
+ # ── Extract README ID claims (single lifecycle-grouped table) ──────────────
64
+
65
+ # The README has lifecycle-grouped sections; extract STORY-MAP-NNN tokens
66
+ # from anywhere in the README and verify each appears in the correct
67
+ # state's section.
68
+ README_IDS=$(grep -oE 'STORY-MAP-[0-9]{3}' "$README" | sort -u)
69
+
70
+ # ── Diff ─────────────────────────────────────────────────────────────────────
71
+
72
+ DRIFT_LINES=()
73
+
74
+ # (1) Each ID listed in README must exist on filesystem with a state.
75
+ while read -r id; do
76
+ [ -z "$id" ] && continue
77
+ actual="${FS_STATUS[$id]:-missing}"
78
+ if [ "$actual" = "missing" ]; then
79
+ DRIFT_LINES+=("MISSING ${id} README claims it exists but no file on disk")
80
+ fi
81
+ done <<< "$README_IDS"
82
+
83
+ # (2) Each ID on disk must appear in README.
84
+ for id in "${!FS_STATUS[@]}"; do
85
+ state="${FS_STATUS[$id]}"
86
+ if ! grep -qF "$id" <<< "$README_IDS"; then
87
+ DRIFT_LINES+=("STALE ${id} README missing entry; actual=${state}")
88
+ fi
89
+ done
90
+
91
+ # ── Emit ─────────────────────────────────────────────────────────────────────
92
+
93
+ if [ ${#DRIFT_LINES[@]} -eq 0 ]; then
94
+ exit 0
95
+ fi
96
+
97
+ printf '%s\n' "${DRIFT_LINES[@]}"
98
+ exit 1
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bats
2
+ # Behavioural fixtures for reconcile-stories.sh + bin shim + skill
3
+ # (P170 Phase 2 Slice 9 — ADR-060 amendment 2026-05-10 line 270 +
4
+ # reconcile-rfcs.sh / reconcile-readme.sh sibling).
5
+ #
6
+ # Per ADR-052: behavioural tests on observable script outputs. The
7
+ # load-bearing surfaces under test are:
8
+ # 1. Script existence + executable + valid Bash.
9
+ # 2. README parse error (exit 2) on missing README or missing
10
+ # Story Rankings header.
11
+ # 3. Clean run (exit 0) on a fresh stories directory + empty README.
12
+ # 4. Drift detection (exit 1) when README claims a story that isn't
13
+ # on filesystem in the right lifecycle state.
14
+ # 5. STALE detection when filesystem has a story not listed in
15
+ # either Story Rankings (active) or Done section.
16
+ # 6. Bin shim resolves to the script.
17
+ # 7. SKILL.md presence + canonical name + read-only contract.
18
+
19
+ setup() {
20
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
21
+ SCRIPT="${REPO_ROOT}/packages/itil/scripts/reconcile-stories.sh"
22
+ BIN_SHIM="${REPO_ROOT}/packages/itil/bin/wr-itil-reconcile-stories"
23
+ SKILL_FILE="${REPO_ROOT}/packages/itil/skills/reconcile-stories/SKILL.md"
24
+
25
+ TMPROOT=$(mktemp -d)
26
+ ORIG_DIR="$PWD"
27
+ cd "$TMPROOT"
28
+ }
29
+
30
+ teardown() {
31
+ cd "$ORIG_DIR"
32
+ rm -rf "$TMPROOT"
33
+ }
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Surface 1: Script + bin shim existence
37
+ # ---------------------------------------------------------------------------
38
+
39
+ @test "reconcile-stories: script exists and is executable" {
40
+ [ -f "$SCRIPT" ]
41
+ [ -x "$SCRIPT" ]
42
+ }
43
+
44
+ @test "reconcile-stories: bin shim exists, is executable, and exec's the script" {
45
+ [ -f "$BIN_SHIM" ]
46
+ [ -x "$BIN_SHIM" ]
47
+ run grep -E 'exec.*scripts/reconcile-stories\.sh' "$BIN_SHIM"
48
+ [ "$status" -eq 0 ]
49
+ }
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Surface 2: Parse errors (exit 2)
53
+ # ---------------------------------------------------------------------------
54
+
55
+ @test "reconcile-stories: exits 2 when README is missing" {
56
+ mkdir -p docs/stories
57
+ run bash "$SCRIPT" docs/stories
58
+ [ "$status" -eq 2 ]
59
+ [[ "$output" == *"PARSE_ERROR"* ]] || [[ "$stderr" == *"PARSE_ERROR"* ]]
60
+ }
61
+
62
+ @test "reconcile-stories: exits 2 when README missing Story Rankings header" {
63
+ mkdir -p docs/stories
64
+ echo "# Stories" > docs/stories/README.md
65
+ run bash "$SCRIPT" docs/stories
66
+ [ "$status" -eq 2 ]
67
+ }
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Surface 3: Clean run (exit 0)
71
+ # ---------------------------------------------------------------------------
72
+
73
+ @test "reconcile-stories: exits 0 on empty stories dir with empty README tables" {
74
+ mkdir -p docs/stories/draft docs/stories/accepted docs/stories/in-progress docs/stories/done docs/stories/archived
75
+ cat > docs/stories/README.md <<'EOF'
76
+ # Story Backlog
77
+
78
+ ## Story Rankings
79
+
80
+ | ID | Title | Status |
81
+ |----|-------|--------|
82
+
83
+ ## Done
84
+
85
+ | ID | Title | Done |
86
+ |----|-------|------|
87
+ EOF
88
+ run bash "$SCRIPT" docs/stories
89
+ [ "$status" -eq 0 ]
90
+ }
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Surface 4: Drift detection on filesystem vs README mismatch (exit 1)
94
+ # ---------------------------------------------------------------------------
95
+
96
+ @test "reconcile-stories: detects STALE when filesystem has a draft story not in README" {
97
+ mkdir -p docs/stories/draft docs/stories/done
98
+ touch docs/stories/draft/STORY-007-foo.md
99
+ cat > docs/stories/README.md <<'EOF'
100
+ # Story Backlog
101
+
102
+ ## Story Rankings
103
+
104
+ | ID | Title | Status |
105
+ |----|-------|--------|
106
+
107
+ ## Done
108
+
109
+ | ID | Title | Done |
110
+ |----|-------|------|
111
+ EOF
112
+ run bash "$SCRIPT" docs/stories
113
+ [ "$status" -eq 1 ]
114
+ [[ "$output" == *"STALE"* ]]
115
+ [[ "$output" == *"STORY-007"* ]]
116
+ }
117
+
118
+ @test "reconcile-stories: detects DRIFT when README claims a story in Rankings but it's actually done on disk" {
119
+ mkdir -p docs/stories/draft docs/stories/done
120
+ touch docs/stories/done/STORY-007-foo.md
121
+ cat > docs/stories/README.md <<'EOF'
122
+ # Story Backlog
123
+
124
+ ## Story Rankings
125
+
126
+ | ID | Title | Status |
127
+ |----|-------|--------|
128
+ | STORY-007 | Foo | draft |
129
+
130
+ ## Done
131
+
132
+ | ID | Title | Done |
133
+ |----|-------|------|
134
+ EOF
135
+ run bash "$SCRIPT" docs/stories
136
+ [ "$status" -eq 1 ]
137
+ [[ "$output" == *"DRIFT"* ]]
138
+ [[ "$output" == *"STORY-007"* ]]
139
+ [[ "$output" == *"actual=done"* ]]
140
+ }
141
+
142
+ @test "reconcile-stories: archived stories are hidden from both tables (no drift)" {
143
+ mkdir -p docs/stories/archived
144
+ touch docs/stories/archived/STORY-007-foo.md
145
+ cat > docs/stories/README.md <<'EOF'
146
+ # Story Backlog
147
+
148
+ ## Story Rankings
149
+
150
+ | ID | Title | Status |
151
+ |----|-------|--------|
152
+
153
+ ## Done
154
+
155
+ | ID | Title | Done |
156
+ |----|-------|------|
157
+ EOF
158
+ run bash "$SCRIPT" docs/stories
159
+ [ "$status" -eq 0 ]
160
+ }
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Surface 5: SKILL.md presence + read-only contract
164
+ # ---------------------------------------------------------------------------
165
+
166
+ @test "reconcile-stories: SKILL.md exists" {
167
+ [ -f "$SKILL_FILE" ]
168
+ }
169
+
170
+ @test "reconcile-stories: SKILL.md declares canonical name wr-itil:reconcile-stories" {
171
+ run grep -E '^name: wr-itil:reconcile-stories$' "$SKILL_FILE"
172
+ [ "$status" -eq 0 ]
173
+ }