@windyroad/itil 0.25.0 → 0.26.0-preview.291
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 +3 -0
- package/bin/wr-itil-reconcile-rfcs +2 -0
- package/hooks/hooks.json +6 -0
- package/hooks/itil-rfc-trailer-advisory.sh +198 -0
- package/hooks/lib/create-gate.sh +30 -0
- package/hooks/manage-problem-enforce-create.sh +89 -44
- package/hooks/test/itil-rfc-trailer-advisory.bats +273 -0
- package/hooks/test/manage-problem-enforce-create.bats +105 -1
- package/package.json +1 -1
- package/scripts/reconcile-rfcs.sh +329 -0
- package/scripts/test/reconcile-rfcs.bats +433 -0
- package/scripts/test/update-problem-rfcs-section.bats +242 -0
- package/scripts/update-problem-rfcs-section.sh +160 -0
- package/skills/capture-rfc/SKILL.md +276 -0
- package/skills/manage-rfc/SKILL.md +260 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# packages/itil/scripts/reconcile-rfcs.sh
|
|
3
|
+
#
|
|
4
|
+
# Diagnose-only drift detector for docs/rfcs/README.md vs filesystem
|
|
5
|
+
# truth. Reads <rfcs-dir>/RFC-<NNN>-*.<status>.md, parses the README's
|
|
6
|
+
# WSJF Rankings + Verification Queue + Closed tables, and reports each
|
|
7
|
+
# disagreement.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# reconcile-rfcs.sh [<rfcs-dir> [<problems-dir>]]
|
|
11
|
+
#
|
|
12
|
+
# Default <rfcs-dir> is ./docs/rfcs.
|
|
13
|
+
# Default <problems-dir> is ./docs/problems (when supplied; absent dir
|
|
14
|
+
# silently skips the reverse-trace pass per backward-compat carve-out).
|
|
15
|
+
#
|
|
16
|
+
# Exit codes:
|
|
17
|
+
# 0 = clean (README matches filesystem)
|
|
18
|
+
# 1 = drift detected (structured diff to stdout)
|
|
19
|
+
# 2 = parse error (README missing or malformed)
|
|
20
|
+
#
|
|
21
|
+
# Output format on drift (one line per drift entry, ≤ 150 bytes per
|
|
22
|
+
# ADR-038 progressive-disclosure budget):
|
|
23
|
+
# DRIFT RFC-<NNN> wsjf-rankings: claims=<status> actual=<status>
|
|
24
|
+
# MISSING RFC-<NNN> wsjf-rankings: actual=<status>
|
|
25
|
+
# STALE RFC-<NNN> verification-queue: actual=<status>
|
|
26
|
+
# MISMATCH RFC-<NNN> closed: actual=<status>
|
|
27
|
+
#
|
|
28
|
+
# Reverse-trace pass (B5.T8 — closes ADR-060 Confirmation criterion 3):
|
|
29
|
+
# When <problems-dir> is provided AND on disk, the reconciler also
|
|
30
|
+
# checks the auto-maintained `## RFCs` section on each problem ticket
|
|
31
|
+
# against the RFC frontmatter `problems:` claims. Three drift kinds:
|
|
32
|
+
# MISSING_REVERSE_TRACE RFC-<NNN> in P<NNN> ## RFCs
|
|
33
|
+
# RFC's frontmatter claims P<NNN> but P<NNN>'s ## RFCs table does
|
|
34
|
+
# not list RFC-<NNN>. Skill-side refresh contract was missed.
|
|
35
|
+
# STALE_REVERSE_TRACE RFC-<NNN> in P<NNN> ## RFCs
|
|
36
|
+
# P<NNN>'s ## RFCs lists RFC-<NNN> but the RFC frontmatter no
|
|
37
|
+
# longer claims P<NNN>. Re-trace bookkeeping was missed.
|
|
38
|
+
# STATUS_MISMATCH RFC-<NNN> in P<NNN> ## RFCs claims=<X> actual=<Y>
|
|
39
|
+
# P<NNN>'s ## RFCs row claims status <X> but RFC's filesystem
|
|
40
|
+
# suffix is <Y>. Status-column refresh contract was missed.
|
|
41
|
+
#
|
|
42
|
+
# Read-only — does NOT mutate the README. The /wr-itil:manage-rfc skill
|
|
43
|
+
# applies edits with narrative-aware preservation; this script's only
|
|
44
|
+
# job is to report ground truth.
|
|
45
|
+
#
|
|
46
|
+
# Sibling to packages/itil/scripts/reconcile-readme.sh (P118 / ADR-014):
|
|
47
|
+
# same parse + diff structure, applied at the RFC tier instead of the
|
|
48
|
+
# problems tier. Differences:
|
|
49
|
+
# - Filename pattern: RFC-NNN-*.<status>.md (5 statuses: proposed,
|
|
50
|
+
# accepted, in-progress, verifying, closed)
|
|
51
|
+
# - ID format: RFC-<NNN> (vs P<NNN>)
|
|
52
|
+
# - WSJF Rankings covers proposed/accepted/in-progress (RFC dev-work
|
|
53
|
+
# queue per ADR-060 § Decisions Resolved — RFC-level WSJF, Phase 1)
|
|
54
|
+
# - Verification Queue covers verifying (matches problem tier)
|
|
55
|
+
# - Closed covers closed
|
|
56
|
+
# - No Parked tier (RFCs don't have a Parked status per ADR-060;
|
|
57
|
+
# only Problems do)
|
|
58
|
+
#
|
|
59
|
+
# @problem P170
|
|
60
|
+
# @adr ADR-060 (Problem-RFC-Story framework — Phase 1 item 5)
|
|
61
|
+
# @adr ADR-049 (Plugin script resolution via bin/ on PATH — paired bin shim)
|
|
62
|
+
|
|
63
|
+
set -uo pipefail
|
|
64
|
+
|
|
65
|
+
RFCS_DIR="${1:-docs/rfcs}"
|
|
66
|
+
# Default PROBLEMS_DIR to the sibling of RFCS_DIR (so real-use
|
|
67
|
+
# `docs/rfcs` → `docs/problems`, and fixture-isolated test runs in
|
|
68
|
+
# `/tmp/X` → `/tmp/problems` which won't exist, gracefully skipping
|
|
69
|
+
# reverse-trace). Backward-compat: existing 18-case bats fixtures
|
|
70
|
+
# stay clean because `dirname /tmp/<rand>` = `/tmp`, never colliding
|
|
71
|
+
# with the real repo's `docs/problems`.
|
|
72
|
+
PROBLEMS_DIR="${2:-$(dirname "$RFCS_DIR")/problems}"
|
|
73
|
+
README="${RFCS_DIR}/README.md"
|
|
74
|
+
|
|
75
|
+
# ── Pre-checks ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
if [ ! -f "$README" ]; then
|
|
78
|
+
echo "PARSE_ERROR: README not found at ${README}" >&2
|
|
79
|
+
exit 2
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
if ! grep -q '^## RFC Rankings\|^## WSJF Rankings' "$README"; then
|
|
83
|
+
echo "PARSE_ERROR: '## RFC Rankings' or '## WSJF Rankings' header missing in ${README}" >&2
|
|
84
|
+
exit 2
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# ── Build filesystem truth: ID → status ─────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
declare -A FS_STATUS
|
|
90
|
+
shopt -s nullglob
|
|
91
|
+
for f in "$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.proposed.md \
|
|
92
|
+
"$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.accepted.md \
|
|
93
|
+
"$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.in-progress.md \
|
|
94
|
+
"$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.verifying.md \
|
|
95
|
+
"$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.closed.md; do
|
|
96
|
+
base="$(basename "$f")"
|
|
97
|
+
# Extract NNN from RFC-NNN-...
|
|
98
|
+
num="${base#RFC-}"
|
|
99
|
+
num="${num%%-*}"
|
|
100
|
+
id="RFC-${num}"
|
|
101
|
+
case "$base" in
|
|
102
|
+
*.proposed.md) ticket_status="proposed" ;;
|
|
103
|
+
*.accepted.md) ticket_status="accepted" ;;
|
|
104
|
+
*.in-progress.md) ticket_status="in-progress" ;;
|
|
105
|
+
*.verifying.md) ticket_status="verifying" ;;
|
|
106
|
+
*.closed.md) ticket_status="closed" ;;
|
|
107
|
+
*) continue ;;
|
|
108
|
+
esac
|
|
109
|
+
FS_STATUS["$id"]="$ticket_status"
|
|
110
|
+
done
|
|
111
|
+
shopt -u nullglob
|
|
112
|
+
|
|
113
|
+
# ── Parse README sections into ID buckets ───────────────────────────────────
|
|
114
|
+
|
|
115
|
+
# Accept either '## RFC Rankings' (this README's heading) or
|
|
116
|
+
# '## WSJF Rankings' (problems-tier-style heading) — the structural test
|
|
117
|
+
# is "is there a ranking section?" not "is the heading word-for-word".
|
|
118
|
+
WSJF_START=$(grep -nE '^## (RFC Rankings|WSJF Rankings)' "$README" | head -1 | cut -d: -f1)
|
|
119
|
+
VQ_START=$(grep -n '^## Verification Queue' "$README" | head -1 | cut -d: -f1)
|
|
120
|
+
CLOSED_START=$(grep -n '^## Closed' "$README" | head -1 | cut -d: -f1)
|
|
121
|
+
END_LINE=$(wc -l < "$README")
|
|
122
|
+
|
|
123
|
+
WSJF_END=${VQ_START:-${CLOSED_START:-$END_LINE}}
|
|
124
|
+
VQ_END=${CLOSED_START:-$END_LINE}
|
|
125
|
+
CLOSED_END=$END_LINE
|
|
126
|
+
|
|
127
|
+
extract_section_ids() {
|
|
128
|
+
local start="$1" end="$2"
|
|
129
|
+
[ -z "$start" ] && return 0
|
|
130
|
+
sed -n "${start},${end}p" "$README" \
|
|
131
|
+
| grep -oE '\| *RFC-[0-9]{3} *\|' \
|
|
132
|
+
| grep -oE 'RFC-[0-9]{3}' \
|
|
133
|
+
| sort -u
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
README_WSJF_IDS="$(extract_section_ids "$WSJF_START" "$WSJF_END")"
|
|
137
|
+
README_VQ_IDS="$(extract_section_ids "$VQ_START" "$VQ_END")"
|
|
138
|
+
README_CLOSED_IDS="$(extract_section_ids "$CLOSED_START" "$CLOSED_END")"
|
|
139
|
+
|
|
140
|
+
# ── Diff ─────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
DRIFT_LINES=()
|
|
143
|
+
|
|
144
|
+
# (1) Each ID listed in RFC Rankings must be proposed/accepted/in-progress
|
|
145
|
+
# on disk. Other statuses (verifying/closed) → drift.
|
|
146
|
+
while read -r id; do
|
|
147
|
+
[ -z "$id" ] && continue
|
|
148
|
+
actual="${FS_STATUS[$id]:-missing}"
|
|
149
|
+
case "$actual" in
|
|
150
|
+
proposed|accepted|in-progress)
|
|
151
|
+
: # ok
|
|
152
|
+
;;
|
|
153
|
+
*)
|
|
154
|
+
DRIFT_LINES+=("DRIFT ${id} wsjf-rankings: claims=open actual=${actual}")
|
|
155
|
+
;;
|
|
156
|
+
esac
|
|
157
|
+
done <<< "$README_WSJF_IDS"
|
|
158
|
+
|
|
159
|
+
# (2) Each ID listed in Verification Queue must be verifying on disk.
|
|
160
|
+
while read -r id; do
|
|
161
|
+
[ -z "$id" ] && continue
|
|
162
|
+
actual="${FS_STATUS[$id]:-missing}"
|
|
163
|
+
case "$actual" in
|
|
164
|
+
verifying)
|
|
165
|
+
: # ok
|
|
166
|
+
;;
|
|
167
|
+
*)
|
|
168
|
+
DRIFT_LINES+=("STALE ${id} verification-queue: actual=${actual}")
|
|
169
|
+
;;
|
|
170
|
+
esac
|
|
171
|
+
done <<< "$README_VQ_IDS"
|
|
172
|
+
|
|
173
|
+
# (3) Each ID listed in Closed section must be closed on disk.
|
|
174
|
+
while read -r id; do
|
|
175
|
+
[ -z "$id" ] && continue
|
|
176
|
+
actual="${FS_STATUS[$id]:-missing}"
|
|
177
|
+
case "$actual" in
|
|
178
|
+
closed)
|
|
179
|
+
: # ok
|
|
180
|
+
;;
|
|
181
|
+
*)
|
|
182
|
+
DRIFT_LINES+=("MISMATCH ${id} closed: actual=${actual}")
|
|
183
|
+
;;
|
|
184
|
+
esac
|
|
185
|
+
done <<< "$README_CLOSED_IDS"
|
|
186
|
+
|
|
187
|
+
# (4) Each filesystem RFC must appear in the right README section.
|
|
188
|
+
declare -A IN_WSJF
|
|
189
|
+
while read -r id; do
|
|
190
|
+
[ -z "$id" ] && continue
|
|
191
|
+
IN_WSJF["$id"]=1
|
|
192
|
+
done <<< "$README_WSJF_IDS"
|
|
193
|
+
|
|
194
|
+
declare -A IN_VQ
|
|
195
|
+
while read -r id; do
|
|
196
|
+
[ -z "$id" ] && continue
|
|
197
|
+
IN_VQ["$id"]=1
|
|
198
|
+
done <<< "$README_VQ_IDS"
|
|
199
|
+
|
|
200
|
+
for id in "${!FS_STATUS[@]}"; do
|
|
201
|
+
ticket_status="${FS_STATUS[$id]}"
|
|
202
|
+
case "$ticket_status" in
|
|
203
|
+
proposed|accepted|in-progress)
|
|
204
|
+
if [ -z "${IN_WSJF[$id]:-}" ]; then
|
|
205
|
+
DRIFT_LINES+=("MISSING ${id} wsjf-rankings: actual=${ticket_status}")
|
|
206
|
+
fi
|
|
207
|
+
;;
|
|
208
|
+
verifying)
|
|
209
|
+
if [ -z "${IN_VQ[$id]:-}" ]; then
|
|
210
|
+
DRIFT_LINES+=("MISSING ${id} verification-queue: actual=${ticket_status}")
|
|
211
|
+
fi
|
|
212
|
+
;;
|
|
213
|
+
# closed: Closed section is curated narrative; absence is soft
|
|
214
|
+
# drift not flagged at this layer (mirrors reconcile-readme).
|
|
215
|
+
esac
|
|
216
|
+
done
|
|
217
|
+
|
|
218
|
+
# ── Reverse-trace pass (B5.T8) ──────────────────────────────────────────────
|
|
219
|
+
# When PROBLEMS_DIR exists, validate that each problem ticket's auto-
|
|
220
|
+
# maintained `## RFCs` section agrees with the corresponding RFC
|
|
221
|
+
# frontmatter `problems:` claims (and vice-versa).
|
|
222
|
+
|
|
223
|
+
if [ -d "$PROBLEMS_DIR" ]; then
|
|
224
|
+
# rfc_problems_claim["RFC-NNN"] = "P168 P169 ..."
|
|
225
|
+
declare -A rfc_problems_claim
|
|
226
|
+
shopt -s nullglob
|
|
227
|
+
for f in "$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.md; do
|
|
228
|
+
base="$(basename "$f")"
|
|
229
|
+
num="${base#RFC-}"
|
|
230
|
+
num="${num%%-*}"
|
|
231
|
+
rfc_id="RFC-${num}"
|
|
232
|
+
# Parse YAML frontmatter `problems: [P168, P169]` (single line).
|
|
233
|
+
raw=$(awk '/^problems:/ { print; exit }' "$f")
|
|
234
|
+
# Strip everything except inside-brackets bare comma-separated content.
|
|
235
|
+
inner=$(echo "$raw" | sed -E 's/^[[:space:]]*problems:[[:space:]]*\[//; s/\][[:space:]]*$//')
|
|
236
|
+
# Tokenise on commas, normalise to bare P<NNN> tokens.
|
|
237
|
+
pids=""
|
|
238
|
+
if [ -n "$inner" ]; then
|
|
239
|
+
while IFS= read -r tok; do
|
|
240
|
+
tok=$(echo "$tok" | tr -d ' "'\''')
|
|
241
|
+
case "$tok" in
|
|
242
|
+
P[0-9][0-9][0-9]) pids="${pids:+$pids }$tok" ;;
|
|
243
|
+
esac
|
|
244
|
+
done <<< "$(echo "$inner" | tr ',' '\n')"
|
|
245
|
+
fi
|
|
246
|
+
rfc_problems_claim["$rfc_id"]="$pids"
|
|
247
|
+
done
|
|
248
|
+
shopt -u nullglob
|
|
249
|
+
|
|
250
|
+
# problem_rfc_rows["P168 RFC-001"] = "<claimed-status>"
|
|
251
|
+
# problem_rfc_ids["P168"] = "RFC-001 RFC-002 ..."
|
|
252
|
+
declare -A problem_rfc_rows
|
|
253
|
+
declare -A problem_rfc_ids
|
|
254
|
+
shopt -s nullglob
|
|
255
|
+
for pf in "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.md; do
|
|
256
|
+
pbase="$(basename "$pf")"
|
|
257
|
+
pnum="${pbase%%-*}"
|
|
258
|
+
pid="P${pnum}"
|
|
259
|
+
# Locate `## RFCs` section start (if any).
|
|
260
|
+
sec_start=$(awk 'BEGIN{flag=0} /^## RFCs[[:space:]]*$/ {print NR; exit}' "$pf")
|
|
261
|
+
[ -z "$sec_start" ] && continue
|
|
262
|
+
# Read until next `## ` header or EOF; extract `| RFC-NNN | <status> | ...|` rows.
|
|
263
|
+
rfcs_in_p=""
|
|
264
|
+
while IFS= read -r line; do
|
|
265
|
+
case "$line" in
|
|
266
|
+
\|*RFC-[0-9][0-9][0-9]*\|*)
|
|
267
|
+
rid=$(echo "$line" | grep -oE 'RFC-[0-9]{3}' | head -1)
|
|
268
|
+
claimed=$(echo "$line" | awk -F'|' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$3); print $3}')
|
|
269
|
+
[ -z "$rid" ] && continue
|
|
270
|
+
problem_rfc_rows["${pid} ${rid}"]="$claimed"
|
|
271
|
+
rfcs_in_p="${rfcs_in_p:+$rfcs_in_p }$rid"
|
|
272
|
+
;;
|
|
273
|
+
esac
|
|
274
|
+
done < <(awk -v start="$sec_start" 'NR>start { if (/^## /) exit; print }' "$pf")
|
|
275
|
+
problem_rfc_ids["$pid"]="$rfcs_in_p"
|
|
276
|
+
done
|
|
277
|
+
shopt -u nullglob
|
|
278
|
+
|
|
279
|
+
# 1. MISSING_REVERSE_TRACE: RFC claims P, P does not list RFC.
|
|
280
|
+
for rfc_id in "${!rfc_problems_claim[@]}"; do
|
|
281
|
+
pids="${rfc_problems_claim[$rfc_id]}"
|
|
282
|
+
[ -z "$pids" ] && continue
|
|
283
|
+
for pid in $pids; do
|
|
284
|
+
listed="${problem_rfc_ids[$pid]:-}"
|
|
285
|
+
case " $listed " in
|
|
286
|
+
*" $rfc_id "*) : ;;
|
|
287
|
+
*)
|
|
288
|
+
DRIFT_LINES+=("MISSING_REVERSE_TRACE ${rfc_id} in ${pid} ## RFCs")
|
|
289
|
+
;;
|
|
290
|
+
esac
|
|
291
|
+
done
|
|
292
|
+
done
|
|
293
|
+
|
|
294
|
+
# 2. STALE_REVERSE_TRACE: P lists RFC, RFC frontmatter does not claim P.
|
|
295
|
+
# STATUS_MISMATCH: P's row claims status X but RFC suffix is Y.
|
|
296
|
+
for pid in "${!problem_rfc_ids[@]}"; do
|
|
297
|
+
rids="${problem_rfc_ids[$pid]}"
|
|
298
|
+
[ -z "$rids" ] && continue
|
|
299
|
+
for rid in $rids; do
|
|
300
|
+
claimed_pids="${rfc_problems_claim[$rid]:-}"
|
|
301
|
+
case " $claimed_pids " in
|
|
302
|
+
*" $pid "*)
|
|
303
|
+
# Status-mismatch check (only when reverse-trace is itself current).
|
|
304
|
+
claimed_status="${problem_rfc_rows[${pid} ${rid}]:-}"
|
|
305
|
+
actual_status="${FS_STATUS[$rid]:-missing}"
|
|
306
|
+
if [ -n "$claimed_status" ] && [ "$claimed_status" != "$actual_status" ]; then
|
|
307
|
+
DRIFT_LINES+=("STATUS_MISMATCH ${rid} in ${pid} ## RFCs claims=${claimed_status} actual=${actual_status}")
|
|
308
|
+
fi
|
|
309
|
+
;;
|
|
310
|
+
*)
|
|
311
|
+
DRIFT_LINES+=("STALE_REVERSE_TRACE ${rid} in ${pid} ## RFCs")
|
|
312
|
+
;;
|
|
313
|
+
esac
|
|
314
|
+
done
|
|
315
|
+
done
|
|
316
|
+
fi
|
|
317
|
+
|
|
318
|
+
# ── Report ──────────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
if [ ${#DRIFT_LINES[@]} -eq 0 ]; then
|
|
321
|
+
exit 0
|
|
322
|
+
fi
|
|
323
|
+
|
|
324
|
+
IFS=$'\n' sorted=($(printf '%s\n' "${DRIFT_LINES[@]}" | sort))
|
|
325
|
+
unset IFS
|
|
326
|
+
for line in "${sorted[@]}"; do
|
|
327
|
+
printf '%s\n' "$line"
|
|
328
|
+
done
|
|
329
|
+
exit 1
|