@windyroad/itil 0.27.1 → 0.28.0-preview.303
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -1
- package/bin/wr-itil-reconcile-stories +2 -0
- package/bin/wr-itil-reconcile-story-maps +2 -0
- package/hooks/hooks.json +4 -0
- package/hooks/itil-readme-refresh-discipline.sh +118 -0
- package/hooks/lib/readme-refresh-detect.sh +161 -0
- package/hooks/test/itil-readme-refresh-discipline.bats +261 -0
- package/lib/migrate-problems-layout.sh +128 -0
- package/package.json +1 -1
- package/scripts/reconcile-stories.sh +236 -0
- package/scripts/reconcile-story-maps.sh +98 -0
- package/scripts/test/reconcile-stories.bats +173 -0
- package/scripts/test/reconcile-story-maps.bats +74 -0
- package/scripts/test/rfc-stories-extension.bats +173 -0
- package/scripts/test/update-problem-references-section.bats +195 -0
- package/scripts/test/update-references-section-sibling-helpers.bats +80 -0
- package/scripts/test/working-the-problem-traversal.bats +109 -0
- package/scripts/update-jtbd-references-section.sh +131 -0
- package/scripts/update-problem-references-section.sh +284 -0
- package/scripts/update-rfc-references-section.sh +152 -0
- package/scripts/update-story-references-section.sh +128 -0
- package/skills/capture-rfc/SKILL.md +28 -3
- package/skills/capture-story/SKILL.md +373 -0
- package/skills/capture-story/test/capture-story-behavioural.bats +227 -0
- package/skills/capture-story-map/SKILL.md +229 -0
- package/skills/capture-story-map/test/capture-story-map-behavioural.bats +98 -0
- package/skills/list-stories/SKILL.md +151 -0
- package/skills/list-stories/test/list-stories-contract.bats +127 -0
- package/skills/list-story-maps/SKILL.md +93 -0
- package/skills/list-story-maps/test/list-story-maps-contract.bats +46 -0
- package/skills/manage-problem/SKILL.md +42 -4
- package/skills/manage-problem/test/manage-problem-auto-migrate-step.bats +53 -0
- package/skills/manage-rfc/SKILL.md +12 -0
- package/skills/manage-story/SKILL.md +242 -0
- package/skills/manage-story/test/manage-story-contract.bats +171 -0
- package/skills/manage-story-map/SKILL.md +158 -0
- package/skills/manage-story-map/test/manage-story-map-contract.bats +63 -0
- package/skills/reconcile-stories/SKILL.md +110 -0
- package/skills/reconcile-story-maps/SKILL.md +70 -0
- package/skills/work-problem/SKILL.md +1 -1
- package/skills/work-problems/SKILL.md +25 -0
- 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
|
@@ -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
|
+
}
|