@windyroad/itil 0.40.0-preview.479 → 0.41.0-preview.484

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.
@@ -484,5 +484,5 @@
484
484
  }
485
485
  },
486
486
  "name": "wr-itil",
487
- "version": "0.40.0"
487
+ "version": "0.41.0"
488
488
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.40.0-preview.479",
3
+ "version": "0.41.0-preview.484",
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"
@@ -2,49 +2,46 @@
2
2
  # packages/itil/scripts/evaluate-relevance.sh
3
3
  #
4
4
  # Evaluate whether a problem ticket has become "no longer relevant" by
5
- # checking observable evidence per ADR-026 grounding. Phase 1 scope per
6
- # ADR-079: ONE evidence shape "file no longer exists in codebase" —
7
- # closest analog to P334/P336 close-on-evidence.
5
+ # checking observable evidence per ADR-026 grounding. Implements 5
6
+ # evidence shapes per ADR-079 (Phase 1 + Phase 2):
7
+ #
8
+ # Shape 1 — file-no-longer-exists (Phase 1, original)
9
+ # Shape 2 — ADR-shipped with `human-oversight: confirmed` (Phase 2)
10
+ # Shape 3 — named-skill-or-feature-exists (Phase 2)
11
+ # Shape 4 — self-marker-in-body (line-anchored) (Phase 2)
12
+ # Shape 5 — driver-child-ticket-closed (Phase 2)
13
+ #
14
+ # Phase 1 false-positive fixes (Phase 2):
15
+ # - state-suffix detection (P180): per-state subdirs + .<state>.md
16
+ # - sibling-file detection (P244): dir-glob slug-prefix
17
+ # - rename detection (P251): git log --follow --diff-filter=AD
8
18
  #
9
19
  # Usage:
10
20
  # evaluate-relevance.sh <ticket-file> [<min-age-days>]
11
21
  #
12
- # Default <min-age-days> is 7. Age gate is a GATING condition, not the
13
- # closing condition (per user direction 2026-05-31: "not just because
14
- # they are old").
15
- #
16
- # Algorithm:
17
- # 1. Read **Reported**: YYYY-MM-DD from the ticket frontmatter.
18
- # If absent or unparseable → SKIP no-reported-date.
19
- # 2. If today - Reported < min-age-days → SKIP too-fresh.
20
- # 3. Extract file-path candidates from the ticket body matching
21
- # (packages|docs|.changeset|src|test|scripts)/<path>.<known-extension>
22
- # then drop self-references (docs/problems/*).
23
- # 4. If no candidates remain → SKIP no-extractable-paths.
24
- # 5. For each candidate, run `git ls-files --error-unmatch <path>`.
25
- # Count present vs missing.
26
- # 6. If ALL candidates missing AND at least 1 was extracted →
27
- # CLOSE-CANDIDATE. Otherwise → KEEP.
22
+ # Default <min-age-days> is 7. Age gate is a GATING condition per user
23
+ # direction 2026-05-31 "not just because they are old".
28
24
  #
29
25
  # Output (stdout, one line):
30
- # CLOSE-CANDIDATE <basename> — all <N> file paths absent: <semicolon list>
31
- # KEEP <basename> — <M>/<N> paths still present
32
- # SKIP <basename> — <reason>
26
+ # CLOSE-CANDIDATE <basename> — shapes: <comma-list> <per-shape cite>; ...
27
+ # CLOSE-CANDIDATE-WITH-CAVEAT <basename> — shapes: <list> caveat: <tag>: <one-line>
28
+ # KEEP-WITH-NOTE <basename> — <note>: <evidence>
29
+ # KEEP <basename> — <M>/<N> paths still present
30
+ # SKIP <basename> — <reason>
33
31
  #
34
32
  # Exit codes:
35
- # 0 = CLOSE-CANDIDATE (close action recommended)
36
- # 1 = KEEP (no action)
37
- # 2 = SKIP (no action; gating condition or unparseable)
38
- # 3 = error (ticket file not found / git not available)
33
+ # 0 = CLOSE-CANDIDATE or CLOSE-CANDIDATE-WITH-CAVEAT
34
+ # 1 = KEEP or KEEP-WITH-NOTE
35
+ # 2 = SKIP
36
+ # 3 = error
39
37
  #
40
38
  # Set LC_ALL=C for portable byte-grep per P328 (BSD grep on macOS
41
39
  # silently misbehaves on UTF-8 without an explicit locale).
42
40
  #
43
- # ADR-049: never source repo-relative `packages/...` paths from a SKILL.
44
- # This script is invoked via the `wr-itil-evaluate-relevance` PATH shim.
45
- # ADR-026: every CLOSE-CANDIDATE verdict cites the paths checked AND
46
- # the verdict is reversible (`git mv` back if a rename was missed).
47
- # ADR-052: behavioural bats coverage at scripts/test/evaluate-relevance.bats.
41
+ # ADR-049: invoked via the `wr-itil-evaluate-relevance` PATH shim.
42
+ # ADR-026: every CLOSE-CANDIDATE verdict cites the evidence per shape.
43
+ # ADR-052: behavioural bats at scripts/test/evaluate-relevance.bats.
44
+ # ADR-079: design (Phase 1 + Phase 2).
48
45
 
49
46
  set -euo pipefail
50
47
  export LC_ALL=C
@@ -72,64 +69,462 @@ if [ -z "$reported" ]; then
72
69
  exit 2
73
70
  fi
74
71
 
75
- # Portable date arithmetic: compute cutoff date min_age_days ago as
76
- # YYYY-MM-DD, then ISO string-compare. Works on both BSD (macOS) and
77
- # GNU date.
78
72
  cutoff=$(date -u -v-"${min_age_days}"d "+%Y-%m-%d" 2>/dev/null || date -u -d "${min_age_days} days ago" "+%Y-%m-%d" 2>/dev/null || true)
79
73
  if [ -z "$cutoff" ]; then
80
74
  echo "SKIP $basename — could not compute cutoff date (date binary missing both BSD and GNU forms)"
81
75
  exit 2
82
76
  fi
83
77
 
84
- # ISO strings sort lexicographically. If reported > cutoff, the ticket
85
- # is younger than min_age_days → skip.
86
78
  if [ "$reported" \> "$cutoff" ]; then
87
79
  echo "SKIP $basename — age gate (reported=$reported newer than cutoff=$cutoff, gate=${min_age_days}d)"
88
80
  exit 2
89
81
  fi
90
82
 
