@windyroad/itil 0.26.0 → 0.27.0

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-itil",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
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,116 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/migrate-problems-add-type.sh
3
+ #
4
+ # One-shot bulk migration: ensure every `<problems-dir>/<NNN>-*.<status>.md`
5
+ # carries a `**Type**: technical` body field per ADR-060 Phase 1 item 8b.
6
+ #
7
+ # Default `**Type**: technical` per ADR-060 line 92 (existing tickets
8
+ # bulk-migrate to default; per-ticket judgement comes later via
9
+ # capture-problem AskUserQuestion in item 8c — out of scope here).
10
+ #
11
+ # Usage:
12
+ # migrate-problems-add-type.sh [--apply] [<problems-dir>]
13
+ #
14
+ # Default <problems-dir> is ./docs/problems.
15
+ #
16
+ # Modes:
17
+ # diagnose (default): read-only. Lists each ticket needing migration on
18
+ # stdout (one per line, basename only). Exit 0 = clean, 1 = drift.
19
+ # --apply: writes `**Type**: technical` after the LAST present body
20
+ # field marker in {Status, Reported, Priority, Effort, WSJF}.
21
+ # Idempotent — re-running with Type already present is a no-op.
22
+ #
23
+ # Exit codes:
24
+ # 0 = clean (diagnose: no migration needed; apply: completed)
25
+ # 1 = drift (diagnose only; tickets needing migration listed)
26
+ # 2 = parse error
27
+ #
28
+ # Tickets with NO recognisable header field markers are skipped with a
29
+ # `SKIP <basename>` warning on stderr — these are typically malformed
30
+ # scaffold leftovers and should not be auto-migrated.
31
+ #
32
+ # @problem P170 (Slice 4 B7.T2 / item 8b)
33
+ # @adr ADR-060 (type-tag schema; default `technical`; spec line 91 amended
34
+ # 2026-05-06: header field block in body, NOT YAML frontmatter)
35
+ # @adr ADR-014 (one bounded sub-task per script)
36
+
37
+ set -uo pipefail
38
+
39
+ APPLY=0
40
+ PROBLEMS_DIR=""
41
+
42
+ for arg in "$@"; do
43
+ case "$arg" in
44
+ --apply) APPLY=1 ;;
45
+ -*) echo "PARSE_ERROR: unknown flag: $arg" >&2; exit 2 ;;
46
+ *)
47
+ if [ -z "$PROBLEMS_DIR" ]; then
48
+ PROBLEMS_DIR="$arg"
49
+ else
50
+ echo "PARSE_ERROR: multiple positional args: $arg" >&2
51
+ exit 2
52
+ fi
53
+ ;;
54
+ esac
55
+ done
56
+
57
+ PROBLEMS_DIR="${PROBLEMS_DIR:-docs/problems}"
58
+
59
+ if [ ! -d "$PROBLEMS_DIR" ]; then
60
+ echo "PARSE_ERROR: problems-dir not found: $PROBLEMS_DIR" >&2
61
+ exit 2
62
+ fi
63
+
64
+ drift=0
65
+ shopt -s nullglob
66
+ for f in "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.open.md \
67
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.known-error.md \
68
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.verifying.md \
69
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.parked.md \
70
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.closed.md; do
71
+ base="$(basename "$f")"
72
+
73
+ # Idempotency check: any line matching `**Type**: <value>` in the
74
+ # first 30 lines counts as already-migrated.
75
+ if head -n 30 "$f" | grep -q '^\*\*Type\*\*:'; then
76
+ continue
77
+ fi
78
+
79
+ # Find the LAST body-field marker line (Status / Reported / Priority /
80
+ # Effort / WSJF) in the first 30 lines. Insertion anchor.
81
+ anchor=$(head -n 30 "$f" \
82
+ | grep -n -E '^\*\*(Status|Reported|Priority|Effort|WSJF)\*\*:' \
83
+ | tail -1 | cut -d: -f1)
84
+
85
+ if [ -z "$anchor" ]; then
86
+ echo "SKIP $base (no recognisable header field markers)" >&2
87
+ continue
88
+ fi
89
+
90
+ drift=1
91
+
92
+ if [ "$APPLY" -eq 0 ]; then
93
+ echo "$base"
94
+ continue
95
+ fi
96
+
97
+ # Apply: insert `**Type**: technical` after line $anchor.
98
+ # Use a temp file for atomic replace.
99
+ tmp=$(mktemp)
100
+ awk -v anchor="$anchor" '
101
+ NR == anchor { print; print "**Type**: technical"; next }
102
+ { print }
103
+ ' "$f" > "$tmp"
104
+ mv "$tmp" "$f"
105
+ done
106
+ shopt -u nullglob
107
+
108
+ if [ "$APPLY" -eq 1 ]; then
109
+ exit 0
110
+ fi
111
+
112
+ if [ "$drift" -eq 1 ]; then
113
+ exit 1
114
+ fi
115
+
116
+ exit 0
@@ -2,15 +2,22 @@
2
2
  # packages/itil/scripts/reconcile-readme.sh
