@windyroad/itil 0.50.3 → 0.51.0-preview.746

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -497,5 +497,5 @@
497
497
  }
498
498
  },
499
499
  "name": "wr-itil",
500
- "version": "0.50.3"
500
+ "version": "0.51.0"
501
501
  }
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # Generated by scripts/sync-shim-wrappers.sh from
3
+ # packages/shared/lib/shim-wrapper-template.sh. DO NOT EDIT individual
4
+ # shim files in packages/*/bin/wr-* directly; edit the template + run
5
+ # `npm run sync:shim-wrappers` to regenerate.
6
+ #
7
+ # Resolution (ADR-080):
8
+ # 1. If the wrapper's parent dir is semver-shaped, treat as installed-
9
+ # cache execution and resolve to the highest-version sibling's
10
+ # scripts/ entry below.
11
+ # 2. Otherwise (parent dir is e.g. `architect`), treat as source-
12
+ # monorepo execution and dispatch to own scripts/. The source-repo-
13
+ # guard `exec` is the anchor parsed by
14
+ # packages/retrospective/scripts/check-tarball-shipped-shims.sh.
15
+ # 3. If the cache parent contains zero semver-shaped siblings, exit
16
+ # 127 with a stderr message naming the cache parent (per SQ-080-2).
17
+ #
18
+ # @adr ADR-080 (highest-version-wins shim wrapper plugin scaffold)
19
+ # @adr ADR-049 (plugin-bundled scripts resolve via bin/ on $PATH — amended)
20
+ # @problem P343 (mid-session staleness window)
21
+
22
+ set -euo pipefail
23
+
24
+ SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"
25
+ OWN_VERSION_DIR="$(dirname "$SHIM_DIR")"
26
+ OWN_VERSION_NAME="$(basename "$OWN_VERSION_DIR")"
27
+ CACHE_PARENT="$(dirname "$OWN_VERSION_DIR")"
28
+
29
+ SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'
30
+
31
+ # Source-repo guard: own parent dir is NOT semver → dispatch to own scripts/.
32
+ if ! [[ "$OWN_VERSION_NAME" =~ $SEMVER_RE ]]; then
33
+ exec "$SHIM_DIR/../scripts/catchup-scan.sh" "$@"
34
+ fi
35
+
36
+ # Cache execution: pick the highest-semver sibling under CACHE_PARENT.
37
+ HIGHEST=""
38
+ while IFS= read -r dir; do
39
+ name="$(basename "$dir")"
40
+ [[ "$name" =~ $SEMVER_RE ]] || continue
41
+ if [[ -z "$HIGHEST" ]] || [[ "$(printf '%s\n%s\n' "$HIGHEST" "$name" | sort -V | tail -1)" == "$name" ]]; then
42
+ HIGHEST="$name"
43
+ fi
44
+ done < <(find "$CACHE_PARENT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
45
+
46
+ if [[ -z "$HIGHEST" ]]; then
47
+ printf 'wr-shim: no cached versions in %s\n' "$CACHE_PARENT" >&2
48
+ exit 127
49
+ fi
50
+
51
+ exec "$CACHE_PARENT/$HIGHEST/scripts/catchup-scan.sh" "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.50.3",
3
+ "version": "0.51.0-preview.746",
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,224 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/catchup-scan.sh
3
+ #
4
+ # Phase 2 of P080 — `--catchup` migration-mode worklist scanner for
5
+ # `/wr-itil:update-upstream`. Walks the EXISTING `.verifying.md` +
6
+ # `.closed.md` ticket corpus, filters to tickets carrying a `## Reported
7
+ # Upstream` back-link section (written by `/wr-itil:report-upstream`
8
+ # Step 7), and emits a per-ticket worklist of upstream issues that still
9
+ # need a retroactive lifecycle-update comment.
10
+ #
11
+ # Read-only / local-only: this script makes NO `gh` calls and NO writes.
12
+ # It only reads local ticket files and prints a worklist to stdout. The
13
+ # actual `gh issue comment` / `gh issue close` posts — and the ADR-028
14
+ # external-comms + voice-tone gate composition that guards them — stay in
15
+ # the `/wr-itil:update-upstream` SKILL's per-ticket Step 4-6 loop, which
16
+ # consumes this worklist. Keeping the writes out of the scanner keeps it
17
+ # AFK-safe and behaviourally testable without a live upstream.
18
+ #
19
+ # Idempotency (P080 Phase 2 acceptance criterion 3): a ticket whose
20
+ # `## Upstream Lifecycle Updates` log already records an entry for the
21
+ # current target-state transition is reported as SKIP/already-logged, NOT
22
+ # re-posted. The marker-on-body log (append-only per the ADR-024 P080
23
+ # amendment) is the source of truth — re-running catchup is safe.
24
+ #
25
+ # Usage:
26
+ # catchup-scan.sh
27
+ # [--problems-dir <dir>] default: docs/problems
28
+ # [--ticket P<NNN>] restrict the scan to one ticket
29
+ #
30
+ # Exit codes:
31
+ # 0 = success (zero or more worklist lines on stdout)
32
+ # 1 = error (problems-dir missing, malformed CLI args)
33
+ #
34
+ # Structured stdout (one per actionable upstream entry; <= 150 bytes per
35
+ # line per ADR-038). ASCII `->` for the transition arrow per the P334
36
+ # awk/script portability lesson (no Unicode in machine-read output):
37
+ # CATCHUP P<NNN> <url> state=<state> transition=<KE->Verifying|Verifying->Closed>
38
+ # SKIP P<NNN> <url> reason=already-logged
39
+ # SKIP P<NNN> <url> reason=out-of-band
40
+ # Tickets with no `## Reported Upstream` section are skipped silently (the
41
+ # common case — most tickets were never reported upstream).
42
+ #
43
+ # Trailing summary line (stderr) for the SKILL / human reader:
44
+ # SUMMARY scanned=<N> catchup=<N> skip-logged=<N> skip-out-of-band=<N>
45
+ #
46
+ # @problem P080 — no bidirectional update of upstream-reported problems (Phase 2 --catchup)
47
+ # @adr ADR-024 (amended P080 Phase 2 — --catchup migration mode + idempotency contract)
48
+ # @adr ADR-014 (governance skills commit their own work)
49
+ # @adr ADR-038 (progressive disclosure — per-row byte budget)
50
+ # @adr ADR-049 (invoked via wr-itil-catchup-scan bin shim, never repo-relative path)
51
+ # @adr ADR-032 (foreground synchronous skill)
52
+ # @jtbd JTBD-301 (reporter feedback loop — the catchup's primary job)
53
+ # @jtbd JTBD-006 (AFK-safe worklist scanner)
54
+ # @jtbd JTBD-004 (cross-repo coordination — reconcile local corpus vs upstream trackers)
55
+ # @jtbd JTBD-001 (governance without slowing down — derive the worklist, no manual policing)
56
+ # @jtbd JTBD-201 (symmetric local/upstream audit trail)
57
+
58
+ set -uo pipefail
59
+
60
+ # ── Parse CLI args ──────────────────────────────────────────────────────────
61
+
62
+ PROBLEMS_DIR="docs/problems"
63
+ TICKET_FILTER=""
64
+
65
+ while [ $# -gt 0 ]; do
66
+ case "$1" in
67
+ --problems-dir) PROBLEMS_DIR="$2"; shift 2 ;;
68
+ --ticket) TICKET_FILTER="$2"; shift 2 ;;
69
+ -h|--help)
70
+ sed -n '/^# Usage:/,/^# Exit codes:/p' "$0" | sed 's/^# //'
71
+ exit 0
72
+ ;;
73
+ *) echo "ERROR: unknown argument: $1" >&2; exit 1 ;;
74
+ esac
75
+ done
76
+
77
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
78
+
79
+ if [ ! -d "$PROBLEMS_DIR" ]; then
80
+ echo "ERROR: problems-dir not found: $PROBLEMS_DIR" >&2
81
+ exit 1
82
+ fi
83
+
84
+ # ── Discover the post-fix corpus (.verifying.md + .closed.md only) ──────────
85
+ #
86
+ # Catchup only covers tickets PAST the fix point — the lifecycle updates a
87
+ # reporter most wants (fix released / closed) are the ones the pre-Phase-1
88
+ # corpus is missing. Open / Known-Error / Parked tickets are out of scope
89
+ # for the migration (their transitions, if linked, fire the per-ticket
90
+ # path going forward). Dual-tolerant per RFC-002: flat layout
91
+ # `<NNN>-*.<status>.md` AND per-state subdir `<status>/<NNN>-*.md`.
92
+
93
+ shopt -s nullglob
94
+
95
+ declare -a TICKET_FILES
96
+ TICKET_FILES=()
97
+ for f in "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.verifying.md \
98
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.closed.md \
99
+ "$PROBLEMS_DIR"/verifying/[0-9][0-9][0-9]-*.md \
100
+ "$PROBLEMS_DIR"/closed/[0-9][0-9][0-9]-*.md ; do
101
+ TICKET_FILES+=("$f")
102
+ done
103
+
104
+ # Extract the first `## Reported Upstream` URL from a ticket file.
105
+ extract_upstream_url() {
106
+ awk '
107
+ /^## Reported Upstream/ { in_section = 1; next }
108
+ /^## / && in_section { in_section = 0 }
109
+ in_section && /^- \*\*URL\*\*:/ {
110
+ sub(/^- \*\*URL\*\*: */, "")
111
+ sub(/[[:space:]].*$/, "")
112
+ print
113
+ exit
114
+ }
115
+ ' "$1"
116
+ }
117
+
118
+ # Extract the `## Reported Upstream` disclosure-path line (lower-cased).
119
+ extract_disclosure_path() {
120
+ awk '
121
+ /^## Reported Upstream/ { in_section = 1; next }
122
+ /^## / && in_section { in_section = 0 }
123
+ in_section && /^- \*\*Disclosure path\*\*:/ {
124
+ sub(/^- \*\*Disclosure path\*\*: */, "")
125
+ print
126
+ exit
127
+ }
128
+ ' "$1" | tr "[:upper:]" "[:lower:]"
129
+ }
130
+
131
+ # Does the `## Upstream Lifecycle Updates` log already record the target
132
+ # transition? `needle` is the ASCII-or-Unicode transition suffix to match
133
+ # (e.g. "Verification Pending" target, or "Closed" target). The back-write
134
+ # format is `- **<date>** — <from> → <to>`, so we match the `<to>` token.
135
+ log_has_target() {
136
+ local file="$1" target="$2"
137
+ awk -v target="$target" '
138
+ /^## Upstream Lifecycle Updates/ { in_section = 1; next }
139
+ /^## / && in_section { in_section = 0 }
140
+ in_section && index($0, target) > 0 { found = 1 }
141
+ END { exit(found ? 0 : 1) }
142
+ ' "$file"
143
+ }
144
+
145
+ extract_ticket_id() {
146
+ local base
147
+ base="$(basename "$1")"
148
+ echo "P${base%%-*}"
149
+ }
150
+
151
+ # ── Per-ticket scan loop ────────────────────────────────────────────────────
152
+
153
+ SCANNED=0
154
+ CATCHUP_COUNT=0
155
+ SKIP_LOGGED=0
156
+ SKIP_OUT_OF_BAND=0
157
+
158
+ declare -A SEEN_IDS
159
+
160
+ for ticket_file in "${TICKET_FILES[@]}"; do
161
+ ticket_id="$(extract_ticket_id "$ticket_file")"
162
+
163
+ if [ -n "$TICKET_FILTER" ] && [ "$ticket_id" != "$TICKET_FILTER" ]; then
164
+ continue
165
+ fi
166
+
167
+ # Dedup: if the same ID appears via both layouts (mid-migration), the
168
+ # per-state subdir copy wins (same rule as check-upstream-responses.sh).
169
+ if [ -n "${SEEN_IDS[$ticket_id]:-}" ]; then
170
+ if [[ "$ticket_file" != *"/verifying/"* && "$ticket_file" != *"/closed/"* ]]; then
171
+ continue
172
+ fi
173
+ fi
174
+ SEEN_IDS[$ticket_id]="$ticket_file"
175
+
176
+ # Filter to tickets carrying a `## Reported Upstream` section.
177
+ if ! grep -q '^## Reported Upstream' "$ticket_file"; then
178
+ continue
179
+ fi
180
+
181
+ SCANNED=$((SCANNED + 1))
182
+
183
+ # Derive the transition the current suffix implies.
184
+ case "$ticket_file" in
185
+ *.verifying.md|*/verifying/*)
186
+ state="verifying"
187
+ transition="KE->Verifying"
188
+ log_target="Verification Pending" ;;
189
+ *.closed.md|*/closed/*)
190
+ state="closed"
191
+ transition="Verifying->Closed"
192
+ log_target="Closed" ;;
193
+ *)
194
+ continue ;;
195
+ esac
196
+
197
+ upstream_url="$(extract_upstream_url "$ticket_file")"
198
+ disclosure="$(extract_disclosure_path "$ticket_file")"
199
+
200
+ # Out-of-band / non-gh disclosure path, or no actionable URL → SKIP.
201
+ if [ -z "$upstream_url" ] \
202
+ || [[ "$disclosure" == *out-of-band* ]] \
203
+ || [[ "$disclosure" == *mailbox* ]]; then
204
+ printf "SKIP %s %s reason=out-of-band\n" "$ticket_id" "${upstream_url:-none}"
205
+ SKIP_OUT_OF_BAND=$((SKIP_OUT_OF_BAND + 1))
206
+ continue
207
+ fi
208
+
209
+ # Idempotency: the lifecycle log already records this target → SKIP.
210
+ if log_has_target "$ticket_file" "$log_target"; then
211
+ printf "SKIP %s %s reason=already-logged\n" "$ticket_id" "$upstream_url"
212
+ SKIP_LOGGED=$((SKIP_LOGGED + 1))
213
+ continue
214
+ fi
215
+
216
+ printf "CATCHUP %s %s state=%s transition=%s\n" \
217
+ "$ticket_id" "$upstream_url" "$state" "$transition"
218
+ CATCHUP_COUNT=$((CATCHUP_COUNT + 1))
219
+ done
220
+
221
+ printf "SUMMARY scanned=%s catchup=%s skip-logged=%s skip-out-of-band=%s\n" \
222
+ "$SCANNED" "$CATCHUP_COUNT" "$SKIP_LOGGED" "$SKIP_OUT_OF_BAND" >&2
223
+
224
+ exit 0
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Behavioural test for packages/itil/scripts/catchup-scan.sh — the P080
4
+ # Phase 2 `--catchup` worklist scanner. Exercises the script against a
5
+ # synthetic `.verifying.md` / `.closed.md` fixture corpus and asserts on
6
+ # its emitted worklist (stdout) + summary (stderr) — NOT on SKILL.md prose.
7
+ # This is a genuinely behavioural test per ADR-052: it runs the target and
8
+ # asserts on its outputs, covering acceptance criterion 6 (fixture +
9
+ # idempotency assertion).
10
+ #
11
+ # Coverage:
12
+ # - CATCHUP emitted for a post-fix ticket with `## Reported Upstream` and
13
+ # no lifecycle log entry for the target state.
14
+ # - SKIP/already-logged (idempotency) for a ticket whose
15
+ # `## Upstream Lifecycle Updates` log already records the target state.
16
+ # - Silent skip for tickets without a `## Reported Upstream` section.
17
+ # - SKIP/out-of-band for out-of-band / mailbox disclosure paths.
18
+ # - Open / Known-Error / Parked tickets are out of the catchup corpus.
19
+ # - Dual-tolerant flat-layout AND per-state subdir layout (RFC-002).
20
+ # - --ticket restricts the scan; bad args / missing dir error cleanly.
21
+ #
22
+ # @problem P080 (Phase 2 --catchup)
23
+ # @adr ADR-052 (behavioural-tests default)
24
+
25
+ setup() {
26
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
27
+ SCRIPT="$REPO_ROOT/packages/itil/scripts/catchup-scan.sh"
28
+ FIX="$BATS_TEST_TMPDIR/problems"
29
+ mkdir -p "$FIX"
30
+ }
31
+
32
+ # Write a ticket with a `## Reported Upstream` section. Args:
33
+ # $1 = filename (relative to $FIX)
34
+ # $2 = upstream URL (or "" for none)
35
+ # $3 = disclosure path text
36
+ make_reported_ticket() {
37
+ local path="$FIX/$1" url="$2" disclosure="$3"
38
+ mkdir -p "$(dirname "$path")"
39
+ {
40
+ echo "# Problem: fixture"
41
+ echo ""
42
+ echo "**Status**: fixture"
43
+ echo ""
44
+ echo "## Reported Upstream"
45
+ echo ""
46
+ [ -n "$url" ] && echo "- **URL**: $url"
47
+ echo "- **Reported**: 2026-01-01"
48
+ echo "- **Disclosure path**: $disclosure"
49
+ } > "$path"
50
+ }
51
+
52
+ # Append an `## Upstream Lifecycle Updates` log entry recording a target.
53
+ append_lifecycle_log() {
54
+ local path="$FIX/$1" transition="$2"
55
+ {
56
+ echo ""
57
+ echo "## Upstream Lifecycle Updates"
58
+ echo ""
59
+ echo "- **2026-02-02** — $transition"
60
+ echo " - **Disclosure path**: posted-comment"
61
+ } >> "$path"
62
+ }
63
+
64
+ @test "catchup-scan: emits CATCHUP for a closed ticket with Reported Upstream and no lifecycle log" {
65
+ make_reported_ticket "113-foo.closed.md" "https://github.com/o/r/issues/5" "public issue"
66
+ run bash "$SCRIPT" --problems-dir "$FIX"
67
+ [ "$status" -eq 0 ]
68
+ [[ "$output" == *"CATCHUP P113 https://github.com/o/r/issues/5 state=closed transition=Verifying->Closed"* ]]
69
+ }
70
+
71
+ @test "catchup-scan: emits CATCHUP for a verifying ticket with the KE->Verifying transition" {
72
+ make_reported_ticket "090-bar.verifying.md" "https://github.com/o/r/issues/9" "public issue"
73
+ run bash "$SCRIPT" --problems-dir "$FIX"
74
+ [ "$status" -eq 0 ]
75
+ [[ "$output" == *"CATCHUP P090 https://github.com/o/r/issues/9 state=verifying transition=KE->Verifying"* ]]
76
+ }
77
+
78
+ @test "catchup-scan: idempotency — SKIP/already-logged when log records the target state (closed)" {
79
+ make_reported_ticket "113-foo.closed.md" "https://github.com/o/r/issues/5" "public issue"
80
+ append_lifecycle_log "113-foo.closed.md" "Verification Pending → Closed"
81
+ run bash "$SCRIPT" --problems-dir "$FIX"
82
+ [ "$status" -eq 0 ]
83
+ [[ "$output" == *"SKIP P113 https://github.com/o/r/issues/5 reason=already-logged"* ]]
84
+ [[ "$output" != *"CATCHUP P113"* ]]
85
+ }
86
+
87
+ @test "catchup-scan: idempotency — SKIP/already-logged when log records the target state (verifying)" {
88
+ make_reported_ticket "090-bar.verifying.md" "https://github.com/o/r/issues/9" "public issue"
89
+ append_lifecycle_log "090-bar.verifying.md" "Known Error → Verification Pending"
90
+ run bash "$SCRIPT" --problems-dir "$FIX"
91
+ [ "$status" -eq 0 ]
92
+ [[ "$output" == *"SKIP P090 https://github.com/o/r/issues/9 reason=already-logged"* ]]
93
+ [[ "$output" != *"CATCHUP P090"* ]]
94
+ }
95
+
96
+ @test "catchup-scan: idempotency is re-run-safe — a logged closed ticket alongside a fresh one" {
97
+ make_reported_ticket "113-done.closed.md" "https://github.com/o/r/issues/5" "public issue"
98
+ append_lifecycle_log "113-done.closed.md" "Verification Pending → Closed"
99
+ make_reported_ticket "114-fresh.closed.md" "https://github.com/o/r/issues/6" "public issue"
100
+ run bash "$SCRIPT" --problems-dir "$FIX"
101
+ [ "$status" -eq 0 ]
102
+ [[ "$output" == *"SKIP P113 https://github.com/o/r/issues/5 reason=already-logged"* ]]
103
+ [[ "$output" == *"CATCHUP P114 https://github.com/o/r/issues/6"* ]]
104
+ }
105
+
106
+ @test "catchup-scan: silently skips tickets with no Reported Upstream section" {
107
+ mkdir -p "$FIX"
108
+ printf '# Problem\n\n**Status**: Closed\n\nNo upstream link here.\n' > "$FIX/200-nolink.closed.md"
109
+ run bash "$SCRIPT" --problems-dir "$FIX"
110
+ [ "$status" -eq 0 ]
111
+ [[ "$output" != *"P200"* ]]
112
+ }
113
+
114
+ @test "catchup-scan: SKIP/out-of-band for a mailbox / out-of-band disclosure path" {
115
+ make_reported_ticket "150-sec.closed.md" "" "drafted-and-saved (mailbox / out-of-band)"
116
+ run bash "$SCRIPT" --problems-dir "$FIX"
117
+ [ "$status" -eq 0 ]
118
+ [[ "$output" == *"SKIP P150"* ]]
119
+ [[ "$output" == *"reason=out-of-band"* ]]
120
+ }
121
+
122
+ @test "catchup-scan: Open / Known-Error / Parked tickets are out of the catchup corpus" {
123
+ make_reported_ticket "300-open.open.md" "https://github.com/o/r/issues/3" "public issue"
124
+ make_reported_ticket "301-ke.known-error.md" "https://github.com/o/r/issues/4" "public issue"
125
+ make_reported_ticket "302-park.parked.md" "https://github.com/o/r/issues/7" "public issue"
126
+ run bash "$SCRIPT" --problems-dir "$FIX"
127
+ [ "$status" -eq 0 ]
128
+ [[ "$output" != *"P300"* ]]
129
+ [[ "$output" != *"P301"* ]]
130
+ [[ "$output" != *"P302"* ]]
131
+ }
132
+
133
+ @test "catchup-scan: dual-tolerant — per-state subdir layout (RFC-002) is scanned" {
134
+ make_reported_ticket "closed/113-sub.md" "https://github.com/o/r/issues/8" "public issue"
135
+ run bash "$SCRIPT" --problems-dir "$FIX"
136
+ [ "$status" -eq 0 ]
137
+ [[ "$output" == *"CATCHUP P113 https://github.com/o/r/issues/8 state=closed transition=Verifying->Closed"* ]]
138
+ }
139
+
140
+ @test "catchup-scan: --ticket restricts the scan to one ticket" {
141
+ make_reported_ticket "113-foo.closed.md" "https://github.com/o/r/issues/5" "public issue"
142
+ make_reported_ticket "114-bar.closed.md" "https://github.com/o/r/issues/6" "public issue"
143
+ run bash "$SCRIPT" --problems-dir "$FIX" --ticket P114
144
+ [ "$status" -eq 0 ]
145
+ [[ "$output" == *"CATCHUP P114"* ]]
146
+ [[ "$output" != *"P113"* ]]
147
+ }
148
+
149
+ @test "catchup-scan: SUMMARY line reports counts on stderr" {
150
+ make_reported_ticket "113-foo.closed.md" "https://github.com/o/r/issues/5" "public issue"
151
+ make_reported_ticket "114-bar.closed.md" "https://github.com/o/r/issues/6" "public issue"
152
+ append_lifecycle_log "114-bar.closed.md" "Verification Pending → Closed"
153
+ run bash -c "bash '$SCRIPT' --problems-dir '$FIX' 2>&1 1>/dev/null"
154
+ [ "$status" -eq 0 ]
155
+ [[ "$output" == *"SUMMARY scanned=2 catchup=1 skip-logged=1 skip-out-of-band=0"* ]]
156
+ }
157
+
158
+ @test "catchup-scan: missing problems-dir exits 1 with error" {
159
+ run bash "$SCRIPT" --problems-dir "$BATS_TEST_TMPDIR/does-not-exist"
160
+ [ "$status" -eq 1 ]
161
+ [[ "$output" == *"ERROR"* ]]
162
+ }
163
+
164
+ @test "catchup-scan: unknown argument exits 1" {
165
+ run bash "$SCRIPT" --bogus
166
+ [ "$status" -eq 1 ]
167
+ [[ "$output" == *"ERROR"* ]]
168
+ }
169
+
170
+ @test "catchup-scan: --help prints usage and exits 0" {
171
+ run bash "$SCRIPT" --help
172
+ [ "$status" -eq 0 ]
173
+ [[ "$output" == *"Usage:"* ]]
174
+ [[ "$output" == *"--catchup"* ]] || [[ "$output" == *"catchup-scan"* ]]
175
+ }
@@ -10,7 +10,8 @@ allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Skill, Agen
10
10
  @jtbd JTBD-201 (Restore Service Fast with an Audit Trail — symmetric local/upstream audit trail)
11
11
  @jtbd JTBD-101 (Extend the Suite with Clear Patterns — downstream adopters inherit bidirectional contract)
12
12
  @problem P080
13
- @adr ADR-024 (amended P080 — bidirectional lifecycle updates)
13
+ @adr ADR-024 (amended P080 — bidirectional lifecycle updates; Phase 2 — --catchup migration mode + idempotency)
14
+ @adr ADR-049 (catchup worklist scanner invoked via wr-itil-catchup-scan bin shim)
14
15
  @adr ADR-028 (voice-tone gate on `gh issue comment` / `gh issue close`)
15
16
  @adr ADR-013 (Rule 1 AskUserQuestion; Rule 6 AFK fail-safe)
16
17
  @adr ADR-014 (single-commit grain — transition + back-write + upstream comment)
@@ -31,12 +32,14 @@ This skill implements the bidirectional extension to ADR-024's outbound contract
31
32
  ## Invocation
32
33
 
33
34
  ```
34
- /wr-itil:update-upstream <NNN>
35
+ /wr-itil:update-upstream <NNN> # single-ticket lifecycle update
36
+ /wr-itil:update-upstream --catchup # batch-retroactive migration (Phase 2)
35
37
  ```
36
38
 
37
39
  - `<NNN>`: the three-digit local ticket ID (e.g. `080`). The ticket file is discovered via the same dual-tolerant lookup as [`/wr-itil:report-upstream`](../report-upstream/SKILL.md) (flat layout + per-state subdir per RFC-002 migration window).
40
+ - `--catchup`: one-shot batch-retroactive migration mode (P080 Phase 2 — see [§ Catchup migration mode](#catchup-migration-mode-phase-2)). Walks the existing `.verifying.md` + `.closed.md` corpus and posts the lifecycle update each ticket should have received but did not (because it was reported upstream / transitioned before the per-ticket auto-update path shipped). Idempotent — already-updated tickets are skipped.
38
41
 
39
- The skill is typically invoked from `/wr-itil:transition-problem` Step 7's advisory subsection when the transitioning ticket carries a `## Reported Upstream` section (per ADR-024 Confirmation criterion 3a — the back-write that `/wr-itil:report-upstream` Step 7 writes). User-initiated invocation is also supported for retroactive catch-up on existing `.verifying.md` / `.closed.md` tickets that pre-date this skill landing.
42
+ The single-ticket form is typically invoked from `/wr-itil:transition-problem` Step 7's advisory subsection when the transitioning ticket carries a `## Reported Upstream` section (per ADR-024 Confirmation criterion 3a — the back-write that `/wr-itil:report-upstream` Step 7 writes). User-initiated single-ticket invocation is also supported. The `--catchup` form is user-initiated only (a deliberate one-shot migration, never auto-fired from a transition).
40
43
 
41
44
  ## Scope
42
45
 
@@ -48,10 +51,10 @@ The skill is typically invoked from `/wr-itil:transition-problem` Step 7's advis
48
51
  - Within appetite → post via `gh issue comment <n>`; on Verifying→Closed also run `gh issue close <n>`.
49
52
  - Above appetite → AskUserQuestion (interactive) / queue `outstanding_questions` (AFK, per P352 queue-and-continue).
50
53
  - Back-write a `## Upstream Lifecycle Updates` log entry to the local ticket recording the transition, the matched URL, the posted comment URL, and the disclosure path.
54
+ - **Historical catch-up migration (`--catchup`, P080 Phase 2)** — one-shot retroactive scan of the existing `.verifying.md` + `.closed.md` corpus; posts the lifecycle update each linked-upstream ticket should already carry. Idempotent — re-running is safe. See [§ Catchup migration mode](#catchup-migration-mode-phase-2).
51
55
 
52
56
  **Out of scope:**
53
57
  - Initial upstream filing — that's `/wr-itil:report-upstream`.
54
- - Historical catch-up migration (one-shot retroactive scan of all closed/verifying tickets with `## Reported Upstream`) — that's a separate orchestration concern; per-ticket invocation suffices for the Phase 1 contract. A future amendment may add a `--catchup` mode.
55
58
  - Cross-tracker propagation (linking the upstream update back into a different upstream's parallel issue) — out of scope; one local ticket → N upstream URLs is supported, but each URL update is independent.
56
59
 
57
60
  ## Step-0 deferral (ADR-027)
@@ -295,6 +298,57 @@ When invoked user-initiatedly (no transition in this session, e.g. retroactive c
295
298
 
296
299
  If the cumulative pipeline risk lands above appetite and `AskUserQuestion` is unavailable, apply the [ADR-013 Rule 6](../../../docs/decisions/013-structured-user-interaction-for-governance-decisions.proposed.md) non-interactive fail-safe: skip the commit and report the uncommitted state. Do NOT auto-commit above appetite without the user's call.
297
300
 
301
+ ## Catchup migration mode (Phase 2)
302
+
303
+ `/wr-itil:update-upstream --catchup` runs a one-shot batch-retroactive migration. It exists because the per-ticket auto-update path (Phase 1) only fires on transitions that happen *after* it shipped — every ticket reported upstream and transitioned *before* Phase 1 silently missed its lifecycle update, leaving upstream issues looking abandoned. Catchup back-fills that history. Authority: [ADR-024](../../../docs/decisions/024-cross-project-problem-reporting-contract.proposed.md) amendment (P080 Phase 2).
304
+
305
+ This mode is **user-initiated only** — it is never auto-fired from a transition. It is a deliberate corpus-wide migration the maintainer runs once (or re-runs safely, thanks to idempotency).
306
+
307
+ ### C1. Build the worklist (read-only scan)
308
+
309
+ Invoke the worklist scanner via its [ADR-049](../../../docs/decisions/049-plugin-script-resolution-via-bin-on-path.proposed.md) `$PATH` shim — **never** via a repo-relative `packages/...` path (that path does not resolve in adopter trees):
310
+
311
+ ```bash
312
+ wr-itil-catchup-scan
313
+ ```
314
+
315
+ The scanner (`packages/itil/scripts/catchup-scan.sh`, dispatched by the `wr-itil-catchup-scan` bin shim) is **read-only and local** — it makes no `gh` calls and writes nothing. It walks the `.verifying.md` + `.closed.md` corpus (dual-tolerant flat + per-state subdir per RFC-002), filters to tickets carrying a `## Reported Upstream` section, applies marker-based idempotency, and prints a worklist:
316
+
317
+ ```
318
+ CATCHUP P<NNN> <url> state=<verifying|closed> transition=<KE->Verifying|Verifying->Closed>
319
+ SKIP P<NNN> <url> reason=already-logged
320
+ SKIP P<NNN> <url> reason=out-of-band
321
+ ```
322
+
323
+ plus a `SUMMARY scanned=… catchup=… skip-logged=… skip-out-of-band=…` line on stderr. Tickets with no `## Reported Upstream` section produce no line (the common case). Open / Known-Error / Parked tickets are out of the catchup corpus — only post-fix states (Verifying, Closed) carry the lifecycle updates a reporter most wants retroactively.
324
+
325
+ ### C2. Idempotency contract
326
+
327
+ Catchup is **idempotent** (P080 Phase 2 acceptance criterion 3). The scanner skips a ticket whose `## Upstream Lifecycle Updates` log already records an entry for the current target state:
328
+
329
+ - `.verifying.md` → already-logged iff the log contains a `→ Verification Pending` entry.
330
+ - `.closed.md` → already-logged iff the log contains a `→ Closed` entry.
331
+
332
+ The append-only log (written by Step 6 on every post) is the source of truth — the same marker the per-ticket path writes. Re-running `--catchup` therefore never double-posts. As defence-in-depth, before posting each `CATCHUP` entry the SKILL MAY also scan the upstream issue for a prior `Update from …` comment authored by the posting account (`gh issue view <n> --json comments`); if one already matches the target transition, treat it as already-logged, back-write the log entry to reconcile, and skip the post. The body-marker check is primary (cheap, no `gh` round-trip); the comment scan is the belt-and-braces fallback for tickets whose log predates Phase 1's back-write.
333
+
334
+ ### C3. Process each CATCHUP entry
335
+
336
+ For each `CATCHUP` line, run the **existing per-ticket flow** (Steps 4–6) against that ticket ID:
337
+
338
+ 1. Draft the transition template (Step 4) for the entry's transition (`KE->Verifying` → Known Error → Verification Pending template; `Verifying->Closed` → Verification Pending → Closed template, which also runs `gh issue close`).
339
+ 2. Compose through the external-comms + voice-tone gates (Step 5) — **identical** dual-gate composition as the per-ticket path. Above-appetite handling (Step 5c) is unchanged: silent risk-reduce + re-score, then queue to `## Queued Upstream Update` + `outstanding_questions` (category `deviation-approval`) per P352 if still above. Catchup does NOT bypass the gates.
340
+ 3. Post within appetite (Step 5b final) and back-write the `## Upstream Lifecycle Updates` log (Step 6).
341
+
342
+ Process entries one at a time so a single above-appetite entry queues only itself; the rest proceed. There is no batch-cap on the number of catchup posts — the gate composition is the rate-limit, and the corpus is bounded (one pass over local tickets).
343
+
344
+ ### C4. Commit per ADR-014
345
+
346
+ The catchup migration is user-initiated, so it owns its commit per the Step 7 user-initiated path: stage every touched ticket's back-write (and any `## Queued Upstream Update` appendage), score commit/push/release risk via `wr-risk-scorer:pipeline`, and commit once covering the whole pass — `docs(problems): upstream lifecycle catchup migration — <N> tickets (P080 Phase 2)`. Above-appetite-and-no-AskUserQuestion → ADR-013 Rule 6 fail-safe (report the uncommitted state, do not auto-commit).
347
+
348
+ ### C5. Verification
349
+
350
+ The live-upstream end-to-end confirmation (P080 acceptance criterion 7 — a catchup comment actually lands on a real upstream issue) is the overall P080 verification step. Running `--catchup` against the real corpus (e.g. P113's `https://github.com/anthropics/claude-code/issues/52831`) and confirming the comment posts is what closes P080 to Verifying once a fresh release ships the mode.
351
+
298
352
  ## AFK behaviour summary
299
353
 
300
354
  Four distinct AFK branches. Per the [ADR-024](../../../docs/decisions/024-cross-project-problem-reporting-contract.proposed.md) amendment (P080) — same composition shape as the post-P270 initial-filing path — ALL pre-post branches route through the `wr-risk-scorer:external-comms` + `wr-voice-tone:external-comms` gates. Below-appetite proceeds; above-appetite silent risk-reduces + re-scores; if still above, queues per P352 queue-and-continue without halting the loop.
@@ -322,7 +376,9 @@ The skill's no-op exit (Step 1) means firing the trigger unconditionally on ever
322
376
 
323
377
  ## References
324
378
 
325
- - [ADR-024](../../../docs/decisions/024-cross-project-problem-reporting-contract.proposed.md) — primary contract this skill extends. The P080 amendment in `## Amendments` authorises the bidirectional lifecycle-update sibling skill, the transition-template shape, and the external-comms + voice-tone gate composition.
379
+ - [ADR-024](../../../docs/decisions/024-cross-project-problem-reporting-contract.proposed.md) — primary contract this skill extends. The P080 amendment in `## Amendments` authorises the bidirectional lifecycle-update sibling skill, the transition-template shape, and the external-comms + voice-tone gate composition; the **P080 Phase 2 amendment** authorises the `--catchup` migration mode, the read-only worklist scanner, and the marker-based idempotency contract.
380
+ - [ADR-049](../../../docs/decisions/049-plugin-script-resolution-via-bin-on-path.proposed.md) — the catchup worklist scanner is invoked as `wr-itil-catchup-scan` ($PATH shim), never via a repo-relative `packages/...` path.
381
+ - [`packages/itil/scripts/catchup-scan.sh`](../../scripts/catchup-scan.sh) — read-only local worklist scanner for `--catchup`; behavioural bats at `packages/itil/scripts/test/catchup-scan.bats`.
326
382
  - [ADR-028](../../../docs/decisions/028-voice-tone-gate-external-comms.proposed.md) — voice-tone gate on `gh issue comment` and `gh issue close`.
327
383
  - [ADR-013](../../../docs/decisions/013-structured-user-interaction-for-governance-decisions.proposed.md) — interaction policy; Rule 1 governs the interactive above-appetite path; Rule 6 governs the AFK fail-safe.
328
384
  - [ADR-014](../../../docs/decisions/014-governance-skills-commit-their-own-work.proposed.md) — single-commit grain for transition + back-write + upstream post.