@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,131 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/update-jtbd-references-section.sh
3
+ #
4
+ # Generalised reverse-trace section updater for JTBD files. Lookup
5
+ # table supports ## RFCs, ## Story Maps, ## Stories sections on
6
+ # JTBD-NNN-*.md files. Mirror of update-problem-references-section.sh
7
+ # with jtbd: frontmatter / data-jtbd attribute extraction.
8
+ #
9
+ # Per ADR-060 § Phase 2 encoding amendment 2026-05-12 architect
10
+ # finding 4: no per-section-name branching; lookup-table dispatch.
11
+ #
12
+ # @adr ADR-060 (Phase 2 encoding amendment 2026-05-12)
13
+ # @problem P170 (Phase 2 Slice 2b)
14
+
15
+ set -uo pipefail
16
+
17
+ JTBD_FILE="${1:-}"
18
+ SECTION_NAME="${2:-}"
19
+
20
+ [ -n "$JTBD_FILE" ] || { echo "ERROR: missing jtbd-file argument" >&2; exit 1; }
21
+ [ -n "$SECTION_NAME" ] || { echo "ERROR: missing section-name argument" >&2; exit 1; }
22
+ [ -f "$JTBD_FILE" ] || { echo "ERROR: jtbd file not found: $JTBD_FILE" >&2; exit 1; }
23
+
24
+ declare -A SECTION_GLOB SECTION_MODE SECTION_ID_PATTERN
25
+
26
+ SECTION_GLOB["RFCs"]="docs/rfcs/RFC-*.md"
27
+ SECTION_MODE["RFCs"]="markdown-frontmatter-jtbd"
28
+ SECTION_ID_PATTERN["RFCs"]="RFC-[0-9]+"
29
+
30
+ SECTION_GLOB["Story Maps"]="docs/story-maps/*/STORY-MAP-*.html"
31
+ SECTION_MODE["Story Maps"]="html-data-attribute-jtbd"
32
+ SECTION_ID_PATTERN["Story Maps"]="STORY-MAP-[0-9]+"
33
+
34
+ SECTION_GLOB["Stories"]="docs/stories/*/STORY-*.md"
35
+ SECTION_MODE["Stories"]="markdown-frontmatter-jtbd"
36
+ SECTION_ID_PATTERN["Stories"]="STORY-[0-9]+"
37
+
38
+ glob_pattern="${SECTION_GLOB[$SECTION_NAME]:-}"
39
+ extraction_mode="${SECTION_MODE[$SECTION_NAME]:-}"
40
+ id_pattern="${SECTION_ID_PATTERN[$SECTION_NAME]:-}"
41
+
42
+ [ -n "$glob_pattern" ] || { echo "ERROR: unknown section-name '$SECTION_NAME'. Supported: RFCs, Story Maps, Stories" >&2; exit 1; }
43
+
44
+ jtbd_basename=$(basename "$JTBD_FILE")
45
+ jtbd_id=$(echo "$jtbd_basename" | grep -oE '^JTBD-[0-9]+' | head -1)
46
+ [ -n "$jtbd_id" ] || { echo "ERROR: cannot extract JTBD ID from filename: $jtbd_basename" >&2; exit 1; }
47
+
48
+ declare -a matched_ids=() matched_titles=() matched_statuses=()
49
+
50
+ extract_from_markdown_frontmatter_jtbd() {
51
+ local file="$1"
52
+ awk '/^---$/{f=!f;next} f && /^jtbd:/' "$file" | head -1 | grep -qE "\\b${jtbd_id}\\b"
53
+ }
54
+
55
+ extract_from_html_data_jtbd() {
56
+ local file="$1"
57
+ grep -E '<meta[[:space:]]+name="jtbd"[[:space:]]+content="[^"]+"' "$file" | head -1 | grep -qE "${jtbd_id}\\b"
58
+ }
59
+
60
+ extract_id_from_filename() { basename "$1" | grep -oE "$id_pattern" | head -1; }
61
+ extract_title_md() { awk '/^# / { sub(/^# /, ""); print; exit }' "$1"; }
62
+ extract_title_html() { grep -oE '<title>[^<]+</title>' "$1" | head -1 | sed -E 's|<title>([^<]+)</title>|\1|'; }
63
+ extract_status_md() { awk '/^---$/{f=!f;next} f && /^status:/{ sub(/^status:[[:space:]]*/, ""); gsub(/"/, ""); print; exit }' "$1"; }
64
+ extract_status_html() { grep -oE '<meta[[:space:]]+name="status"[[:space:]]+content="[^"]+"' "$1" | head -1 | sed -E 's|.*content="([^"]+)".*|\1|'; }
65
+
66
+ case "$extraction_mode" in
67
+ markdown-frontmatter-jtbd)
68
+ extract_match=extract_from_markdown_frontmatter_jtbd
69
+ extract_title=extract_title_md
70
+ extract_status=extract_status_md
71
+ ;;
72
+ html-data-attribute-jtbd)
73
+ extract_match=extract_from_html_data_jtbd
74
+ extract_title=extract_title_html
75
+ extract_status=extract_status_html
76
+ ;;
77
+ *)
78
+ echo "ERROR: unknown extraction-mode '$extraction_mode'" >&2
79
+ exit 1
80
+ ;;
81
+ esac
82
+
83
+ shopt -s nullglob
84
+ for artefact in $glob_pattern; do
85
+ [ -e "$artefact" ] || continue
86
+ if "$extract_match" "$artefact"; then
87
+ aid=$(extract_id_from_filename "$artefact")
88
+ [ -n "$aid" ] || continue
89
+ matched_ids+=("$aid")
90
+ matched_titles+=("$("$extract_title" "$artefact" 2>/dev/null || echo "")")
91
+ matched_statuses+=("$("$extract_status" "$artefact" 2>/dev/null || echo "unknown")")
92
+ fi
93
+ done
94
+ shopt -u nullglob
95
+
96
+ new_section=""
97
+ if [ ${#matched_ids[@]} -gt 0 ]; then
98
+ new_section="## ${SECTION_NAME}"$'\n\n| ID | Title | Status |\n|----|-------|--------|\n'
99
+ for i in "${!matched_ids[@]}"; do
100
+ new_section+="| ${matched_ids[$i]} | ${matched_titles[$i]} | ${matched_statuses[$i]} |"$'\n'
101
+ done
102
+ fi
103
+
104
+ tmp_file="$(mktemp)"
105
+ awk -v sec="## $SECTION_NAME" '
106
+ BEGIN { in_target=0; blank_buffer="" }
107
+ $0 == sec { in_target=1; blank_buffer=""; next }
108
+ in_target && /^## / && $0 != sec { in_target=0 }
109
+ !in_target {
110
+ if ($0 ~ /^[[:space:]]*$/) { if (blank_buffer == "") blank_buffer="\n"; next }
111
+ if (blank_buffer != "") { printf "%s", blank_buffer; blank_buffer="" }
112
+ print
113
+ }
114
+ END { if (blank_buffer != "") printf "%s", blank_buffer }
115
+ ' "$JTBD_FILE" > "$tmp_file"
116
+
117
+ tmp_file2="$(mktemp)"
118
+ awk 'BEGIN{c=0} /^[[:space:]]*$/{c++; next} {for(i=0;i<c;i++)print ""; c=0; print} END{print ""}' "$tmp_file" > "$tmp_file2"
119
+ mv "$tmp_file2" "$tmp_file"
120
+
121
+ if [ -n "$new_section" ]; then
122
+ printf '\n%s' "$new_section" >> "$tmp_file"
123
+ fi
124
+
125
+ if ! cmp -s "$tmp_file" "$JTBD_FILE"; then
126
+ mv "$tmp_file" "$JTBD_FILE"
127
+ else
128
+ rm -f "$tmp_file"
129
+ fi
130
+
131
+ exit 0
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/update-problem-references-section.sh
3
+ #
4
+ # Generalised reverse-trace section updater for problem tickets.
5
+ # Refreshes the auto-maintained `## <section-name>` section on a
6
+ # problem ticket file based on which artefacts in the configured
7
+ # source directory claim the ticket via either HTML `<meta>` data
8
+ # attributes (story-maps) or YAML frontmatter `problems:` list
9
+ # (stories + RFCs).
10
+ #
11
+ # Per ADR-060 § Phase 2 encoding amendment 2026-05-12 architect
12
+ # finding 4: the helper body MUST NOT carry per-section-name
13
+ # branching; `<section-name>` is a positional argument; the only
14
+ # branch in the helper body is the per-input-extension dispatch
15
+ # (`.html` → data-attribute grep; `.md` → frontmatter parse), which
16
+ # is determined by the section-to-source-dir lookup table, NOT by
17
+ # the section-name value itself. Each table row maps a section-name
18
+ # to its (source-dir, extension, ID-pattern) tuple; the helper body
19
+ # treats the table as opaque data.
20
+ #
21
+ # Supersedes/absorbs the single-purpose
22
+ # `packages/itil/scripts/update-problem-rfcs-section.sh` for the
23
+ # `## RFCs` section per ADR-060 amendment 2026-05-12 § Phase 2
24
+ # commit-grain decomposition + ADR-010 forwarder pattern. The old
25
+ # single-purpose helper stays as a thin shim during the deprecation
26
+ # window for back-compat with any pinned callers.
27
+ #
28
+ # Usage:
29
+ # update-problem-references-section.sh <problem-file> <section-name>
30
+ #
31
+ # Supported section-names (lookup-table-driven, NOT branching):
32
+ # - RFCs → docs/rfcs/*.md (frontmatter `problems:` extraction)
33
+ # - Story Maps → docs/story-maps/*/*.html (data-attribute extraction)
34
+ # - Stories → docs/stories/*/*.md (frontmatter `problems:` extraction)
35
+ #
36
+ # Lazy-empty discipline (per JTBD-101 atomic-fix-adopter friction
37
+ # guard + sibling pattern from update-problem-rfcs-section.sh): if
38
+ # zero artefacts trace the problem, the `## <section-name>` section
39
+ # is REMOVED entirely from the ticket file.
40
+ #
41
+ # Idempotent: running over a current section is a no-op (no file
42
+ # diff). Section placement: between `## Related` and `## Fix Released`
43
+ # (or at EOF if neither present), mirroring the RFC-section convention.
44
+ #
45
+ # @adr ADR-060 (Phase 2 encoding amendment 2026-05-12 — architect
46
+ # finding 4: no per-section-name branching; per-extension dispatch
47
+ # via lookup table)
48
+ # @adr ADR-014 (called by capture/manage skills to ride single-purpose
49
+ # commit grain)
50
+ # @adr ADR-052 (behavioural bats coverage in test/)
51
+ # @problem P170 (Phase 2 Slice 2 deliverable)
52
+
53
+ set -uo pipefail
54
+
55
+ PROBLEM_FILE="${1:-}"
56
+ SECTION_NAME="${2:-}"
57
+
58
+ if [ -z "$PROBLEM_FILE" ]; then
59
+ echo "ERROR: missing problem-file argument" >&2
60
+ echo "Usage: $(basename "$0") <problem-file> <section-name>" >&2
61
+ exit 1
62
+ fi
63
+ if [ -z "$SECTION_NAME" ]; then
64
+ echo "ERROR: missing section-name argument" >&2
65
+ echo "Usage: $(basename "$0") <problem-file> <section-name>" >&2
66
+ exit 1
67
+ fi
68
+ if [ ! -f "$PROBLEM_FILE" ]; then
69
+ echo "ERROR: problem file not found: $PROBLEM_FILE" >&2
70
+ exit 1
71
+ fi
72
+
73
+ # Lookup table: section-name -> (source-dir, glob, extraction-mode, id-pattern, title-render-helper).
74
+ # The helper body reads from this table; it does NOT branch on the
75
+ # section-name value directly (architect finding 4). Add a new section
76
+ # by extending the table.
77
+ declare -A SECTION_SOURCE_DIR
78
+ declare -A SECTION_GLOB
79
+ declare -A SECTION_MODE
80
+ declare -A SECTION_ID_PATTERN
81
+
82
+ SECTION_SOURCE_DIR["RFCs"]="docs/rfcs"
83
+ SECTION_GLOB["RFCs"]="docs/rfcs/RFC-*.md"
84
+ SECTION_MODE["RFCs"]="markdown-frontmatter"
85
+ SECTION_ID_PATTERN["RFCs"]="RFC-[0-9]+"
86
+
87
+ SECTION_SOURCE_DIR["Story Maps"]="docs/story-maps"
88
+ SECTION_GLOB["Story Maps"]="docs/story-maps/*/STORY-MAP-*.html"
89
+ SECTION_MODE["Story Maps"]="html-data-attribute"
90
+ SECTION_ID_PATTERN["Story Maps"]="STORY-MAP-[0-9]+"
91
+
92
+ SECTION_SOURCE_DIR["Stories"]="docs/stories"
93
+ SECTION_GLOB["Stories"]="docs/stories/*/STORY-*.md"
94
+ SECTION_MODE["Stories"]="markdown-frontmatter"
95
+ SECTION_ID_PATTERN["Stories"]="STORY-[0-9]+"
96
+
97
+ source_dir="${SECTION_SOURCE_DIR[$SECTION_NAME]:-}"
98
+ glob_pattern="${SECTION_GLOB[$SECTION_NAME]:-}"
99
+ extraction_mode="${SECTION_MODE[$SECTION_NAME]:-}"
100
+ id_pattern="${SECTION_ID_PATTERN[$SECTION_NAME]:-}"
101
+
102
+ if [ -z "$source_dir" ]; then
103
+ echo "ERROR: unknown section-name '$SECTION_NAME'. Supported: RFCs, Story Maps, Stories" >&2
104
+ exit 1
105
+ fi
106
+
107
+ # Extract problem ID from filename (NNN portion of NNN-<slug>.md or NNN-<slug>.<state>.md)
108
+ problem_basename=$(basename "$PROBLEM_FILE")
109
+ problem_id_num=$(echo "$problem_basename" | grep -oE '^[0-9]+')
110
+ if [ -z "$problem_id_num" ]; then
111
+ echo "ERROR: cannot extract problem ID from filename: $problem_basename" >&2
112
+ exit 1
113
+ fi
114
+ problem_id="P${problem_id_num}"
115
+
116
+ # Collect matching artefact IDs via the configured extraction mode.
117
+ # Mode dispatch (the only branch in the body) is keyed on the
118
+ # extraction-mode value pulled from the lookup table, NOT on the
119
+ # section-name. Adding a new mode is a lookup-table extension; the
120
+ # branch grows by one case.
121
+ declare -a matched_ids=()
122
+ declare -a matched_titles=()
123
+ declare -a matched_statuses=()
124
+
125
+ extract_from_markdown_frontmatter() {
126
+ local file="$1"
127
+ # Read frontmatter problems: list. Match formats:
128
+ # problems: [P200, P201]
129
+ # problems: ["P200", "P201"]
130
+ local problems_line
131
+ problems_line=$(awk '/^---$/{f=!f;next} f && /^problems:/' "$file" | head -1)
132
+ if [ -z "$problems_line" ]; then
133
+ return 1
134
+ fi
135
+ echo "$problems_line" | grep -qE "\\bP${problem_id_num}\\b"
136
+ }
137
+
138
+ extract_from_html_data_attribute() {
139
+ local file="$1"
140
+ # Read <meta name="problems" content="P200,P201">
141
+ local problems_line
142
+ problems_line=$(grep -E '<meta[[:space:]]+name="problems"[[:space:]]+content="[^"]+"' "$file" | head -1)
143
+ if [ -z "$problems_line" ]; then
144
+ return 1
145
+ fi
146
+ echo "$problems_line" | grep -qE "P${problem_id_num}\\b"
147
+ }
148
+
149
+ extract_id_from_filename() {
150
+ local file="$1"
151
+ basename "$file" | grep -oE "$id_pattern" | head -1
152
+ }
153
+
154
+ extract_title_from_markdown() {
155
+ local file="$1"
156
+ awk '/^# / { sub(/^# /, ""); print; exit }' "$file"
157
+ }
158
+
159
+ extract_title_from_html() {
160
+ local file="$1"
161
+ grep -oE '<title>[^<]+</title>' "$file" | head -1 | sed -E 's|<title>([^<]+)</title>|\1|'
162
+ }
163
+
164
+ extract_status_from_markdown_frontmatter() {
165
+ local file="$1"
166
+ awk '/^---$/{f=!f;next} f && /^status:/{ sub(/^status:[[:space:]]*/, ""); gsub(/"/, ""); print; exit }' "$file"
167
+ }
168
+
169
+ extract_status_from_html_meta() {
170
+ local file="$1"
171
+ grep -oE '<meta[[:space:]]+name="status"[[:space:]]+content="[^"]+"' "$file" | head -1 | sed -E 's|.*content="([^"]+)".*|\1|'
172
+ }
173
+
174
+ # Per-mode extraction dispatch (the ONLY branch in the body — keyed on
175
+ # extraction-mode pulled from the lookup table, not on section-name).
176
+ case "$extraction_mode" in
177
+ markdown-frontmatter)
178
+ extract_match=extract_from_markdown_frontmatter
179
+ extract_title=extract_title_from_markdown
180
+ extract_status=extract_status_from_markdown_frontmatter
181
+ ;;
182
+ html-data-attribute)
183
+ extract_match=extract_from_html_data_attribute
184
+ extract_title=extract_title_from_html
185
+ extract_status=extract_status_from_html_meta
186
+ ;;
187
+ *)
188
+ echo "ERROR: unknown extraction-mode '$extraction_mode'" >&2
189
+ exit 1
190
+ ;;
191
+ esac
192
+
193
+ shopt -s nullglob
194
+ for artefact in $glob_pattern; do
195
+ [ -e "$artefact" ] || continue
196
+ if "$extract_match" "$artefact"; then
197
+ aid=$(extract_id_from_filename "$artefact")
198
+ [ -n "$aid" ] || continue
199
+ title=$("$extract_title" "$artefact" 2>/dev/null || echo "")
200
+ status=$("$extract_status" "$artefact" 2>/dev/null || echo "unknown")
201
+ matched_ids+=("$aid")
202
+ matched_titles+=("$title")
203
+ matched_statuses+=("$status")
204
+ fi
205
+ done
206
+ shopt -u nullglob
207
+
208
+ # Render new section body (markdown table) when matches present;
209
+ # lazy-empty when not. Leading `\n` is intentionally omitted — the
210
+ # boundary is normalised at insertion time to exactly one blank line.
211
+ new_section=""
212
+ if [ ${#matched_ids[@]} -gt 0 ]; then
213
+ new_section="## ${SECTION_NAME}"$'\n\n'
214
+ new_section+=$'| ID | Title | Status |\n'
215
+ new_section+=$'|----|-------|--------|\n'
216
+ for i in "${!matched_ids[@]}"; do
217
+ new_section+="| ${matched_ids[$i]} | ${matched_titles[$i]} | ${matched_statuses[$i]} |"
218
+ new_section+=$'\n'
219
+ done
220
+ fi
221
+
222
+ # Rewrite the problem file: strip existing ## <SECTION_NAME> section
223
+ # (if present) AND any preceding blank-line run that abutted it, then
224
+ # normalise trailing whitespace to exactly one final newline, then
225
+ # insert new_section at the canonical placement (before ## Fix
226
+ # Released; else at EOF separated by exactly one blank line).
227
+ tmp_file="$(mktemp)"
228
+ awk -v sec="## $SECTION_NAME" '
229
+ BEGIN { in_target=0; blank_buffer="" }
230
+ $0 == sec {
231
+ in_target=1
232
+ blank_buffer="" # discard buffered blanks that preceded this section
233
+ next
234
+ }
235
+ in_target && /^## / && $0 != sec { in_target=0 }
236
+ !in_target {
237
+ if ($0 ~ /^[[:space:]]*$/) {
238
+ if (blank_buffer == "") {
239
+ blank_buffer = "\n" # remember a single blank
240
+ }
241
+ # collapse runs of blanks into one
242
+ next
243
+ }
244
+ if (blank_buffer != "") {
245
+ printf "%s", blank_buffer
246
+ blank_buffer=""
247
+ }
248
+ print
249
+ }
250
+ END {
251
+ if (blank_buffer != "") {
252
+ printf "%s", blank_buffer
253
+ }
254
+ }
255
+ ' "$PROBLEM_FILE" > "$tmp_file"
256
+
257
+ # Normalise trailing whitespace to single newline
258
+ tmp_file2="$(mktemp)"
259
+ awk 'BEGIN{c=0} /^[[:space:]]*$/{c++; next} {for(i=0;i<c;i++)print ""; c=0; print} END{print ""}' "$tmp_file" > "$tmp_file2"
260
+ mv "$tmp_file2" "$tmp_file"
261
+
262
+ # Append new section before ## Fix Released, or at EOF
263
+ if [ -n "$new_section" ]; then
264
+ if grep -q '^## Fix Released' "$tmp_file"; then
265
+ tmp_file2="$(mktemp)"
266
+ awk -v section="$new_section" '
267
+ /^## Fix Released/ { printf "%s\n", section; print; next }
268
+ { print }
269
+ ' "$tmp_file" > "$tmp_file2"
270
+ mv "$tmp_file2" "$tmp_file"
271
+ else
272
+ # Ensure file ends with exactly one blank line before section
273
+ printf '\n%s' "$new_section" >> "$tmp_file"
274
+ fi
275
+ fi
276
+
277
+ # Idempotent write: only update if content changed
278
+ if ! cmp -s "$tmp_file" "$PROBLEM_FILE"; then
279
+ mv "$tmp_file" "$PROBLEM_FILE"
280
+ else
281
+ rm -f "$tmp_file"
282
+ fi
283
+
284
+ exit 0
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/update-rfc-references-section.sh
3
+ #
4
+ # Generalised reverse-trace section updater for RFC files. Mirror of
5
+ # update-problem-references-section.sh with the lookup table tuned for
6
+ # RFC-on-RFC reverse traces:
7
+ # - ## Story Maps : sources docs/story-maps/*/*.html via data attributes
8
+ # - ## Stories : forward-trace from RFC's own frontmatter `stories:`
9
+ #
10
+ # Per ADR-060 § Phase 2 encoding amendment 2026-05-12 architect finding 4:
11
+ # no per-section-name branching in body; lookup-table-driven dispatch.
12
+ #
13
+ # Usage: update-rfc-references-section.sh <rfc-file> <section-name>
14
+ #
15
+ # @adr ADR-060 (Phase 2 encoding amendment 2026-05-12)
16
+ # @problem P170 (Phase 2 Slice 2b)
17
+
18
+ set -uo pipefail
19
+
20
+ RFC_FILE="${1:-}"
21
+ SECTION_NAME="${2:-}"
22
+
23
+ if [ -z "$RFC_FILE" ]; then
24
+ echo "ERROR: missing rfc-file argument" >&2
25
+ exit 1
26
+ fi
27
+ if [ -z "$SECTION_NAME" ]; then
28
+ echo "ERROR: missing section-name argument" >&2
29
+ exit 1
30
+ fi
31
+ if [ ! -f "$RFC_FILE" ]; then
32
+ echo "ERROR: rfc file not found: $RFC_FILE" >&2
33
+ exit 1
34
+ fi
35
+
36
+ declare -A SECTION_GLOB SECTION_MODE SECTION_ID_PATTERN
37
+
38
+ SECTION_GLOB["Story Maps"]="docs/story-maps/*/STORY-MAP-*.html"
39
+ SECTION_MODE["Story Maps"]="html-data-attribute-rfc"
40
+ SECTION_ID_PATTERN["Story Maps"]="STORY-MAP-[0-9]+"
41
+
42
+ SECTION_GLOB["Stories"]="docs/stories/*/STORY-*.md"
43
+ SECTION_MODE["Stories"]="markdown-frontmatter-rfc"
44
+ SECTION_ID_PATTERN["Stories"]="STORY-[0-9]+"
45
+
46
+ glob_pattern="${SECTION_GLOB[$SECTION_NAME]:-}"
47
+ extraction_mode="${SECTION_MODE[$SECTION_NAME]:-}"
48
+ id_pattern="${SECTION_ID_PATTERN[$SECTION_NAME]:-}"
49
+
50
+ if [ -z "$glob_pattern" ]; then
51
+ echo "ERROR: unknown section-name '$SECTION_NAME'. Supported: Story Maps, Stories" >&2
52
+ exit 1
53
+ fi
54
+
55
+ # Extract RFC ID from filename: RFC-NNN-slug.<status>.md or RFC-NNN-slug.md
56
+ rfc_basename=$(basename "$RFC_FILE")
57
+ rfc_id=$(echo "$rfc_basename" | grep -oE '^RFC-[0-9]+' | head -1)
58
+ if [ -z "$rfc_id" ]; then
59
+ echo "ERROR: cannot extract RFC ID from filename: $rfc_basename" >&2
60
+ exit 1
61
+ fi
62
+
63
+ declare -a matched_ids=() matched_titles=() matched_statuses=()
64
+
65
+ extract_from_html_rfcs_meta() {
66
+ local file="$1"
67
+ local rfcs_line
68
+ rfcs_line=$(grep -E '<meta[[:space:]]+name="rfcs"[[:space:]]+content="[^"]+"' "$file" | head -1)
69
+ [ -n "$rfcs_line" ] || return 1
70
+ echo "$rfcs_line" | grep -qE "\\b${rfc_id}\\b"
71
+ }
72
+
73
+ extract_from_markdown_frontmatter_rfcs() {
74
+ local file="$1"
75
+ local rfcs_line
76
+ rfcs_line=$(awk '/^---$/{f=!f;next} f && /^rfcs:/' "$file" | head -1)
77
+ [ -n "$rfcs_line" ] || return 1
78
+ echo "$rfcs_line" | grep -qE "\\b${rfc_id}\\b"
79
+ }
80
+
81
+ extract_id_from_filename() { basename "$1" | grep -oE "$id_pattern" | head -1; }
82
+ extract_title_from_markdown() { awk '/^# / { sub(/^# /, ""); print; exit }' "$1"; }
83
+ extract_title_from_html() { grep -oE '<title>[^<]+</title>' "$1" | head -1 | sed -E 's|<title>([^<]+)</title>|\1|'; }
84
+ extract_status_from_markdown() { awk '/^---$/{f=!f;next} f && /^status:/{ sub(/^status:[[:space:]]*/, ""); gsub(/"/, ""); print; exit }' "$1"; }
85
+ extract_status_from_html() { grep -oE '<meta[[:space:]]+name="status"[[:space:]]+content="[^"]+"' "$1" | head -1 | sed -E 's|.*content="([^"]+)".*|\1|'; }
86
+
87
+ case "$extraction_mode" in
88
+ html-data-attribute-rfc)
89
+ extract_match=extract_from_html_rfcs_meta
90
+ extract_title=extract_title_from_html
91
+ extract_status=extract_status_from_html
92
+ ;;
93
+ markdown-frontmatter-rfc)
94
+ extract_match=extract_from_markdown_frontmatter_rfcs
95
+ extract_title=extract_title_from_markdown
96
+ extract_status=extract_status_from_markdown
97
+ ;;
98
+ *)
99
+ echo "ERROR: unknown extraction-mode '$extraction_mode'" >&2
100
+ exit 1
101
+ ;;
102
+ esac
103
+
104
+ shopt -s nullglob
105
+ for artefact in $glob_pattern; do
106
+ [ -e "$artefact" ] || continue
107
+ if "$extract_match" "$artefact"; then
108
+ aid=$(extract_id_from_filename "$artefact")
109
+ [ -n "$aid" ] || continue
110
+ matched_ids+=("$aid")
111
+ matched_titles+=("$("$extract_title" "$artefact" 2>/dev/null || echo "")")
112
+ matched_statuses+=("$("$extract_status" "$artefact" 2>/dev/null || echo "unknown")")
113
+ fi
114
+ done
115
+ shopt -u nullglob
116
+
117
+ new_section=""
118
+ if [ ${#matched_ids[@]} -gt 0 ]; then
119
+ new_section="## ${SECTION_NAME}"$'\n\n| ID | Title | Status |\n|----|-------|--------|\n'
120
+ for i in "${!matched_ids[@]}"; do
121
+ new_section+="| ${matched_ids[$i]} | ${matched_titles[$i]} | ${matched_statuses[$i]} |"$'\n'
122
+ done
123
+ fi
124
+
125
+ tmp_file="$(mktemp)"
126
+ awk -v sec="## $SECTION_NAME" '
127
+ BEGIN { in_target=0; blank_buffer="" }
128
+ $0 == sec { in_target=1; blank_buffer=""; next }
129
+ in_target && /^## / && $0 != sec { in_target=0 }
130
+ !in_target {
131
+ if ($0 ~ /^[[:space:]]*$/) { if (blank_buffer == "") blank_buffer="\n"; next }
132
+ if (blank_buffer != "") { printf "%s", blank_buffer; blank_buffer="" }
133
+ print
134
+ }
135
+ END { if (blank_buffer != "") printf "%s", blank_buffer }
136
+ ' "$RFC_FILE" > "$tmp_file"
137
+
138
+ tmp_file2="$(mktemp)"
139
+ awk 'BEGIN{c=0} /^[[:space:]]*$/{c++; next} {for(i=0;i<c;i++)print ""; c=0; print} END{print ""}' "$tmp_file" > "$tmp_file2"
140
+ mv "$tmp_file2" "$tmp_file"
141
+
142
+ if [ -n "$new_section" ]; then
143
+ printf '\n%s' "$new_section" >> "$tmp_file"
144
+ fi
145
+
146
+ if ! cmp -s "$tmp_file" "$RFC_FILE"; then
147
+ mv "$tmp_file" "$RFC_FILE"
148
+ else
149
+ rm -f "$tmp_file"
150
+ fi
151
+
152
+ exit 0