3
3
  #
4
4
  # Diagnose-only drift detector for docs/problems/README.md vs filesystem
5
- # truth. Reads <problems-dir>/<NNN>-*.<status>.md, parses the README's
6
- # WSJF Rankings + Verification Queue + Closed tables, and reports each
7
- # disagreement.
5
+ # truth. Reads ticket files from BOTH the flat layout
6
+ # `<problems-dir>/<NNN>-*.<status>.md` AND the per-state subdir layout
7
+ # `<problems-dir>/<status>/<NNN>-*.md` (RFC-002 dual-tolerant migration
8
+ # window), parses the README's WSJF Rankings + Verification Queue +
9
+ # Closed tables, and reports each disagreement.
8
10
  #
9
11
  # Usage:
10
12
  # reconcile-readme.sh [<problems-dir>]
11
13
  #
12
14
  # Default <problems-dir> is ./docs/problems.
13
15
  #
16
+ # Dual-layout precedence: when the same ID appears in both layout-halves
17
+ # (transient mid-migration race between `git mv` and README refresh),
18
+ # the per-state subdir wins — ADR-031 §"Authoritative state signal"
19
+ # treats subdirectory as the post-migration ground truth.
20
+ #
14
21
  # Exit codes:
15
22
  # 0 = clean (README matches filesystem)
16
23
  # 1 = drift detected (structured diff to stdout)
@@ -28,8 +35,10 @@
28
35
  # only job is to report ground truth.
29
36
  #
30
37
  # @problem P118
38
+ # @problem P170 (RFC-002 — dual-tolerant migration window)
31
39
  # @adr ADR-014 (Reconciliation as preflight robustness layer)
32
40
  # @adr ADR-022 (Verification Pending lifecycle excludes from WSJF Rankings)
41
+ # @adr ADR-031 (Per-state subdir is post-migration authoritative state signal)
33
42
  # @adr ADR-038 (Progressive disclosure — per-row byte budget)
34
43
 
35
44
  set -uo pipefail
@@ -50,9 +59,15 @@ if ! grep -q '^## WSJF Rankings' "$README"; then
50
59
  fi
51
60
 
52
61
  # ── Build filesystem truth: ID → status ─────────────────────────────────────
62
+ #
63
+ # RFC-002 dual-tolerant enumeration: walk BOTH the flat layout and the
64
+ # per-state subdir layout. Per-state subdir wins on collision (mid-
65
+ # migration race; per-state is the migration target per ADR-031).
53
66
 
54
67
  declare -A FS_STATUS
55
68
  shopt -s nullglob
69
+ # Flat layout: docs/problems/<NNN>-<title>.<state>.md
70
+ # Status classified from filename suffix.
56
71
  for f in "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.open.md \
57
72
  "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.known-error.md \
58
73
  "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.verifying.md \
@@ -74,6 +89,18 @@ for f in "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.open.md \
74
89
  esac
75
90
  FS_STATUS["$id"]="$ticket_status"
76
91
  done
92
+ # Per-state subdir layout: docs/problems/<state>/<NNN>-<title>.md
93
+ # Status derived from parent directory name (the subdirectory IS the
94
+ # state signal post-migration). Writes after the flat loop so per-state
95
+ # wins on cross-layout ID collision (ADR-031 authoritative state).
96
+ for ticket_status in open known-error verifying closed parked; do
97
+ for f in "$PROBLEMS_DIR"/"$ticket_status"/[0-9][0-9][0-9]-*.md; do
98
+ base="$(basename "$f")"
99
+ num="${base%%-*}"
100
+ id="P${num}"
101
+ FS_STATUS["$id"]="$ticket_status"
102
+ done
103
+ done
77
104
  shopt -u nullglob