91
- # ── Path extraction ─────────────────────────────────────────────────────────
83
+ # ── Helpers ─────────────────────────────────────────────────────────────────
84
+
85
+ # Check whether <path> is tracked by git (`git ls-files --error-unmatch`).
86
+ # Falls back to filesystem check when not in a git repo or when the file
87
+ # is staged-but-untracked-by-HEAD. Returns 0 if present.
88
+ path_exists() {
89
+ local p="$1"
90
+ if git ls-files --error-unmatch "$p" >/dev/null 2>&1; then
91
+ return 0
92
+ fi
93
+ if [ -e "$p" ]; then
94
+ return 0
95
+ fi
96
+ return 1
97
+ }
98
+
99
+ # Detect state-suffix variants of an incident/problem/RFC path (P180 fix).
100
+ # Given a path like `docs/incidents/I002-foo.investigating.md`, also checks
101
+ # `docs/incidents/I002-foo.{restored,mitigating,closed}.md`. Returns 0 if
102
+ # any sibling state-suffix variant exists.
103
+ state_suffix_variant_exists() {
104
+ local p="$1"
105
+ local dir base ext
106
+ dir=$(dirname "$p")
107
+ base=$(basename "$p")
108
+ # Strip trailing .<state>.md to a slug-prefix and recombine with each
109
+ # state. Conservative — only fires for paths in docs/incidents/,
110
+ # docs/problems/, docs/rfcs/, docs/stories/, docs/story-maps/ where the
111
+ # state-suffix convention applies (ADR-031 / ADR-060).
112
+ case "$dir" in
113
+ docs/incidents|docs/problems|docs/problems/*|docs/rfcs|docs/rfcs/*|docs/stories|docs/stories/*|docs/story-maps|docs/story-maps/*)
114
+ ;;
115
+ *)
116
+ return 1
117
+ ;;
118
+ esac
119
+ # Strip <state>.md suffix
120
+ local slug
121
+ slug=$(echo "$base" | sed -E 's/\.(open|known-error|verifying|closed|parked|investigating|mitigating|restored|draft|accepted|in-progress|done|archived|proposed|superseded)\.md$//')
122
+ if [ "$slug" = "$base" ]; then
123
+ return 1
124
+ fi
125
+ # Also try the per-state subdir layout (RFC-002 migration window).
126
+ local parent
127
+ parent=$(dirname "$dir")
128
+ local entity
129
+ entity=$(basename "$dir")
130
+ for state in open known-error verifying closed parked investigating mitigating restored draft accepted in-progress done archived proposed superseded; do
131
+ if [ -f "$dir/$slug.$state.md" ] && [ "$dir/$slug.$state.md" != "$p" ]; then
132
+ echo "$dir/$slug.$state.md"
133
+ return 0
134
+ fi
135
+ # Per-state subdir form: docs/problems/<state>/<slug>.md
136
+ case "$dir" in
137
+ docs/incidents|docs/problems|docs/rfcs|docs/stories|docs/story-maps)
138
+ if [ -f "$dir/$state/$slug.md" ]; then
139
+ echo "$dir/$state/$slug.md"
140
+ return 0
141
+ fi
142
+ ;;
143
+ docs/problems/*|docs/rfcs/*|docs/stories/*|docs/story-maps/*|docs/incidents/*)
144
+ if [ -f "$parent/$state/$slug.md" ]; then
145
+ echo "$parent/$state/$slug.md"
146
+ return 0
147
+ fi
148
+ ;;
149
+ esac
150
+ done
151
+ return 1
152
+ }
153
+
154
+ # Detect sibling files with similar slug-prefix in the same parent dir
155
+ # (P244 fix). Given `packages/foo/scripts/bar-list.sh`, finds
156
+ # `packages/foo/scripts/bar-render.sh` / `bar-populate.sh` etc.
157
+ # Returns 0 (and echoes the matched sibling) if a sibling exists.
158
+ sibling_file_exists() {
159
+ local p="$1"
160
+ local dir base ext stem prefix
161
+ dir=$(dirname "$p")
162
+ base=$(basename "$p")
163
+ ext="${base##*.}"
164
+ stem="${base%.*}"
165
+ # slug-prefix = first 2 dash-separated tokens (e.g. "plugin-maturity")
166
+ # for "plugin-maturity-list" we take "plugin-maturity"; for "foo-bar"
167
+ # we take "foo". Conservative — too-short prefixes (single token) skip.
168
+ prefix=$(echo "$stem" | cut -d- -f1-2)
169
+ if [ "$prefix" = "$stem" ]; then
170
+ # Single-token stem — no sibling-pattern to detect.
171
+ return 1
172
+ fi
173
+ # Require at least 2 dash-separated tokens AND a multi-char first token
174
+ local token_count
175
+ token_count=$(echo "$stem" | tr '-' '\n' | wc -l | tr -d ' ')
176
+ if [ "$token_count" -lt 2 ]; then
177
+ return 1
178
+ fi
179
+ if [ ! -d "$dir" ]; then
180
+ return 1
181
+ fi
182
+ local sibling
183
+ # Use ls/find to enumerate; nullglob via shell would expand to literal
184
+ # if no matches. Use find for portability.
185
+ while IFS= read -r sibling; do
186
+ [ -z "$sibling" ] && continue
187
+ if [ "$sibling" = "$p" ]; then
188
+ continue
189
+ fi
190
+ if [ -f "$sibling" ]; then
191
+ echo "$sibling"
192
+ return 0
193
+ fi
194
+ done < <(find "$dir" -maxdepth 1 -type f -name "$prefix-*.$ext" 2>/dev/null)
195
+ return 1
196
+ }
197
+
198
+ # Detect rename via git log --follow (P251 fix). Returns 0 (and echoes the
199
+ # detected new name) if the file was renamed away from <path>.
200
+ rename_detected() {
201
+ local p="$1"
202
+ local log
203
+ log=$(git log --follow --diff-filter=AD --name-only --pretty=format: -- "$p" 2>/dev/null | grep -v '^$' || true)
204
+ if [ -z "$log" ]; then
205
+ return 1
206
+ fi
207
+ # If the most recent name is different from the queried path, it was renamed.
208
+ local most_recent
209
+ most_recent=$(echo "$log" | head -1)
210
+ if [ -n "$most_recent" ] && [ "$most_recent" != "$p" ]; then
211
+ if [ -f "$most_recent" ] || git ls-files --error-unmatch "$most_recent" >/dev/null 2>&1; then
212
+ echo "$most_recent"
213
+ return 0
214
+ fi
215
+ fi
216
+ return 1
217
+ }
218
+
219
+ # ── Shape detection ─────────────────────────────────────────────────────────
220
+
221
+ shapes=""
222
+ cites=""
223
+ caveat_tag=""
224
+ caveat_msg=""
225
+ keep_with_note=""
226
+
227
+ # Append a shape to the cumulative shape list + emit per-shape cite.
228
+ record_shape() {
229
+ local shape="$1" cite="$2"
230
+ if [ -z "$shapes" ]; then
231
+ shapes="$shape"
232
+ cites="$cite"
233
+ else
234
+ case ",$shapes," in
235
+ *",$shape,"*) return 0 ;; # already recorded
236
+ esac
237
+ shapes="$shapes,$shape"
238
+ cites="$cites; $cite"
239
+ fi
240
+ }
241
+
242
+ # Shape 1 — file-no-longer-exists (Phase 1 original).
243
+ # Extracts candidate paths, drops self-refs, runs path_exists per
244
+ # candidate. Detects state-suffix / sibling-file / rename to avoid Phase 1
245
+ # false-positives (P180/P244/P251); on detection routes to KEEP-WITH-NOTE.
92
246
 
93
- # Regex restricts candidates to well-known repo subdirs with known
94
- # file extensions. Tight on purpose — false-positive-resistant.
95
- # Extension list mirrors the file types typically referenced in
96
- # problem-ticket bodies (markdown, shell scripts, source, configs).
97
247
  candidates=$(grep -oE '(packages|docs|\.changeset|src|test|scripts)/[A-Za-z0-9._/-]+\.(md|sh|ts|tsx|js|jsx|json|yml|yaml|bats|py|txt|html)' "$ticket_file" 2>/dev/null \
98
248
  | sort -u \
99
249
  | grep -v '^docs/problems/' \
100
250
  || true)
101
251
 
102
- if [ -z "$candidates" ]; then
103
- echo "SKIP $basename — no extractable file paths (after self-reference exclusion)"
104
- exit 2
252
+ shape1_missing=0
253
+ shape1_present=0
254
+ shape1_missing_list=""
255
+
256
+ if [ -n "$candidates" ]; then
257
+ while IFS= read -r path; do
258
+ [ -z "$path" ] && continue
259
+ if path_exists "$path"; then
260
+ shape1_present=$((shape1_present + 1))
261
+ else
262
+ # Phase 1 false-positive fixes — check state-suffix / sibling-file
263
+ # / rename BEFORE counting as missing.
264
+ variant=$(state_suffix_variant_exists "$path" 2>/dev/null || true)
265
+ if [ -n "$variant" ]; then
266
+ keep_with_note="state-suffix variant exists: $variant"
267
+ break
268
+ fi
269
+ sibling=$(sibling_file_exists "$path" 2>/dev/null || true)
270
+ if [ -n "$sibling" ]; then
271
+ keep_with_note="sibling file with similar slug-prefix exists: $sibling"
272
+ break
273
+ fi
274
+ renamed=$(rename_detected "$path" 2>/dev/null || true)
275
+ if [ -n "$renamed" ]; then
276
+ keep_with_note="renamed (git log --follow): $path → $renamed"
277
+ break
278
+ fi
279
+ shape1_missing=$((shape1_missing + 1))
280
+ if [ -z "$shape1_missing_list" ]; then
281
+ shape1_missing_list="$path"
282
+ else
283
+ shape1_missing_list="$shape1_missing_list;$path"
284
+ fi
285
+ fi
286
+ done <<< "$candidates"
105
287
  fi
106
288
 
107
- # ── Existence check via git ls-files ────────────────────────────────────────
289
+ # Shape 1 fires only when ALL extracted candidates are absent AND at
290
+ # least one was extracted.
291
+ if [ -z "$keep_with_note" ] && [ -n "$candidates" ] && [ "$shape1_missing" -ge 1 ] && [ "$shape1_present" -eq 0 ]; then
292
+ shape1_total=$shape1_missing
293
+ record_shape "file-no-longer-exists" "all ${shape1_total} file paths absent: ${shape1_missing_list}"
294
+ fi
108
295
 
109
- missing=0
110
- present=0
111
- missing_list=""
296
+ # Shape 2 — ADR-shipped with `human-oversight: confirmed`.
297
+ # grep ticket body for ADR-NNN; for each, check docs/decisions/<NNN>-*.md
298
+ # exists AND frontmatter contains `human-oversight: confirmed`.
112
299
 
113
- while IFS= read -r path; do
114
- [ -z "$path" ] && continue
115
- if git ls-files --error-unmatch "$path" >/dev/null 2>&1; then
116
- present=$((present + 1))
117
- else
118
- missing=$((missing + 1))
119
- if [ -z "$missing_list" ]; then
120
- missing_list="$path"
300
+ adr_refs=$(grep -oE '\bADR-[0-9]{3}\b' "$ticket_file" 2>/dev/null | sort -u || true)
301
+ shape2_confirmed=""
302
+ if [ -n "$adr_refs" ]; then
303
+ while IFS= read -r adr; do
304
+ [ -z "$adr" ] && continue
305
+ num="${adr#ADR-}"
306
+ # Find any docs/decisions/<num>-*.md (any state suffix).
307
+ while IFS= read -r adr_file; do
308
+ [ -z "$adr_file" ] && continue
309
+ if grep -qE '^human-oversight: confirmed' "$adr_file" 2>/dev/null; then
310
+ if [ -z "$shape2_confirmed" ]; then
311
+ shape2_confirmed="$adr ($adr_file)"
312
+ else
313
+ shape2_confirmed="$shape2_confirmed, $adr ($adr_file)"
314
+ fi
315
+ break
316
+ fi
317
+ done < <(find docs/decisions -maxdepth 1 -type f -name "${num}-*.md" 2>/dev/null)
318
+ done <<< "$adr_refs"
319
+ fi
320
+ if [ -n "$shape2_confirmed" ]; then
321
+ record_shape "ADR-shipped-confirmed" "ADRs human-oversight-confirmed: ${shape2_confirmed}"
322
+ fi
323
+
324
+ # Shape 3 — named-skill-or-feature-exists.
325
+ # Detects SKILL.md / hook / agent / slash-command surfaces that exist.
326
+
327
+ shape3_hits=""
328
+
329
+ # (a) SKILL.md paths.
330
+ while IFS= read -r skill_path; do
331
+ [ -z "$skill_path" ] && continue
332
+ if path_exists "$skill_path"; then
333
+ if [ -z "$shape3_hits" ]; then
334
+ shape3_hits="$skill_path"
121
335
  else
122
- missing_list="$missing_list;$path"
336
+ shape3_hits="$shape3_hits; $skill_path"
123
337
  fi
124
338
  fi
125
- done <<< "$candidates"
339
+ done < <(grep -oE 'packages/[A-Za-z0-9_-]+/skills/[A-Za-z0-9_-]+/SKILL\.md' "$ticket_file" 2>/dev/null | sort -u || true)
126
340
 
127
- total=$((missing + present))
341
+ # (b) Hook paths.
342
+ while IFS= read -r hook_path; do
343
+ [ -z "$hook_path" ] && continue
344
+ if path_exists "$hook_path"; then
345
+ if [ -z "$shape3_hits" ]; then
346
+ shape3_hits="$hook_path"
347
+ else
348
+ shape3_hits="$shape3_hits; $hook_path"
349
+ fi
350
+ fi
351
+ done < <(grep -oE 'packages/[A-Za-z0-9_-]+/hooks/[A-Za-z0-9._-]+\.sh' "$ticket_file" 2>/dev/null | sort -u || true)
128
352
 
129
- if [ "$missing" -eq "$total" ] && [ "$missing" -ge 1 ]; then
130
- echo "CLOSE-CANDIDATE $basename all ${total} file paths absent: ${missing_list}"
353
+ # (c) Agent paths.
354
+ while IFS= read -r agent_path; do
355
+ [ -z "$agent_path" ] && continue
356
+ if path_exists "$agent_path"; then
357
+ if [ -z "$shape3_hits" ]; then
358
+ shape3_hits="$agent_path"
359
+ else
360
+ shape3_hits="$shape3_hits; $agent_path"
361
+ fi
362
+ fi
363
+ done < <(grep -oE 'packages/[A-Za-z0-9_-]+/agents/[A-Za-z0-9._-]+\.md' "$ticket_file" 2>/dev/null | sort -u || true)
364
+
365
+ # (d) Slash-command refs — resolve to packages/<plugin>/skills/<skill>/SKILL.md
366
+ while IFS= read -r slash; do
367
+ [ -z "$slash" ] && continue
368
+ plugin=$(echo "$slash" | sed -E 's|/wr-([a-z0-9-]+):.*|\1|')
369
+ skill=$(echo "$slash" | sed -E 's|/wr-[a-z0-9-]+:([a-z0-9-]+).*|\1|')
370
+ candidate="packages/$plugin/skills/$skill/SKILL.md"
371
+ if path_exists "$candidate"; then
372
+ if [ -z "$shape3_hits" ]; then
373
+ shape3_hits="$slash → $candidate"
374
+ else
375
+ shape3_hits="$shape3_hits; $slash → $candidate"
376
+ fi
377
+ fi
378
+ done < <(grep -oE '/wr-[a-z0-9-]+:[a-z0-9-]+' "$ticket_file" 2>/dev/null | sort -u || true)
379
+
380
+ if [ -n "$shape3_hits" ]; then
381
+ record_shape "named-skill-or-feature-exists" "feature surfaces exist: ${shape3_hits}"
382
+ fi
383
+
384
+ # Shape 4 — self-marker-in-body (line-anchored regex per architect A2).
385
+ # Patterns:
386
+ # ^.* Close to (Verifying|Closed)\b
387
+ # ^.* DONE 2026-
388
+ # ^.* fix shipped session
389
+ # ^.* awaiting K→V
390
+ # ^## Fix Released
391
+ # Line-anchored: must appear at line-start (with optional leading bullet
392
+ # or heading prefix) — prevents mid-prose narrative false-positives.
393
+
394
+ shape4_marker=""
395
+ if grep -qE '^[[:space:]]*[#>*-]*[[:space:]]*.*Close to (Verifying|Closed)\b' "$ticket_file" 2>/dev/null; then
396
+ shape4_marker="'Close to Verifying|Closed' line marker"
397
+ elif grep -qE '^[[:space:]]*[#>*-]*[[:space:]]*\[?[x ]?\]?[[:space:]]*\*?\*?DONE 2026-' "$ticket_file" 2>/dev/null; then
398
+ shape4_marker="'DONE 2026-' line marker"
399
+ elif grep -qE '^## Fix Released' "$ticket_file" 2>/dev/null; then
400
+ shape4_marker="'## Fix Released' heading"
401
+ elif grep -qE '^[[:space:]]*[#>*-]*[[:space:]]*.*fix shipped session' "$ticket_file" 2>/dev/null; then
402
+ shape4_marker="'fix shipped session' line marker"
403
+ elif grep -qE '^[[:space:]]*[#>*-]*[[:space:]]*.*awaiting K→V' "$ticket_file" 2>/dev/null; then
404
+ shape4_marker="'awaiting K→V' line marker"
405
+ fi
406
+ if [ -n "$shape4_marker" ]; then
407
+ record_shape "self-marker-in-body" "self-marker: ${shape4_marker}"
408
+ fi
409
+
410
+ # Shape 5 — driver-child-ticket-closed.
411
+ # Parse `## Related` section for P<NNN> refs; check if any are in
412
+ # docs/problems/closed/ (dual-tolerant: subdir OR .closed.md suffix).
413
+ # Per advisory A1, only fires when the child has NO unresolved
414
+ # investigation items of its own (rough heuristic: no unticked checkboxes
415
+ # OR no extractable open file refs).
416
+
417
+ # Extract section starting at "## Related" through end of file.
418
+ related_section=$(awk '/^## Related/{flag=1;next} /^## /{flag=0} flag' "$ticket_file" 2>/dev/null || true)
419
+ closed_drivers=""
420
+ if [ -n "$related_section" ]; then
421
+ while IFS= read -r pnum; do
422
+ [ -z "$pnum" ] && continue
423
+ n="${pnum#P}"
424
+ n="${n#p}"
425
+ # Strip leading zeros before printf so bash doesn't interpret 034 as
426
+ # octal (would yield decimal 28 — silent attribution bug). Use a
427
+ # while-loop strip rather than $((10#$n)) so we don't trip on
428
+ # malformed input.
429
+ n_clean="$n"
430
+ while [ "${n_clean#0}" != "$n_clean" ] && [ -n "${n_clean#0}" ]; do
431
+ n_clean="${n_clean#0}"
432
+ done
433
+ pattern_num=$(printf "%03d" "$n_clean" 2>/dev/null || echo "$n")
434
+ while IFS= read -r closed_file; do
435
+ [ -z "$closed_file" ] && continue
436
+ if [ -z "$closed_drivers" ]; then
437
+ closed_drivers="$pnum ($closed_file)"
438
+ else
439
+ closed_drivers="$closed_drivers, $pnum ($closed_file)"
440
+ fi
441
+ break
442
+ done < <(find docs/problems/closed -maxdepth 1 -type f \( -name "${pattern_num}-*.md" -o -name "${pattern_num}-*.closed.md" \) 2>/dev/null; find docs/problems -maxdepth 1 -type f -name "${pattern_num}-*.closed.md" 2>/dev/null)
443
+ done < <(echo "$related_section" | grep -oE '\bP[0-9]{2,4}\b' | sort -u || true)
444
+ fi
445
+
446
+ # A1 guard — "child has independent open work" disambiguation.
447
+ #
448
+ # Shape 5 says "the driver in ## Related is closed". This is contributory
449
+ # evidence at best — the driver's closure does not prove the child's work
450
+ # is done. Per architect advisory A1, shape 5 should NOT fire when the
451
+ # child clearly names independent outstanding scope.
452
+ #
453
+ # Detection: if the ticket body references a `packages/.../skills/<name>/
454
+ # SKILL.md` or `packages/.../agents/<name>.md` that does NOT exist on
455
+ # disk, the umbrella is naming a feature that hasn't been built. This is
456
+ # future-work, not stale-work — suppress both shape 5 AND shape 1 for the
457
+ # missing future-work path; emit KEEP-WITH-NOTE.
458
+ future_work_skill_ref=""
459
+ while IFS= read -r future_skill; do
460
+ [ -z "$future_skill" ] && continue
461
+ if ! path_exists "$future_skill"; then
462
+ future_work_skill_ref="$future_skill"
463
+ break
464
+ fi
465
+ done < <(grep -oE 'packages/[A-Za-z0-9_-]+/(skills/[A-Za-z0-9_-]+/SKILL\.md|agents/[A-Za-z0-9._-]+\.md)' "$ticket_file" 2>/dev/null | sort -u || true)
466
+
467
+ if [ -n "$closed_drivers" ] && [ -n "$future_work_skill_ref" ]; then
468
+ # Closed driver AND child names a SKILL/agent that hasn't been built
469
+ # yet — future work, not stale. KEEP-WITH-NOTE.
470
+ keep_with_note="closed driver(s) ${closed_drivers}, but child names unbuilt SKILL/agent: $future_work_skill_ref"
471
+ # Reset cumulative shapes so the KEEP-WITH-NOTE branch routes cleanly.
472
+ shapes=""
473
+ cites=""
474
+ fi
475
+
476
+ if [ -n "$closed_drivers" ] && [ -z "$keep_with_note" ]; then
477
+ record_shape "driver-child-ticket-closed" "drivers closed: ${closed_drivers}"
478
+ fi
479
+
480
+ # ── Caveat detection ────────────────────────────────────────────────────────
481
+ # Architect condition C2: when shape detection is partial (umbrella with
482
+ # mixed-phase progress), emit CLOSE-CANDIDATE-WITH-CAVEAT with structured
483
+ # caveat short-tag + one-line prose so the SKILL Step 4.6b template can
484
+ # splice the **Caveat** field directly.
485
+
486
+ if [ -n "$shapes" ]; then
487
+ # Multi-phase umbrella detection: unticked checkboxes in the ticket
488
+ # body + at least one shipped-evidence shape match. The shape match
489
+ # itself is the "progress made" signal — unticked tasks indicate
490
+ # outstanding scope the maintainer must confirm before close.
491
+ #
492
+ # Use `grep | wc -l` so a no-match scenario exits 0 (wc always returns
493
+ # 0) — avoids the pipefail + `grep -c` trap where grep prints "0" then
494
+ # exits 1, tripping set -e on assignment.
495
+ unticked_count=$(grep -cE '^[[:space:]]*-[[:space:]]*\[[[:space:]]\][[:space:]]' "$ticket_file" 2>/dev/null || true)
496
+ ticked_count=$(grep -cE '^[[:space:]]*-[[:space:]]*\[x\][[:space:]]' "$ticket_file" 2>/dev/null || true)
497
+ unticked_count=${unticked_count:-0}
498
+ ticked_count=${ticked_count:-0}
499
+ if [ "$unticked_count" -ge 1 ]; then
500
+ caveat_tag="multi-phase-mixed-progress"
501
+ caveat_msg="${ticked_count} task(s) done, ${unticked_count} outstanding — confirm umbrella scope before close"
502
+ fi
503
+ fi
504
+
505
+ # ── Verdict emission ────────────────────────────────────────────────────────
506
+
507
+ if [ -n "$keep_with_note" ]; then
508
+ echo "KEEP-WITH-NOTE $basename — $keep_with_note"
509
+ exit 1
510
+ fi
511
+
512
+ if [ -n "$shapes" ]; then
513
+ if [ -n "$caveat_tag" ]; then
514
+ echo "CLOSE-CANDIDATE-WITH-CAVEAT $basename — shapes: $shapes — caveat: $caveat_tag: $caveat_msg — cites: $cites"
515
+ else
516
+ echo "CLOSE-CANDIDATE $basename — shapes: $shapes — $cites"
517
+ fi
131
518
  exit 0
132
519
  fi
133
520
 
134
- echo "KEEP $basename${present}/${total} paths still present"
521
+ # No shape fired fall back to the legacy KEEP / SKIP routing.
522
+
523
+ if [ -z "$candidates" ]; then
524
+ echo "SKIP $basename — no extractable file paths (after self-reference exclusion)"
525
+ exit 2
526
+ fi
527
+
528
+ total=$((shape1_missing + shape1_present))
529
+ echo "KEEP $basename — ${shape1_present}/${total} paths still present"
135
530
  exit 1
@@ -41,7 +41,7 @@ setup() {
41
41
  git init -q -b main
42
42
  git config user.email test@example.com
43
43
  git config user.name "Test"
44
- mkdir -p docs/problems/open docs/problems/known-error packages/itil/scripts docs/decisions
44
+ mkdir -p docs/problems/open docs/problems/known-error docs/problems/closed packages/itil/scripts docs/decisions
45
45
 
46
46
  # An "old" Reported date: 60 days before today. ISO date arithmetic
47
47
  # portable across BSD + GNU date.
@@ -327,3 +327,447 @@ EOF
327
327
  [ "$status" -eq 2 ]
328
328
  [[ "${lines[0]}" == "SKIP "* ]]
329
329
  }
330
+
331
+ # ── Phase 2 Shape 2: ADR-shipped with `human-oversight: confirmed` ───────────
332
+ #
333
+ # @adr ADR-079 Phase 2 — shape 2 covers 8 of 14 closes in the 2026-05-31
334
+ # labeled fixture set (P012/P015/P018/P022/P033/P039/
335
+ # P194/P292). Mechanical check: grep ticket body for
336
+ # ADR-NNN refs; for each, verify
337
+ # docs/decisions/<NNN>-*.md exists AND frontmatter has
338
+ # `human-oversight: confirmed`.
339
+
340
+ @test "evaluate-relevance: Phase 2 shape 2 — ADR-shipped-confirmed → CLOSE-CANDIDATE exit 0" {
341
+ cat > docs/decisions/037-confirmed-adr.proposed.md <<EOF
342
+ ---
343
+ status: "proposed"
344
+ human-oversight: confirmed
345
+ oversight-date: 2026-05-25
346
+ ---
347
+
348
+ # ADR-037: Test confirmed ADR
349
+ EOF
350
+ git add docs/decisions/037-confirmed-adr.proposed.md
351
+
352
+ cat > docs/problems/open/120-adr-confirmed.md <<EOF
353
+ # Problem 120: adr-confirmed
354
+
355
+ **Status**: Open
356
+ **Reported**: $OLD_DATE
357
+
358
+ ## Description
359
+
360
+ ADR-037 was the design decision; the implementation has landed and the
361
+ ADR is human-oversight: confirmed. Concern no longer concerning.
362
+ EOF
363
+ run "$SCRIPT" docs/problems/open/120-adr-confirmed.md
364
+ [ "$status" -eq 0 ]
365
+ [[ "$output" == "CLOSE-CANDIDATE "*"shapes: "*"ADR-shipped-confirmed"* ]]
366
+ [[ "$output" == *"ADR-037"* ]]
367
+ }
368
+
369
+ @test "evaluate-relevance: Phase 2 shape 2 — ADR exists but NOT confirmed → no shape 2 fire" {
370
+ cat > docs/decisions/038-proposed-but-unconfirmed.proposed.md <<EOF
371
+ ---
372
+ status: "proposed"
373
+ ---
374
+
375
+ # ADR-038: Not yet confirmed
376
+ EOF
377
+ git add docs/decisions/038-proposed-but-unconfirmed.proposed.md
378
+
379
+ cat > docs/problems/open/121-adr-unconfirmed.md <<EOF
380
+ # Problem 121: adr-unconfirmed
381
+
382
+ **Status**: Open
383
+ **Reported**: $OLD_DATE
384
+
385
+ ## Description
386
+
387
+ ADR-038 captured the design but is not yet confirmed by human review.
388
+ EOF
389
+ run "$SCRIPT" docs/problems/open/121-adr-unconfirmed.md
390
+ # Without other shape matches, shape 2 alone (unconfirmed ADR) MUST NOT
391
+ # produce a CLOSE-CANDIDATE. Verdict routes to SKIP (no extractable
392
+ # paths to ground shape 1) or KEEP — never CLOSE-CANDIDATE.
393
+ [ "$status" -ne 0 ]
394
+ [[ "${lines[0]}" != "CLOSE-CANDIDATE "* ]]
395
+ [[ "${lines[0]}" != "CLOSE-CANDIDATE-WITH-CAVEAT "* ]]
396
+ }
397
+
398
+ # ── Phase 2 Shape 3: named-skill-or-feature-exists ───────────────────────────
399
+ #
400
+ # @adr ADR-079 Phase 2 — shape 3 covers 6 of 14 closes (P014/P034/P045/P079/
401
+ # P190/P289). Verifies the cited SKILL.md / hook /
402
+ # agent / slash-command surface exists.
403
+
404
+ @test "evaluate-relevance: Phase 2 shape 3 — SKILL.md exists → CLOSE-CANDIDATE exit 0" {
405
+ mkdir -p packages/itil/skills/some-feature
406
+ cat > packages/itil/skills/some-feature/SKILL.md <<EOF
407
+ ---
408
+ name: wr-itil:some-feature
409
+ ---
410
+ # Some Feature
411
+ EOF
412
+ git add packages/itil/skills/some-feature/SKILL.md
413
+
414
+ cat > docs/problems/open/130-feature-shipped.md <<EOF
415
+ # Problem 130: feature-shipped
416
+
417
+ **Status**: Open
418
+ **Reported**: $OLD_DATE
419
+
420
+ ## Description
421
+
422
+ The feature this ticket asks for has shipped at
423
+ \`packages/itil/skills/some-feature/SKILL.md\`. Concern resolved.
424
+ EOF
425
+ run "$SCRIPT" docs/problems/open/130-feature-shipped.md
426
+ [ "$status" -eq 0 ]
427
+ [[ "$output" == "CLOSE-CANDIDATE "*"shapes: "*"named-skill-or-feature-exists"* ]]
428
+ }
429
+
430
+ @test "evaluate-relevance: Phase 2 shape 3 — slash-command ref + SKILL exists → CLOSE-CANDIDATE exit 0" {
431
+ mkdir -p packages/architect/skills/capture-adr
432
+ cat > packages/architect/skills/capture-adr/SKILL.md <<EOF
433
+ ---
434
+ name: wr-architect:capture-adr
435
+ ---
436
+ EOF
437
+ git add packages/architect/skills/capture-adr/SKILL.md
438
+
439
+ cat > docs/problems/open/131-slash-command.md <<EOF
440
+ # Problem 131: slash-command
441
+
442
+ **Status**: Open
443
+ **Reported**: $OLD_DATE
444
+
445
+ ## Description
446
+
447
+ The aside-invocation surface /wr-architect:capture-adr now exists and
448
+ covers the original concern.
449
+ EOF
450
+ run "$SCRIPT" docs/problems/open/131-slash-command.md
451
+ [ "$status" -eq 0 ]
452
+ [[ "$output" == "CLOSE-CANDIDATE "*"named-skill-or-feature-exists"* ]]
453
+ }
454
+
455
+ # ── Phase 2 Shape 4: self-marker-in-body (line-anchored regex per A2) ────────
456
+ #
457
+ # @adr ADR-079 Phase 2 — shape 4 explicit in P289 ("Close to Verifying"),
458
+ # contributory in P033 ("## Fix Released"). Regex
459
+ # line-anchored per architect advisory A2 to avoid
460
+ # mid-prose false-positives.
461
+
462
+ @test "evaluate-relevance: Phase 2 shape 4 — 'Close to Verifying' line marker → CLOSE-CANDIDATE exit 0" {
463
+ cat > docs/problems/open/140-self-marker.md <<EOF
464
+ # Problem 140: self-marker
465
+
466
+ **Status**: Open
467
+ **Reported**: $OLD_DATE
468
+
469
+ ## Description
470
+
471
+ Work has been done.
472
+
473
+ ## Resolution
474
+
475
+ The fix shipped 2026-05-27. Close to Verifying.
476
+ EOF
477
+ run "$SCRIPT" docs/problems/open/140-self-marker.md
478
+ [ "$status" -eq 0 ]
479
+ [[ "$output" == "CLOSE-CANDIDATE "*"self-marker-in-body"* ]]
480
+ }
481
+
482
+ @test "evaluate-relevance: Phase 2 shape 4 — '## Fix Released' heading → CLOSE-CANDIDATE exit 0" {
483
+ cat > docs/problems/open/141-fix-released.md <<EOF
484
+ # Problem 141: fix-released
485
+
486
+ **Status**: Open
487
+ **Reported**: $OLD_DATE
488
+
489
+ ## Description
490
+
491
+ Bug.
492
+
493
+ ## Fix Released
494
+
495
+ Implemented 2026-04-17.
496
+ EOF
497
+ run "$SCRIPT" docs/problems/open/141-fix-released.md
498
+ [ "$status" -eq 0 ]
499
+ [[ "$output" == "CLOSE-CANDIDATE "*"self-marker-in-body"* ]]
500
+ }
501
+
502
+ @test "evaluate-relevance: Phase 2 shape 4 — 'DONE 2026-' line marker → CLOSE-CANDIDATE exit 0" {
503
+ cat > docs/problems/open/142-done-marker.md <<EOF
504
+ # Problem 142: done-marker
505
+
506
+ **Status**: Open
507
+ **Reported**: $OLD_DATE
508
+
509
+ ## Description
510
+
511
+ Description.
512
+
513
+ ### Investigation Tasks
514
+
515
+ - [x] DONE 2026-05-27 — Migration-strategy decision executed.
516
+ EOF
517
+ run "$SCRIPT" docs/problems/open/142-done-marker.md
518
+ [ "$status" -eq 0 ]
519
+ [[ "$output" == "CLOSE-CANDIDATE "*"self-marker-in-body"* ]]
520
+ }
521
+
522
+ @test "evaluate-relevance: Phase 2 shape 4 — mid-prose 'close to verifying' (lowercase, narrative) → KEEP exit 1 (negative fixture per advisory A2)" {
523
+ cat > docs/problems/open/143-mid-prose.md <<EOF
524
+ # Problem 143: mid-prose
525
+
526
+ **Status**: Open
527
+ **Reported**: $OLD_DATE
528
+
529
+ ## Description
530
+
531
+ The team thinks this is close to verifying our hypothesis but no concrete
532
+ shipped evidence exists yet — still investigating.
533
+ EOF
534
+ run "$SCRIPT" docs/problems/open/143-mid-prose.md
535
+ # Without other shape matches, mid-prose narrative MUST NOT fire shape 4.
536
+ # Verdict should be SKIP (no extractable paths) or KEEP — never CLOSE-CANDIDATE.
537
+ [ "$status" -ne 0 ]
538
+ }
539
+
540
+ # ── Phase 2 Shape 5: driver-child-ticket-closed ──────────────────────────────
541
+ #
542
+ # @adr ADR-079 Phase 2 — shape 5 contributory in several closes (e.g. P014
543
+ # cites closed P155 driver). Parses ## Related for
544
+ # P<NNN> refs; checks if any are in
545
+ # docs/problems/closed/.
546
+
547
+ @test "evaluate-relevance: Phase 2 shape 5 — Related cites closed driver → CLOSE-CANDIDATE exit 0" {
548
+ cat > docs/problems/closed/155-driver-done.md <<EOF
549
+ # Problem 155: driver-done
550
+
551
+ **Status**: Closed
552
+ EOF
553
+ git add docs/problems/closed/155-driver-done.md
554
+
555
+ cat > docs/problems/open/150-child-of-closed.md <<EOF
556
+ # Problem 150: child-of-closed
557
+
558
+ **Status**: Open
559
+ **Reported**: $OLD_DATE
560
+
561
+ ## Description
562
+
563
+ This is the umbrella for several driver tickets.
564
+
565
+ ## Related
566
+
567
+ - **P155** — implementation driver.
568
+ EOF
569
+ run "$SCRIPT" docs/problems/open/150-child-of-closed.md
570
+ [ "$status" -eq 0 ]
571
+ [[ "$output" == "CLOSE-CANDIDATE "*"driver-child-ticket-closed"* ]]
572
+ }
573
+
574
+ @test "evaluate-relevance: Phase 2 shape 5 — Related cites closed driver BUT child has independent open work → KEEP exit 1 (advisory A1 negative fixture)" {
575
+ cat > docs/problems/closed/156-driver-done.md <<EOF
576
+ # Problem 156: driver-done
577
+
578
+ **Status**: Closed
579
+ EOF
580
+ git add docs/problems/closed/156-driver-done.md
581
+
582
+ cat > docs/problems/open/151-independent.md <<EOF
583
+ # Problem 151: independent
584
+
585
+ **Status**: Open
586
+ **Reported**: $OLD_DATE
587
+
588
+ ## Description
589
+
590
+ While the driver P156 is closed, this ticket has its own outstanding
591
+ investigation around \`packages/itil/skills/unrelated-future-skill/SKILL.md\`
592
+ which has not been implemented yet.
593
+
594
+ ## Related
595
+
596
+ - **P156** — original driver (closed); this ticket carries new scope beyond P156.
597
+ EOF
598
+ run "$SCRIPT" docs/problems/open/151-independent.md
599
+ # The script extracts \`packages/itil/skills/unrelated-future-skill/SKILL.md\`
600
+ # which does NOT exist — shape 1 (file-no-longer-exists) would fire. But the
601
+ # ticket independent-work signal lives in the unimplemented-file class; the
602
+ # KEEP requirement here is: shape 5 must NOT fire when an existing file
603
+ # reference is unresolvable (i.e. the umbrella has unfinished scope).
604
+ # Per architect advisory A1, we surface this as KEEP-WITH-NOTE rather than
605
+ # silent CLOSE-CANDIDATE.
606
+ [ "$status" -ne 0 ]
607
+ }
608
+
609
+ # ── Phase 1 false-positive fixes ─────────────────────────────────────────────
610
+ #
611
+ # @adr ADR-079 Phase 2 — Phase 1 false-positive class fixes:
612
+ # - P180: state-suffix detection (incident I002.investigating.md vs I002.restored.md)
613
+ # - P244: sibling-file detection (plugin-maturity-list.sh vs plugin-maturity-render.sh)
614
+ # - P251: rename detection via git log --follow
615
+
616
+ @test "evaluate-relevance: Phase 1 fix — state-suffix variant exists at different suffix → KEEP-WITH-NOTE (not CLOSE-CANDIDATE)" {
617
+ mkdir -p docs/incidents
618
+ cat > docs/incidents/I002-renamed.restored.md <<EOF
619
+ # Incident I002
620
+ EOF
621
+ git add docs/incidents/I002-renamed.restored.md
622
+
623
+ cat > docs/problems/open/160-state-suffix.md <<EOF
624
+ # Problem 160: state-suffix
625
+
626
+ **Status**: Open
627
+ **Reported**: $OLD_DATE
628
+
629
+ ## Description
630
+
631
+ Investigation references docs/incidents/I002-renamed.investigating.md
632
+ which has since transitioned state.
633
+ EOF
634
+ run "$SCRIPT" docs/problems/open/160-state-suffix.md
635
+ # Phase 1 would falsely declare the file gone. Phase 2 detects the
636
+ # restored.md state-suffix variant and routes to KEEP-WITH-NOTE.
637
+ [ "$status" -eq 1 ]
638
+ [[ "$output" == "KEEP-WITH-NOTE "* ]] || [[ "$output" == "KEEP "* ]]
639
+ [[ "$output" == *"state-suffix"* ]] || [[ "$output" == *"renamed.restored.md"* ]]
640
+ }
641
+
642
+ @test "evaluate-relevance: Phase 1 fix — sibling file with similar slug-prefix → KEEP-WITH-NOTE" {
643
+ mkdir -p packages/architect/scripts
644
+ cat > packages/architect/scripts/plugin-maturity-render.sh <<EOF
645
+ #!/bin/bash
646
+ EOF
647
+ cat > packages/architect/scripts/plugin-maturity-populate.sh <<EOF
648
+ #!/bin/bash
649
+ EOF
650
+ git add packages/architect/scripts/plugin-maturity-render.sh packages/architect/scripts/plugin-maturity-populate.sh
651
+
652
+ cat > docs/problems/open/161-sibling-file.md <<EOF
653
+ # Problem 161: sibling-file
654
+
655
+ **Status**: Open
656
+ **Reported**: $OLD_DATE
657
+
658
+ ## Description
659
+
660
+ Bug in packages/architect/scripts/plugin-maturity-list.sh
661
+ EOF
662
+ run "$SCRIPT" docs/problems/open/161-sibling-file.md
663
+ # Phase 1 would declare plugin-maturity-list.sh gone. Phase 2 detects
664
+ # the sibling-file class (plugin-maturity-* slug-prefix) and routes to
665
+ # KEEP-WITH-NOTE.
666
+ [ "$status" -eq 1 ]
667
+ [[ "$output" == "KEEP-WITH-NOTE "* ]] || [[ "$output" == "KEEP "* ]]
668
+ [[ "$output" == *"sibling"* ]] || [[ "$output" == *"plugin-maturity-"* ]]
669
+ }
670
+
671
+ # ── CLOSE-CANDIDATE-WITH-CAVEAT structured emission (architect condition C2) ──
672
+
673
+ @test "evaluate-relevance: CLOSE-CANDIDATE-WITH-CAVEAT emits structured caveat format" {
674
+ cat > docs/decisions/040-multi-phase-confirmed.proposed.md <<EOF
675
+ ---
676
+ status: "proposed"
677
+ human-oversight: confirmed
678
+ oversight-date: 2026-05-25
679
+ ---
680
+ # ADR-040
681
+ EOF
682
+ git add docs/decisions/040-multi-phase-confirmed.proposed.md
683
+
684
+ cat > docs/problems/open/170-umbrella-caveat.md <<EOF
685
+ # Problem 170: umbrella-caveat
686
+
687
+ **Status**: Open
688
+ **Reported**: $OLD_DATE
689
+
690
+ ## Description
691
+
692
+ Multi-phase umbrella. ADR-040 covers the design; Phase 2 done, Phase 3
693
+ outstanding work \`packages/jtbd/lib/phase3-helper.sh\`.
694
+
695
+ ## Phase 3 progress
696
+
697
+ - [ ] Phase 3 work
698
+ EOF
699
+ # Phase 3 cited path does not exist; ADR-040 confirmed → shape 2 fires + caveat
700
+ run "$SCRIPT" docs/problems/open/170-umbrella-caveat.md
701
+ # Architect condition C2 — structured caveat emission:
702
+ # CLOSE-CANDIDATE-WITH-CAVEAT <basename> — shapes: <list> — caveat: <tag>: <one-line>
703
+ # For multi-phase umbrellas with unticked checkboxes the caveat tag is
704
+ # 'multi-phase-mixed-progress'.
705
+ [[ "$output" == "CLOSE-CANDIDATE-WITH-CAVEAT "* ]]
706
+ [[ "$output" == *"shapes:"* ]]
707
+ [[ "$output" == *"caveat:"* ]]
708
+ [[ "$output" == *"multi-phase-mixed-progress"* ]]
709
+ }
710
+
711
+ # ── KEEP fixtures from the 2026-05-31 labeled negative set ────────────────────
712
+ #
713
+ # @adr ADR-079 Phase 2 — KEEP regression suite: P136 multi-phase umbrella,
714
+ # P303/P326 recent-observation-no-shipped-evidence.
715
+
716
+ @test "evaluate-relevance: KEEP fixture — recent observation, no shipped evidence (P303/P326 class)" {
717
+ cat > docs/problems/open/180-recent-observation.md <<EOF
718
+ # Problem 180: recent-observation
719
+
720
+ **Status**: Open
721
+ **Reported**: $OLD_DATE
722
+
723
+ ## Description
724
+
725
+ Observed friction with the risk-scorer pipeline staging. Composes-with
726
+ P057 / P125 / P273 (sibling staging traps) but no shipped fix yet.
727
+
728
+ ## Related
729
+
730
+ - **P057** — sibling.
731
+ EOF
732
+ # P057 not closed (no file); no ADR refs that are confirmed; no SKILL refs;
733
+ # no self-markers. Should KEEP.
734
+ run "$SCRIPT" docs/problems/open/180-recent-observation.md
735
+ [ "$status" -ne 0 ]
736
+ # Either SKIP (no extractable paths) or KEEP — never CLOSE-CANDIDATE.
737
+ [[ "${lines[0]}" != "CLOSE-CANDIDATE "* ]]
738
+ [[ "${lines[0]}" != "CLOSE-CANDIDATE-WITH-CAVEAT "* ]]
739
+ }
740
+
741
+ # ── Cumulative shape annotation (architect Q4 — cumulative is correct) ───────
742
+
743
+ @test "evaluate-relevance: multi-shape match emits cumulative shapes: list" {
744
+ cat > docs/decisions/041-multi-match.proposed.md <<EOF
745
+ ---
746
+ status: "proposed"
747
+ human-oversight: confirmed
748
+ oversight-date: 2026-05-25
749
+ ---
750
+ # ADR-041
751
+ EOF
752
+ git add docs/decisions/041-multi-match.proposed.md
753
+
754
+ cat > docs/problems/open/190-multi-shape.md <<EOF
755
+ # Problem 190: multi-shape
756
+
757
+ **Status**: Open
758
+ **Reported**: $OLD_DATE
759
+
760
+ ## Description
761
+
762
+ ADR-041 captures the design. Fix shipped.
763
+
764
+ ## Fix Released
765
+
766
+ Implemented and confirmed.
767
+ EOF
768
+ run "$SCRIPT" docs/problems/open/190-multi-shape.md
769
+ [ "$status" -eq 0 ]
770
+ # Both shape 2 (ADR-confirmed) and shape 4 (Fix Released line) match.
771
+ [[ "$output" == *"ADR-shipped-confirmed"* ]]
772
+ [[ "$output" == *"self-marker-in-body"* ]]
773
+ }
@@ -56,7 +56,7 @@ The `.verifying.md` suffix distinguishes "fix released, awaiting user verificati
56
56
  | **Known Error** | `.known-error.md` | Root cause confirmed, fix path clear, **fix NOT yet released** | Root cause documented, reproduction test exists, workaround in place |
57
57
  | **Verification Pending** | `.verifying.md` | Fix released, awaiting user verification (ADR-022) | Fix shipped; `## Fix Released` section written; user action remaining |
58
58
  | **Parked** | `.parked.md` | Blocked on upstream or suspended by user decision | Upstream blocker identified, or user explicitly suspends; reason and un-park trigger documented |
59
- | **Closed** | `.closed.md` | Fix verified in production OR ticket determined no longer relevant via evidence | (a) User explicitly confirms the released fix works (canonical Verifying → Closed path), OR (b) auto-closed by `/wr-itil:review-problems` Step 4.6 relevance-close pass per ADR-079 Phase 1 — file-no-longer-exists evidence shape with `## Closed as no longer relevant` audit section per ADR-026 grounding (extends ADR-022 lifecycle: Open\|Known Error → Closed bypasses Verifying when no fix was released) |
59
+ | **Closed** | `.closed.md` | Fix verified in production OR ticket determined no longer relevant via evidence | (a) User explicitly confirms the released fix works (canonical Verifying → Closed path), OR (b) auto-closed by `/wr-itil:review-problems` Step 4.6 relevance-close pass per ADR-079 Phase 1 + Phase 2 evidence shapes `file-no-longer-exists` / `ADR-shipped-confirmed` / `named-skill-or-feature-exists` / `self-marker-in-body` / `driver-child-ticket-closed` (cumulative; multi-shape matches emit comma-joined) with `## Closed as no longer relevant` audit section per ADR-026 grounding (extends ADR-022 lifecycle: Open\|Known Error → Closed bypasses Verifying when no fix was released). Partial-scope umbrellas emit `CLOSE-CANDIDATE-WITH-CAVEAT` and ride the maintainer's `AskUserQuestion` surface-batch-confirm path. |
60
60
 
61
61
  **Parked problems** are excluded from WSJF ranking and work selection. They are listed separately in review output so users can see them without them polluting the backlog. To park a problem:
62
62
  1. **If the park reason is `upstream-blocked`**, run the external-root-cause detection block at Step 7 first (see "External-root-cause detection (P063)"). Park without recording the upstream dependency in `## Related` would be the canonical audit-trail gap this block closes.
@@ -208,9 +208,25 @@ The `## Inbound Upstream Reports` README section (ADR-062 § Step 9e renderer pe
208
208
 
209
209
  When invoked from `/wr-itil:work-problems` Step 6.5 (AFK orchestrator), Step 4.5 runs silently per the mechanical-stage carve-out. The only user-attention surface during AFK is the existing external-comms gate UX (a known interrupt class per ADR-028 amended); per-branch `AskUserQuestion` would re-introduce the friction P132 was engineered to remove.
210
210
 
211
- ### 4.6. Relevance-close pass (P346 / ADR-079 Phase 1)
211
+ ### 4.6. Relevance-close pass (P346 / P347 / ADR-079 Phase 1 + Phase 2)
212
212
 
213
- For each `.open.md` / `.known-error.md` ticket aged ≥ 7 days, evaluate whether the ticket has become **no longer relevant** by checking observable evidence per ADR-026 grounding. Phase 1 scope: auto-close on the single evidence shape "file no longer exists in codebase" closest analog to P334/P336 close-on-evidence patterns. Other evidence shapes (ADR-supersession, duplicate-of-X, "concern no longer concerning", SKILL-contract-superseded) are deferred to sibling tickets per ADR-079 scope discipline.
213
+ For each `.open.md` / `.known-error.md` ticket aged ≥ 7 days, evaluate whether the ticket has become **no longer relevant** by checking observable evidence per ADR-026 grounding. Phase 1 + Phase 2 cover **five evidence shapes** grounded in the 14-fixture labeled close-on-evidence set from the 2026-05-31 foreground relevance-scan (the regression suite per ADR-052 lives at `packages/itil/scripts/test/evaluate-relevance.bats`):
214
+
215
+ | Shape | Phase | Mechanical check | Empirical closes (2026-05-31) |
216
+ |---|---|---|---|
217
+ | 1. `file-no-longer-exists` | Phase 1 | grep ticket body for `(packages\|docs\|...)/...\.(md\|sh\|...)`; verify each via `git ls-files --error-unmatch` | 0 of 14 |
218
+ | 2. `ADR-shipped-confirmed` | Phase 2 | grep ticket body for `ADR-NNN`; for each, verify `docs/decisions/<NNN>-*.md` exists AND frontmatter has `human-oversight: confirmed` | 8 of 14 — P012/P015/P018/P022/P033/P039/P194/P292 |
219
+ | 3. `named-skill-or-feature-exists` | Phase 2 | grep for SKILL.md / hook / agent paths + `/wr-<plugin>:<skill>` slash-command refs; verify each via `git ls-files` | 6 of 14 — P014/P034/P045/P079/P190/P289 |
220
+ | 4. `self-marker-in-body` | Phase 2 | line-anchored grep for `Close to (Verifying\|Closed)`, `DONE 2026-`, `## Fix Released` heading, `fix shipped session`, `awaiting K→V`. Pattern MUST anchor to line-start to avoid mid-prose false-positives (architect advisory A2) | explicit in P289; contributory in P033 |
221
+ | 5. `driver-child-ticket-closed` | Phase 2 | parse `## Related` for `P<NNN>` refs; check if any are in `docs/problems/closed/`. Suppressed when child names an unbuilt SKILL/agent path (future work, not stale; architect advisory A1) | contributory in several closes |
222
+
223
+ **Phase 1 false-positive fixes** (the iter-4 60% false-positive rate is structurally addressed; each fix routes the candidate to `KEEP-WITH-NOTE` rather than auto-close):
224
+
225
+ - **P180 — state-suffix detection**: per-state subdirs (`open|known-error|verifying|closed|parked` for problems; `investigating|mitigating|restored` for incidents) AND `.<state>.md` suffix variants.
226
+ - **P244 — sibling-file detection**: dir-glob the parent dir for files with similar slug-prefix (first 2 dash-tokens).
227
+ - **P251 — rename detection**: `git log --follow --diff-filter=AD --name-only` surfaces the renamed-to path.
228
+
229
+ Tickets with no extractable evidence (no file refs, no ADR refs, no SKILL refs, no self-markers, no closed drivers) route to `SKIP`. Other evidence shapes (ADR-supersession via `.superseded.md`, duplicate-of-X, "concern no longer concerning", test-passes-without-issue) are deferred to sibling tickets per ADR-079 scope discipline.
214
230
 
215
231
  **User direction (verbatim, 2026-05-31)**: *"Ok, I'm happy for a skill executed as part of review problems that closes tickets that are no longer relevant, but not just because they are old"* — the relevance signal MUST be observable; age is a **gating** condition (don't bother evaluating fresh tickets), never the **closing** condition. The 7-day gate is conservative; tickets younger than that are likely still actionable.
216
232
 
@@ -228,28 +244,40 @@ Exit-code routing (one verdict line per ticket on stdout):
228
244
 
229
245
  | Exit | Stdout prefix | Action |
230
246
  |------|--------------|--------|
231
- | 0 | `CLOSE-CANDIDATE <basename> — all <N> file paths absent: <semicolon list>` | Auto-close branch (4.6b). |
247
+ | 0 | `CLOSE-CANDIDATE <basename> — shapes: <comma-list> <per-shape cite>; ...` | Auto-close branch (4.6b). |
248
+ | 0 | `CLOSE-CANDIDATE-WITH-CAVEAT <basename> — shapes: <comma-list> — caveat: <short-tag>: <one-line> — cites: ...` | Surface-batch-confirm branch (4.6b-with-caveat); the caveat short-tag + one-line splices verbatim into the audit section's **Caveat** field per architect condition C2. |
232
249
  | 1 | `KEEP <basename> — <M>/<N> paths still present` | No action; log only. |
233
- | 2 | `SKIP <basename> — <reason>` | No action (age gate, no Reported date, no extractable paths). |
250
+ | 1 | `KEEP-WITH-NOTE <basename> — <note>: <evidence>` | Phase 1 false-positive class (state-suffix / sibling-file / rename) OR architect-A1 future-work disambiguation. No action; log only. |
251
+ | 2 | `SKIP <basename> — <reason>` | No action (age gate, no Reported date, no extractable evidence). |
234
252
  | 3 | error | Log advisory; do not abort the pass — relevance-close is non-blocking per the Step 4.5 fail-soft precedent. |
235
253
 
236
- **Algorithm (canonical body)**: extracts file-path candidates from the ticket body matching `(packages|docs|.changeset|src|test|scripts)/[A-Za-z0-9._/-]+\.(md|sh|ts|tsx|js|jsx|json|yml|yaml|bats|py|txt|html)`, drops self-references to `docs/problems/*`, then runs `git ls-files --error-unmatch <path>` for each surviving candidate. The `CLOSE-CANDIDATE` verdict fires only when **all** extracted paths return zero AND **at least one** path was extracted. This is intentionally conservative — tickets with no extractable file paths route to `SKIP no-extractable-paths`, not auto-close.
254
+ **Algorithm (canonical body)**: runs each of the five shape detectors over the ticket body. Multi-shape matches emit cumulatively (corroborating evidence is stronger than first-match-wins per ADR-026): the `shapes:` field carries a comma-joined list, the trailing fragment carries per-shape cites semicolon-separated. The caveat fires when at least one shape matches AND the body has any unticked checkboxes (multi-phase mixed-progress umbrella class). The verdict is intentionally conservative — tickets with no shape match AND no extractable evidence route to `SKIP`, not auto-close.
255
+
256
+ **Surface-batch-confirm flow** (the methodology that produced today's 14 closes — codified for repeatable use):
257
+
258
+ 1. **Surface a batch** — run the evaluator across the dual-tolerant glob in 4.6a; group all `CLOSE-CANDIDATE` and `CLOSE-CANDIDATE-WITH-CAVEAT` verdicts as a batch.
259
+ 2. **Interactive surface (`AskUserQuestion`)** — present each batch of ~5 candidates with their shape annotations + caveats; user confirms close / amend / defer. Surface caveat tickets adjacent to their clean-close siblings so the maintainer sees the full batch class together. The interactive batch is the one-and-only `AskUserQuestion` per relevance-close pass (mechanical-stage carve-out per ADR-044 cat 4 + P132 — do NOT ask per-ticket; ask per-batch).
260
+ 3. **AFK (`/wr-itil:work-problems` Step 6.5)** — close clean `CLOSE-CANDIDATE` verdicts silently per ADR-013 Rule 5 + ADR-044 cat 4 (file existence + frontmatter inspection + line-anchored grep are empirical). Route `CLOSE-CANDIDATE-WITH-CAVEAT` verdicts to the next interactive review's `AskUserQuestion` surface — the caveat short-tag is the maintainer's decision input.
261
+ 4. **Batched closure commit per ADR-014** — all relevance-closes from one review pass batch into ONE commit (mirroring `/wr-itil:transition-problems` P139 batch grain).
262
+
263
+ Real-backlog smoke test 2026-05-31 against today's labeled fixtures: P012 → `CLOSE-CANDIDATE-WITH-CAVEAT` (shapes 2 + 5 + multi-phase-mixed-progress caveat); P136 → `KEEP-WITH-NOTE` (sibling-file class); P303/P326 → `SKIP` (age gate, recent observations).
237
264
 
238
265
  #### 4.6b. Auto-close action per CLOSE-CANDIDATE
239
266
 
240
- For each `CLOSE-CANDIDATE` ticket, perform the following BEFORE the `git mv`:
267
+ For each `CLOSE-CANDIDATE` or `CLOSE-CANDIDATE-WITH-CAVEAT` ticket, perform the following BEFORE the `git mv`:
241
268
 
242
269
  1. Use the `Edit` tool to append a `## Closed as no longer relevant` section to the ticket body (cite + persist + uncertainty per ADR-026):
243
270
 
244
271
  ```markdown
245
272
  ## Closed as no longer relevant
246
273
 
247
- - **Evidence shape**: file-no-longer-exists (ADR-079 Phase 1)
274
+ - **Evidence shape**: <comma-joined list from the verdict's `shapes:` field — e.g. `ADR-shipped-confirmed, self-marker-in-body` for multi-shape match> (ADR-079 Phase 1 + Phase 2)
248
275
  - **Closed on**: <YYYY-MM-DD>
249
276
  - **Closed by**: /wr-itil:review-problems Step 4.6 relevance-close pass
250
- - **Cite (paths checked, all absent in `git ls-files`)**: <semicolon-separated list from the CLOSE-CANDIDATE verdict>
277
+ - **Cite (per-shape evidence)**: <semicolon-separated per-shape cites from the trailing fragment of the verdict line>
278
+ - **Caveat (if CLOSE-CANDIDATE-WITH-CAVEAT)**: `<short-tag>: <one-line>` from the verdict's `caveat:` field (splice verbatim — preserves ADR-026 uncertainty leg structurally per architect condition C2). Omit this field for plain CLOSE-CANDIDATE verdicts.
251
279
  - **Persist**: this section is committed in the ticket file itself; the script body at `packages/itil/scripts/evaluate-relevance.sh` is the re-runnable verdict source per ADR-026
252
- - **Uncertainty / reversibility**: verdict is deterministic given the path set. False-positive remediation: `git revert` the relevance-close commit OR `git mv` the ticket back to its prior state if a missed rename surfaces. The ≥7-day age gate guards against premature evaluation.
280
+ - **Uncertainty / reversibility**: verdict is deterministic given the body + git state. False-positive remediation: `git revert` the relevance-close commit OR `git mv` the ticket back to its prior state. The ≥7-day age gate + Phase 1 false-positive fixes (state-suffix / sibling-file / rename) + shape-cumulative annotation guard against premature evaluation.
253
281
  ```
254
282
 
255
283
  2. `git mv` the ticket from its current state directory to `closed/` (lifecycle extension per ADR-079 — Open|Known Error → Closed bypasses Verifying because no fix was released; conclusion is "no fix needed"):
@@ -288,9 +316,11 @@ Step 5's README refresh rides the same commit per ADR-014 single-commit grain
288
316
 
289
317
  The relevance-close pass runs **unconditionally** during AFK orchestration (`/wr-itil:work-problems` Step 6.5). File existence is empirical, not user-judgment — the mechanical-stage carve-out (P132) applies per ADR-044 category-4 silent framework action. Do NOT fire `AskUserQuestion` per CLOSE-CANDIDATE; the framework has already resolved the close-on-empirical-evidence question.
290
318
 
291
- **Worked example (real backlog smoke test, 2026-05-31)**: across 143 open / known-error tickets, Phase 1 surfaced 6 CLOSE-CANDIDATEs (4.2%) — tickets referencing files renamed or removed but whose lifecycle close was missed. 44 routed to KEEP (file paths still present), 93 to SKIP (age gate, no extractable paths, or no Reported date). The 4.2% close rate matches expectations for a 47-day-old backlog with one-time outflow gap closure.
319
+ **Worked example (Phase 1 smoke test, 2026-05-31)**: across 143 open / known-error tickets, Phase 1 surfaced 6 CLOSE-CANDIDATEs (4.2%) — but the post-batch-1 verification showed 60% of those were false-positives (state-suffix / sibling-file / rename class). Phase 2's false-positive fixes route those to `KEEP-WITH-NOTE`. The same-day foreground relevance-scan that used the broader Phase 2 shape vocabulary produced 14 actual closes across 5 batches empirically calibrating the Phase 2 shape set.
320
+
321
+ **Worked example (Phase 2 surface-batch-confirm, 2026-05-31)**: 14 closes across 5 batches using shapes 2-5. Each batch surfaced via `AskUserQuestion` (≤ 5 candidates per batch); maintainer confirmed clean closes and routed caveat candidates with explicit caveat acknowledgement (e.g. P039 `shared-template-not-built`; P194 `deep-dive-bloat-remains`). All closures batched into per-batch commits per ADR-014. The 14-fixture labeled set is the regression suite (`packages/itil/scripts/test/evaluate-relevance.bats` covers each shape positive + the architect A1/A2 advisory negatives).
292
322
 
293
- **Cross-references**: ADR-079 (this pass's design ADR), ADR-026 (grounding), ADR-022 + ADR-079 lifecycle extension (Open|Known Error → Closed bypassing Verifying for no-fix-needed conclusions), ADR-049 (PATH shim), ADR-052 (behavioural bats at `packages/itil/scripts/test/evaluate-relevance.bats`), ADR-014 (commit grain), P057 (staging trap), P346 (driver ticket).
323
+ **Cross-references**: ADR-079 (this pass's design ADR, Phase 1 + Phase 2), ADR-026 (grounding, cumulative shape cite + structured caveat field), ADR-022 + ADR-079 lifecycle extension (Open|Known Error → Closed bypassing Verifying for no-fix-needed conclusions; the Closed-row entry at `/wr-itil:manage-problem` SKILL.md line 59 names Phase 1 + Phase 2 shapes), ADR-049 (PATH shim), ADR-052 (behavioural bats at `packages/itil/scripts/test/evaluate-relevance.bats` — 33/33 GREEN), ADR-014 (batched closure commit grain per pass), ADR-044 cat 4 + P132 (mechanical-stage carve-out: ask per-batch, not per-ticket), P057 (staging trap), P346 (Phase 1 driver), P347 (Phase 2 driver).
294
324
 
295
325
  ### 5. Rewrite `docs/problems/README.md`
296
326