78
105
 
79
106
  # ── Parse README sections into ID buckets ───────────────────────────────────
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @rfc RFC-002 T2 — Dual-tolerant SKILL.md glob updates
4
+ # @adr ADR-031 (Problem-ticket directory layout)
5
+ # @adr ADR-051 (load-bearing-from-the-start — the dual-tolerant glob
6
+ # contract ships with a behavioural enforcement test, not later by
7
+ # graceful drift)
8
+ # @adr ADR-052 (behavioural-bats default — this test exercises the
9
+ # glob shape against synthetic fixtures and asserts observable
10
+ # enumeration behaviour; it does NOT structurally grep SKILL.md
11
+ # prose for the dual-pattern string, which would be P081-class
12
+ # structural-test-disguised-as-behavioural)
13
+ # @adr ADR-014 (single-purpose: one mechanical contract — dual-glob
14
+ # enumeration parity across both layouts)
15
+ # @problem P069 (driving — flat layout unskimmable; the migration this
16
+ # contract guards is the relief)
17
+ # @problem P081 (no structural-grep on SKILL.md content — this test
18
+ # is the behavioural alternative)
19
+ # @jtbd JTBD-001 (extended scope — multi-commit RFC-grain coordinated
20
+ # change; the test is the cross-skill invariant the SKILL.md edits
21
+ # share)
22
+ # @jtbd JTBD-006 (work-backlog-AFK — dual-tolerant globs preserve
23
+ # AFK-loop continuity across the migration window; without this
24
+ # contract, mid-migration loop iterations silently miss tickets in
25
+ # the un-migrated layout)
26
+ #
27
+ # Contract: SKILL.md enumeration globs of `docs/problems/<state>/...`
28
+ # during the RFC-002 migration window MUST match BOTH the flat layout
29
+ # (`docs/problems/<NNN>-<title>.<state>.md`) AND the per-state subdir
30
+ # layout (`docs/problems/<state>/<NNN>-<title>.md`). The dual-tolerant
31
+ # pattern shape is:
32
+ #
33
+ # ls docs/problems/*.<state>.md docs/problems/<state>/*.md 2>/dev/null
34
+ #
35
+ # (with `2>/dev/null` swallowing the no-match error from whichever
36
+ # half of the OR currently has zero matches.)
37
+ #
38
+ # This test exercises the canonical dual-tolerant pattern shapes (state-
39
+ # filtered enumeration, ID-anchored lookup, all-state-all-tickets) on
40
+ # synthetic fixtures of three shapes (flat-only, per-state-only, mixed).
41
+ # Each cross-product asserts non-empty enumeration of every present
42
+ # ticket and zero false-positive enumeration of absent tickets.
43
+ #
44
+ # CONTRACT NOTE: when one half of the dual-glob has zero matches in the
45
+ # current fixture (single-layout fixtures), `ls X Y 2>/dev/null` exits
46
+ # nonzero — the unmatched literal pathname propagates to ls's argv and
47
+ # `2>/dev/null` only suppresses the stderr noise, not the exit code.
48
+ # This is CORRECT behaviour — SKILL.md call sites MUST treat STDOUT
49
+ # emptiness as the canonical "no tickets" signal, NOT exit code zero.
50
+ # Test assertions therefore probe stdout content, not `$status`, except
51
+ # in the empty-fixture and missing-ID cases where nonzero exit is the
52
+ # intended contract.
53
+ #
54
+ # T6 (post-T5 verification) drops the flat-layout half. This test
55
+ # updates at T6 to single-pattern, NOT removed — the contract becomes
56
+ # "per-state layout enumerates correctly" but remains behavioural.
57
+
58
+ setup() {
59
+ REPO_ROOT="$(mktemp -d)"
60
+ cd "$REPO_ROOT"
61
+ mkdir -p docs/problems
62
+ }
63
+
64
+ teardown() {
65
+ cd /
66
+ rm -rf "$REPO_ROOT"
67
+ }
68
+
69
+ # ── fixture builders ─────────────────────────────────────────────────────────
70
+
71
+ build_flat_layout() {
72
+ cat > docs/problems/100-foo.open.md <<'EOF'
73
+ # Problem 100: Foo
74
+ **Status**: Open
75
+ EOF
76
+ cat > docs/problems/101-bar.known-error.md <<'EOF'
77
+ # Problem 101: Bar
78
+ **Status**: Known Error
79
+ EOF
80
+ cat > docs/problems/102-baz.verifying.md <<'EOF'
81
+ # Problem 102: Baz
82
+ **Status**: Verification Pending
83
+ EOF
84
+ cat > docs/problems/103-qux.parked.md <<'EOF'
85
+ # Problem 103: Qux
86
+ **Status**: Parked
87
+ EOF
88
+ cat > docs/problems/104-quux.closed.md <<'EOF'
89
+ # Problem 104: Quux
90
+ **Status**: Closed
91
+ EOF
92
+ }
93
+
94
+ build_per_state_layout() {
95
+ mkdir -p docs/problems/open docs/problems/known-error docs/problems/verifying docs/problems/parked docs/problems/closed
96
+ cat > docs/problems/open/200-foo2.md <<'EOF'
97
+ # Problem 200: Foo2
98
+ **Status**: Open
99
+ EOF
100
+ cat > docs/problems/known-error/201-bar2.md <<'EOF'
101
+ # Problem 201: Bar2
102
+ **Status**: Known Error
103
+ EOF
104
+ cat > docs/problems/verifying/202-baz2.md <<'EOF'
105
+ # Problem 202: Baz2
106
+ **Status**: Verification Pending
107
+ EOF
108
+ cat > docs/problems/parked/203-qux2.md <<'EOF'
109
+ # Problem 203: Qux2
110
+ **Status**: Parked
111
+ EOF
112
+ cat > docs/problems/closed/204-quux2.md <<'EOF'
113
+ # Problem 204: Quux2
114
+ **Status**: Closed
115
+ EOF
116
+ }
117
+
118
+ build_mixed_layout() {
119
+ build_flat_layout
120
+ build_per_state_layout
121
+ }
122
+
123
+ # ── Pattern A: state-filtered enumeration ────────────────────────────────────
124
+
125
+ @test "dual-tolerant state-filtered glob: flat-only fixture enumerates open ticket" {
126
+ build_flat_layout
127
+ run bash -c 'ls docs/problems/*.open.md docs/problems/open/*.md 2>/dev/null'
128
+ # ls exits nonzero because the per-state half has no match — stdout
129
+ # content is the canonical signal, not exit code.
130
+ [[ "$output" == *"100-foo.open.md"* ]]
131
+ }
132
+
133
+ @test "dual-tolerant state-filtered glob: per-state-only fixture enumerates open ticket" {
134
+ build_per_state_layout
135
+ run bash -c 'ls docs/problems/*.open.md docs/problems/open/*.md 2>/dev/null'
136
+ [[ "$output" == *"open/200-foo2.md"* ]]
137
+ }
138
+
139
+ @test "dual-tolerant state-filtered glob: mixed fixture enumerates BOTH layouts simultaneously" {
140
+ build_mixed_layout
141
+ run bash -c 'ls docs/problems/*.open.md docs/problems/open/*.md 2>/dev/null'
142
+ [ "$status" -eq 0 ]
143
+ [[ "$output" == *"100-foo.open.md"* ]]
144
+ [[ "$output" == *"open/200-foo2.md"* ]]
145
+ }
146
+
147
+ @test "dual-tolerant state-filtered glob: mixed fixture, known-error" {
148
+ build_mixed_layout
149
+ run bash -c 'ls docs/problems/*.known-error.md docs/problems/known-error/*.md 2>/dev/null'
150
+ [ "$status" -eq 0 ]
151
+ [[ "$output" == *"101-bar.known-error.md"* ]]
152
+ [[ "$output" == *"known-error/201-bar2.md"* ]]
153
+ }
154
+
155
+ @test "dual-tolerant state-filtered glob: mixed fixture, verifying" {
156
+ build_mixed_layout
157
+ run bash -c 'ls docs/problems/*.verifying.md docs/problems/verifying/*.md 2>/dev/null'
158
+ [ "$status" -eq 0 ]
159
+ [[ "$output" == *"102-baz.verifying.md"* ]]
160
+ [[ "$output" == *"verifying/202-baz2.md"* ]]
161
+ }
162
+
163
+ @test "dual-tolerant state-filtered glob: mixed fixture, parked" {
164
+ build_mixed_layout
165
+ run bash -c 'ls docs/problems/*.parked.md docs/problems/parked/*.md 2>/dev/null'
166
+ [ "$status" -eq 0 ]
167
+ [[ "$output" == *"103-qux.parked.md"* ]]
168
+ [[ "$output" == *"parked/203-qux2.md"* ]]
169
+ }
170
+
171
+ @test "dual-tolerant state-filtered glob: state filter excludes other-state tickets in flat layout" {
172
+ build_flat_layout
173
+ run bash -c 'ls docs/problems/*.open.md docs/problems/open/*.md 2>/dev/null'
174
+ [[ "$output" != *"known-error"* ]]
175
+ [[ "$output" != *"verifying"* ]]
176
+ [[ "$output" != *"parked"* ]]
177
+ [[ "$output" != *"closed"* ]]
178
+ }
179
+
180
+ @test "dual-tolerant state-filtered glob: state filter excludes other-state subdirs in per-state layout" {
181
+ build_per_state_layout
182
+ run bash -c 'ls docs/problems/*.open.md docs/problems/open/*.md 2>/dev/null'
183
+ # No known-error/, verifying/, parked/, closed/ subdir contents leak into open enumeration.
184
+ [[ "$output" != *"201-bar2.md"* ]]
185
+ [[ "$output" != *"202-baz2.md"* ]]
186
+ [[ "$output" != *"203-qux2.md"* ]]
187
+ [[ "$output" != *"204-quux2.md"* ]]
188
+ }
189
+
190
+ # ── Pattern B: ID-anchored lookup (any state) ────────────────────────────────
191
+
192
+ @test "dual-tolerant ID-anchored glob: flat-only fixture finds ticket by ID" {
193
+ build_flat_layout
194
+ run bash -c 'ls docs/problems/100-*.md docs/problems/*/100-*.md 2>/dev/null'
195
+ # Single-layout fixture: ls exits nonzero on the unmatched half;
196
+ # stdout content is the contract signal.
197
+ [[ "$output" == *"100-foo.open.md"* ]]
198
+ }
199
+
200
+ @test "dual-tolerant ID-anchored glob: per-state-only fixture finds ticket by ID" {
201
+ build_per_state_layout
202
+ run bash -c 'ls docs/problems/200-*.md docs/problems/*/200-*.md 2>/dev/null'
203
+ [[ "$output" == *"open/200-foo2.md"* ]]
204
+ }
205
+
206
+ @test "dual-tolerant ID-anchored glob: mixed fixture finds tickets across both layouts" {
207
+ build_mixed_layout
208
+ run bash -c 'ls docs/problems/100-*.md docs/problems/*/100-*.md 2>/dev/null'
209
+ [[ "$output" == *"100-foo.open.md"* ]]
210
+ run bash -c 'ls docs/problems/200-*.md docs/problems/*/200-*.md 2>/dev/null'
211
+ [[ "$output" == *"open/200-foo2.md"* ]]
212
+ }
213
+
214
+ @test "dual-tolerant ID-anchored glob: missing ID returns empty (status nonzero from ls)" {
215
+ build_flat_layout
216
+ set +e
217
+ result=$(ls docs/problems/999-*.md docs/problems/*/999-*.md 2>/dev/null)
218
+ rc=$?
219
+ set -e
220
+ [ -z "$result" ]
221
+ [ "$rc" -ne 0 ]
222
+ }
223
+
224
+ # ── Pattern C: all-states-all-tickets (next-ID compute, count) ───────────────
225
+
226
+ @test "dual-tolerant all-tickets glob: flat-only enumerates every ticket regardless of state" {
227
+ build_flat_layout
228
+ run bash -c 'ls docs/problems/*.md docs/problems/*/*.md 2>/dev/null'
229
+ # Single-layout fixture: stdout is contract signal, not $status.
230
+ [[ "$output" == *"100-foo.open.md"* ]]
231
+ [[ "$output" == *"101-bar.known-error.md"* ]]
232
+ [[ "$output" == *"102-baz.verifying.md"* ]]
233
+ [[ "$output" == *"103-qux.parked.md"* ]]
234
+ [[ "$output" == *"104-quux.closed.md"* ]]
235
+ }
236
+
237
+ @test "dual-tolerant all-tickets glob: per-state-only enumerates every ticket regardless of state" {
238
+ build_per_state_layout
239
+ run bash -c 'ls docs/problems/*.md docs/problems/*/*.md 2>/dev/null'
240
+ [[ "$output" == *"open/200-foo2.md"* ]]
241
+ [[ "$output" == *"known-error/201-bar2.md"* ]]
242
+ [[ "$output" == *"verifying/202-baz2.md"* ]]
243
+ [[ "$output" == *"parked/203-qux2.md"* ]]
244
+ [[ "$output" == *"closed/204-quux2.md"* ]]
245
+ }
246
+
247
+ @test "dual-tolerant all-tickets glob: mixed fixture enumerates ALL tickets in BOTH layouts" {
248
+ build_mixed_layout
249
+ run bash -c 'ls docs/problems/*.md docs/problems/*/*.md 2>/dev/null'
250
+ [ "$status" -eq 0 ]
251
+ for ticket in 100-foo.open.md 101-bar.known-error.md 102-baz.verifying.md 103-qux.parked.md 104-quux.closed.md \
252
+ open/200-foo2.md known-error/201-bar2.md verifying/202-baz2.md parked/203-qux2.md closed/204-quux2.md; do
253
+ [[ "$output" == *"${ticket}"* ]]
254
+ done
255
+ }
256
+
257
+ @test "dual-tolerant all-tickets next-ID compute: highest ID across both layouts" {
258
+ # Critical for capture-problem next-ID compute (architect finding 2):
259
+ # the next-ID surface MUST recurse so flat-layout 104 and per-state 204
260
+ # both contribute to max-ID; missing the per-state half re-allocates
261
+ # already-taken IDs.
262
+ build_mixed_layout
263
+ result=$(ls docs/problems/*.md docs/problems/*/*.md 2>/dev/null \
264
+ | sed 's/.*\///' \
265
+ | grep -oE '^[0-9]+' \
266
+ | sort -n \
267
+ | tail -1)
268
+ [ "$result" = "204" ]
269
+ }
270
+
271
+ # ── Pattern D: pathspec-pair shell-glob equivalence ──────────────────────────
272
+ # git log accepts multiple pathspecs; the dual-tolerant filter is two
273
+ # pathspecs side-by-side. We validate this against the working-tree
274
+ # semantics that git uses (the same shell-glob shape).
275
+
276
+ @test "dual-tolerant pathspec pair: each pathspec enumerates its layout half independently" {
277
+ build_mixed_layout
278
+ flat_count=$(ls docs/problems/*.md 2>/dev/null | wc -l | tr -d ' ')
279
+ subdir_count=$(ls docs/problems/*/*.md 2>/dev/null | wc -l | tr -d ' ')
280
+ [ "$flat_count" -ge 5 ]
281
+ [ "$subdir_count" -ge 5 ]
282
+ }
283
+
284
+ # ── Pattern E: brace-expansion ID + state-set (report-upstream) ──────────────
285
+
286
+ @test "dual-tolerant ID + state-set lookup: flat brace expansion + per-state lookup" {
287
+ build_mixed_layout
288
+ # Old shape: ls docs/problems/${ID}-*.{open,known-error,verifying,closed}.md
289
+ # Dual-tolerant: add docs/problems/*/${ID}-*.md as a sibling pathspec.
290
+ run bash -c 'ls docs/problems/100-*.{open,known-error,verifying,closed}.md docs/problems/*/100-*.md 2>/dev/null'
291
+ [[ "$output" == *"100-foo.open.md"* ]]
292
+ run bash -c 'ls docs/problems/200-*.{open,known-error,verifying,closed}.md docs/problems/*/200-*.md 2>/dev/null'
293
+ [[ "$output" == *"open/200-foo2.md"* ]]
294
+ }
295
+
296
+ # ── Composition: empty-tree fixture exit-code semantics ──────────────────────
297
+
298
+ @test "dual-tolerant glob: empty fixture produces empty output and ls exits nonzero" {
299
+ # Critical for null-safe `2>/dev/null` semantics — `ls` on a
300
+ # non-matching glob exits nonzero. SKILL.md call sites must rely on
301
+ # `2>/dev/null` to suppress the stderr noise but still treat empty
302
+ # stdout as the canonical "no tickets" signal.
303
+ set +e
304
+ result=$(ls docs/problems/*.open.md docs/problems/open/*.md 2>/dev/null)
305
+ rc=$?
306
+ set -e
307
+ [ -z "$result" ]
308
+ [ "$rc" -ne 0 ]
309
+ }