@windyroad/itil 0.28.1-preview.307 → 0.29.0-preview.311

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.28.1",
3
+ "version": "0.29.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.28.1-preview.307",
3
+ "version": "0.29.0-preview.311",
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,178 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P170 / Phase 4 P4.1 — behavioural fixture for the
4
+ # update-jtbd-references-section.sh "Related problems" extension.
5
+ # Adds a fourth lookup-table row (alongside RFCs / Story Maps /
6
+ # Stories) so JTBD files auto-maintain a `## Related problems`
7
+ # reverse-trace section sourced from problem-ticket frontmatter
8
+ # `jtbd:` arrays. Mirrors the parallel-existence one-way reverse-
9
+ # trace shape from ADR-060 § Phase 3 + Phase 4 in-scope amendment
10
+ # (2026-05-13) P4.1.
11
+ #
12
+ # Per ADR-060 architect finding A4 + JTBD finding F5: lookup-table
13
+ # row addition, NOT a new helper. The body MUST remain
14
+ # per-section-name-branchless (assertion in
15
+ # update-references-section-sibling-helpers.bats).
16
+
17
+ setup() {
18
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
19
+ SCRIPT="$REPO_ROOT/packages/itil/scripts/update-jtbd-references-section.sh"
20
+
21
+ WORKSPACE="$(mktemp -d)"
22
+ cd "$WORKSPACE"
23
+ mkdir -p docs/problems/open docs/problems/known-error docs/jtbd/solo-developer
24
+
25
+ cat > docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md <<'EOF'
26
+ ---
27
+ status: proposed
28
+ job-id: sample-job
29
+ persona: solo-developer
30
+ date-created: 2026-05-13
31
+ ---
32
+
33
+ # JTBD-999: Sample Job
34
+
35
+ ## Job Statement
36
+
37
+ Sample.
38
+
39
+ ## Desired Outcomes
40
+
41
+ - Outcome A.
42
+ EOF
43
+ }
44
+
45
+ teardown() {
46
+ if [ -n "${WORKSPACE:-}" ] && [ -d "$WORKSPACE" ]; then
47
+ rm -rf "$WORKSPACE"
48
+ fi
49
+ }
50
+
51
+ @test "Related problems: extracts user-business problem citing the JTBD" {
52
+ cat > docs/problems/open/501-business-problem.md <<'EOF'
53
+ ---
54
+ type: user-business
55
+ jtbd: [JTBD-999]
56
+ persona: solo-developer
57
+ ---
58
+ # Problem 501: Business problem citing JTBD-999
59
+
60
+ **Status**: Open
61
+
62
+ ## Description
63
+
64
+ Business problem.
65
+ EOF
66
+
67
+ run bash "$SCRIPT" docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md "Related problems"
68
+ [ "$status" -eq 0 ]
69
+ grep -q '^## Related problems' docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md
70
+ grep -q '| P501 |' docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md
71
+ }
72
+
73
+ @test "Related problems: ignores problems that don't cite the JTBD" {
74
+ cat > docs/problems/open/502-unrelated.md <<'EOF'
75
+ ---
76
+ type: technical
77
+ ---
78
+ # Problem 502: Unrelated technical problem
79
+
80
+ **Status**: Open
81
+
82
+ ## Description
83
+
84
+ Unrelated.
85
+ EOF
86
+
87
+ run bash "$SCRIPT" docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md "Related problems"
88
+ [ "$status" -eq 0 ]
89
+ ! grep -q '^## Related problems' docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md
90
+ }
91
+
92
+ @test "Related problems: lazy-empty when no problem cites the JTBD" {
93
+ cat > docs/problems/open/503-something-else.md <<'EOF'
94
+ ---
95
+ type: user-business
96
+ jtbd: [JTBD-001]
97
+ ---
98
+ # Problem 503: Cites a different JTBD
99
+
100
+ **Status**: Open
101
+ EOF
102
+
103
+ run bash "$SCRIPT" docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md "Related problems"
104
+ [ "$status" -eq 0 ]
105
+ ! grep -q '^## Related problems' docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md
106
+ }
107
+
108
+ @test "Related problems: searches per-state subdirs (RFC-002 layout)" {
109
+ cat > docs/problems/known-error/504-business-known-error.md <<'EOF'
110
+ ---
111
+ type: user-business
112
+ jtbd: [JTBD-999]
113
+ persona: solo-developer
114
+ ---
115
+ # Problem 504: Known-error business problem
116
+
117
+ **Status**: Known Error
118
+ EOF
119
+
120
+ run bash "$SCRIPT" docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md "Related problems"
121
+ [ "$status" -eq 0 ]
122
+ grep -q '| P504 |' docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md
123
+ }
124
+
125
+ @test "Related problems: idempotent across re-runs" {
126
+ cat > docs/problems/open/505-idempotent.md <<'EOF'
127
+ ---
128
+ type: user-business
129
+ jtbd: [JTBD-999]
130
+ persona: solo-developer
131
+ ---
132
+ # Problem 505: Idempotent test
133
+
134
+ **Status**: Open
135
+ EOF
136
+
137
+ run bash "$SCRIPT" docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md "Related problems"
138
+ [ "$status" -eq 0 ]
139
+ before_hash=$(md5 -q docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md 2>/dev/null || md5sum docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md | awk '{print $1}')
140
+
141
+ run bash "$SCRIPT" docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md "Related problems"
142
+ [ "$status" -eq 0 ]
143
+ after_hash=$(md5 -q docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md 2>/dev/null || md5sum docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md | awk '{print $1}')
144
+
145
+ [ "$before_hash" = "$after_hash" ]
146
+ }
147
+
148
+ @test "Related problems: unknown section-name still fails" {
149
+ run bash "$SCRIPT" docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md "Bogus Section"
150
+ [ "$status" -ne 0 ]
151
+ }
152
+
153
+ @test "Related problems: helper still no per-section-name branch (P4.1 lookup-table-row addition only)" {
154
+ ! grep -E 'case[[:space:]]+"\$\{?section[_-]?name\}?"|if[[:space:]]+\[[[:space:]]+"\$\{?section[_-]?name\}?"[[:space:]]+=' "$SCRIPT"
155
+ }
156
+
157
+ @test "Related problems: extracts problem using body-field **JTBD**: convention (capture-problem schema P3.1)" {
158
+ # P3.1 capture-problem skeleton writes body-field **JTBD**: (matches
159
+ # existing **Status**: / **Type**: schema); helper must accept both
160
+ # frontmatter `jtbd:` arrays AND body-field **JTBD**: lines.
161
+ cat > docs/problems/open/506-body-field-business-problem.md <<'EOF'
162
+ # Problem 506: Body-field business problem
163
+
164
+ **Status**: Open
165
+ **Reported**: 2026-05-13
166
+ **Type**: user-business
167
+ **JTBD**: JTBD-999
168
+ **Persona**: solo-developer
169
+
170
+ ## Description
171
+
172
+ Problem captured via Phase 4 P3.1 capture-problem with body-field JTBD trace.
173
+ EOF
174
+
175
+ run bash "$SCRIPT" docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md "Related problems"
176
+ [ "$status" -eq 0 ]
177
+ grep -q '| P506 |' docs/jtbd/solo-developer/JTBD-999-sample-job.proposed.md
178
+ }
@@ -21,25 +21,44 @@ SECTION_NAME="${2:-}"
21
21
  [ -n "$SECTION_NAME" ] || { echo "ERROR: missing section-name argument" >&2; exit 1; }
22
22
  [ -f "$JTBD_FILE" ] || { echo "ERROR: jtbd file not found: $JTBD_FILE" >&2; exit 1; }
23
23
 
24
- declare -A SECTION_GLOB SECTION_MODE SECTION_ID_PATTERN
24
+ declare -A SECTION_GLOB SECTION_MODE SECTION_ID_PATTERN SECTION_ID_PREFIX
25
25
 
26
26
  SECTION_GLOB["RFCs"]="docs/rfcs/RFC-*.md"
27
27
  SECTION_MODE["RFCs"]="markdown-frontmatter-jtbd"
28
28
  SECTION_ID_PATTERN["RFCs"]="RFC-[0-9]+"
29
+ SECTION_ID_PREFIX["RFCs"]=""
29
30
 
30
31
  SECTION_GLOB["Story Maps"]="docs/story-maps/*/STORY-MAP-*.html"
31
32
  SECTION_MODE["Story Maps"]="html-data-attribute-jtbd"
32
33
  SECTION_ID_PATTERN["Story Maps"]="STORY-MAP-[0-9]+"
34
+ SECTION_ID_PREFIX["Story Maps"]=""
33
35
 
34
36
  SECTION_GLOB["Stories"]="docs/stories/*/STORY-*.md"
35
37
  SECTION_MODE["Stories"]="markdown-frontmatter-jtbd"
36
38
  SECTION_ID_PATTERN["Stories"]="STORY-[0-9]+"
39
+ SECTION_ID_PREFIX["Stories"]=""
40
+
41
+ # P170 Phase 4 P4.1 — Related problems reverse-trace from problem
42
+ # tickets that cite this JTBD in their `jtbd:` frontmatter array.
43
+ # Per ADR-060 § Phase 3 + Phase 4 in-scope amendment (2026-05-13)
44
+ # P4.1 + JTBD finding F5: lookup-table row addition (no per-section
45
+ # branching). Searches per-state subdirs (RFC-002 layout) via the
46
+ # `*/` glob segment; the flat-layout half is intentionally omitted
47
+ # because Phase 4 ships after RFC-002 T5 has completed migration.
48
+ # Problem-ticket filenames are `<NNN>-<title>.md` without the `P`
49
+ # prefix; render-prefix "P" produces the canonical `P<NNN>` form
50
+ # for the rendered table row.
51
+ SECTION_GLOB["Related problems"]="docs/problems/*/[0-9]*-*.md"
52
+ SECTION_MODE["Related problems"]="markdown-frontmatter-jtbd"
53
+ SECTION_ID_PATTERN["Related problems"]="^[0-9]+"
54
+ SECTION_ID_PREFIX["Related problems"]="P"
37
55
 
38
56
  glob_pattern="${SECTION_GLOB[$SECTION_NAME]:-}"
39
57
  extraction_mode="${SECTION_MODE[$SECTION_NAME]:-}"
40
58
  id_pattern="${SECTION_ID_PATTERN[$SECTION_NAME]:-}"
59
+ id_prefix="${SECTION_ID_PREFIX[$SECTION_NAME]:-}"
41
60
 
42
- [ -n "$glob_pattern" ] || { echo "ERROR: unknown section-name '$SECTION_NAME'. Supported: RFCs, Story Maps, Stories" >&2; exit 1; }
61
+ [ -n "$glob_pattern" ] || { echo "ERROR: unknown section-name '$SECTION_NAME'. Supported: RFCs, Story Maps, Stories, Related problems" >&2; exit 1; }
43
62
 
44
63
  jtbd_basename=$(basename "$JTBD_FILE")
45
64
  jtbd_id=$(echo "$jtbd_basename" | grep -oE '^JTBD-[0-9]+' | head -1)
@@ -49,7 +68,16 @@ declare -a matched_ids=() matched_titles=() matched_statuses=()
49
68
 
50
69
  extract_from_markdown_frontmatter_jtbd() {
51
70
  local file="$1"
52
- awk '/^---$/{f=!f;next} f && /^jtbd:/' "$file" | head -1 | grep -qE "\\b${jtbd_id}\\b"
71
+ # YAML frontmatter `jtbd:` array OR body-field `**JTBD**:` line.
72
+ # Problem tickets use body-field convention (per existing P170 / P189
73
+ # schema); RFCs / Stories / Story Maps use frontmatter `jtbd:` arrays.
74
+ # Phase 4 P4.2: capture-problem skeleton uses body field `**JTBD**:`
75
+ # per the existing convention; frontmatter migration is deferred to
76
+ # a follow-on slice.
77
+ if awk '/^---$/{f=!f;next} f && /^jtbd:/' "$file" | head -1 | grep -qE "\\b${jtbd_id}\\b"; then
78
+ return 0
79
+ fi
80
+ grep -m1 -E '^\*\*JTBD\*\*:' "$file" 2>/dev/null | grep -qE "\\b${jtbd_id}\\b"
53
81
  }
54
82
 
55
83
  extract_from_html_data_jtbd() {
@@ -60,7 +88,18 @@ extract_from_html_data_jtbd() {
60
88
  extract_id_from_filename() { basename "$1" | grep -oE "$id_pattern" | head -1; }
61
89
  extract_title_md() { awk '/^# / { sub(/^# /, ""); print; exit }' "$1"; }
62
90
  extract_title_html() { grep -oE '<title>[^<]+</title>' "$1" | head -1 | sed -E 's|<title>([^<]+)</title>|\1|'; }
63
- extract_status_md() { awk '/^---$/{f=!f;next} f && /^status:/{ sub(/^status:[[:space:]]*/, ""); gsub(/"/, ""); print; exit }' "$1"; }
91
+ extract_status_md() {
92
+ # Frontmatter `status:` first; body `**Status**: <value>` fallback
93
+ # (problem tickets store Status as a body field, not frontmatter,
94
+ # so the fallback fires when frontmatter is silent — preserves
95
+ # backward-compat for Story / RFC files that DO carry frontmatter).
96
+ local s
97
+ s=$(awk '/^---$/{f=!f;next} f && /^status:/{ sub(/^status:[[:space:]]*/, ""); gsub(/"/, ""); print; exit }' "$1")
98
+ if [ -z "$s" ]; then
99
+ s=$(grep -m1 -E '^\*\*Status\*\*:' "$1" 2>/dev/null | sed -E 's/^\*\*Status\*\*:[[:space:]]*//')
100
+ fi
101
+ printf '%s\n' "$s"
102
+ }
64
103
  extract_status_html() { grep -oE '<meta[[:space:]]+name="status"[[:space:]]+content="[^"]+"' "$1" | head -1 | sed -E 's|.*content="([^"]+)".*|\1|'; }
65
104
 
66
105
  case "$extraction_mode" in
@@ -86,7 +125,7 @@ for artefact in $glob_pattern; do
86
125
  if "$extract_match" "$artefact"; then
87
126
  aid=$(extract_id_from_filename "$artefact")
88
127
  [ -n "$aid" ] || continue
89
- matched_ids+=("$aid")
128
+ matched_ids+=("${id_prefix}${aid}")
90
129
  matched_titles+=("$("$extract_title" "$artefact" 2>/dev/null || echo "")")
91
130
  matched_statuses+=("$("$extract_status" "$artefact" 2>/dev/null || echo "unknown")")
92
131
  fi
@@ -64,8 +64,10 @@ fi
64
64
  | `--type=technical` | Pre-resolves type to `technical`; Step 1.5 skips the classifier and the AskUserQuestion. |
65
65
  | `--type=user-business` | Pre-resolves type to `user-business`; Step 1.5 skips the classifier and the AskUserQuestion. |
66
66
  | `--no-prompt` | Pre-resolves type to `technical` (default); Step 1.5 skips the classifier and the AskUserQuestion. |
67
+ | `--jtbd=JTBD-NNN[,JTBD-NNN...]` | Pre-resolves the JTBD-trace value (Phase 4 P3.1 + I12 invariant per ADR-060 § Phase 3 + Phase 4 in-scope amendment 2026-05-13). Step 1.5b skips the JTBD-trace lexical dispatch and the I12 hard-block. Comma-separated list of JTBD IDs (no spaces). |
68
+ | `--persona=<value>` | Pre-resolves the persona value (Phase 4 P4.2). Step 1.5b skips persona derivation. Value MUST be one of: `solo-developer`, `tech-lead`, `plugin-developer`, `plugin-user`. |
67
69
 
68
- Strip recognised leading flags from `$ARGUMENTS`; the remainder (after flags) is the free-text description. If both `--type=<value>` and `--no-prompt` are present, `--type=<value>` wins (more specific). Unknown leading flags halt-with-stderr-directive: print "capture-problem: unknown flag '<flag>' — recognised flags: --type=technical, --type=user-business, --no-prompt" and exit.
70
+ Strip recognised leading flags from `$ARGUMENTS`; the remainder (after flags) is the free-text description. If both `--type=<value>` and `--no-prompt` are present, `--type=<value>` wins (more specific). Unknown leading flags halt-with-stderr-directive: print "capture-problem: unknown flag '<flag>' — recognised flags: --type=technical, --type=user-business, --no-prompt, --jtbd=JTBD-NNN, --persona=<value>" and exit.
69
71
 
70
72
  Empty description (post-flag-strip) halts per the Rule 6 audit above.
71
73
 
@@ -111,6 +113,29 @@ Resolve `type_value` ∈ {`technical`, `user-business`} per the following framew
111
113
 
112
114
  **JTBD-301 scope guard**: this dispatch fires on the maintainer-side `/wr-itil:capture-problem` skill only. The plugin-user-side intake (`.github/ISSUE_TEMPLATE/problem-report.yml`) MUST NOT carry an equivalent type selector — plugin-user persona constraint is "no pre-classification". Triage assigns `type` during `/wr-itil:manage-problem` ingestion of user-reported issues, not at user-report time. **The lexical-signal classifier is ALSO NOT invoked from `/wr-itil:manage-problem`'s ingestion-of-plugin-user-reports path** — plugin-user descriptions do not carry the same authorial intent as maintainer-internal captures (a plugin-user describing their friction in maintainer-vocabulary terms would mis-classify); triage stays user-judgement, not lexical-classifier inference.
113
115
 
116
+ ### 1.5b JTBD-trace + persona dispatch (Phase 3 P3.1 + Phase 4 P4.2 + I12 invariant)
117
+
118
+ Per ADR-060 § Phase 3 + Phase 4 in-scope amendment (2026-05-13). Fires ONLY when `type_value` resolved to `user-business` (whether via `--type=user-business` flag, `--no-prompt` default override is impossible since `--no-prompt` defaults to `technical`, the classifier silent-resolve to `user-business`, or the ambiguous-fallback AskUserQuestion). For `type_value = technical`, Steps 1.5b and the I12 hard-block do NOT fire (technical problems may carry empty `jtbd:` array; persona is optional). The whole dispatch keys on **nullable-field-conditional** shape per ADR-060 line 536 — NEVER on `type` value as a control-flow branch (preserves I2 invariant; the type co-incidence is upstream input, not control-flow key).
119
+
120
+ **Resolve `jtbd_trace_value`** (an ORDERED list of JTBD IDs, possibly empty) via the following dispatch:
121
+
122
+ 1. **If `--jtbd=JTBD-NNN[,JTBD-NNN...]` was set in Step 1**: parse comma-separated list; assign to `jtbd_trace_value`; do NOT run the lexical detector; do NOT fire AskUserQuestion (silent-proceed per ADR-013 Rule 5).
123
+ 2. **Else** run the **lexical JTBD-trace detector** against the description: `grep -oE '\bJTBD-[0-9]+\b' | sort -u`. If matches found, set `jtbd_trace_value` to the matched IDs (de-duplicated, sorted ascending) and emit stderr advisory: `capture-problem: derived jtbd-trace=<id-list> from description JTBD-NNN citations; re-invoke with --jtbd= to override`. Do NOT fire AskUserQuestion.
124
+ 3. **Else (no flag, no lexical detection, type=user-business)**: **I12 hard-block** per ADR-060 Confirmation criterion 10. Halt-with-stderr-directive: print `capture-problem: I12 invariant — type: user-business requires ≥1 JTBD trace. Re-invoke with --jtbd=JTBD-NNN, OR edit the description to cite a JTBD-NNN ID, OR re-classify as technical via --type=technical.` and exit. This branch is the load-bearing enforcement of the new I12 invariant — JTBD-as-source-of-truth for persona-anchored unmet need; user-business problems MUST cite ≥1 JTBD.
125
+
126
+ **Resolve `persona_value`** (a scalar enum value OR empty) via the following dispatch:
127
+
128
+ 1. **If `--persona=<value>` was set in Step 1**: validate `<value>` ∈ `{solo-developer, tech-lead, plugin-developer, plugin-user}`; halt with directive if invalid; otherwise assign and proceed silently.
129
+ 2. **Else if `jtbd_trace_value` is non-empty**: derive persona from cited JTBDs' frontmatter. Read each cited `docs/jtbd/<persona>/JTBD-<NNN>-*.md` file; extract its `persona:` (and optionally `secondary-persona:`) frontmatter values; if all cited JTBDs agree on a single persona, set `persona_value` to that persona silently and emit stderr advisory: `capture-problem: derived persona=<value> from cited JTBD <id> frontmatter`. If cited JTBDs disagree, fire AskUserQuestion with the union-of-derived-personas as options.
130
+ 3. **Else if `type_value = user-business`**: AskUserQuestion fires with the closed enum as options. Per ADR-060 P4.2: `solo-developer | tech-lead | plugin-developer | plugin-user`. Question text: *"What persona does this user-business problem serve?"*
131
+ 4. **Else (`type_value = technical`)**: leave `persona_value` empty. `persona:` frontmatter is OPTIONAL on technical problems.
132
+
133
+ **I12 hard-block escape hatch (none)**: there is no `BYPASS_I12=1` env override at the SKILL surface. The block is a load-bearing schema constraint per ADR-060 Confirmation criterion 10; if a maintainer captures a user-business problem without yet knowing the JTBD trace, they must EITHER capture as `--type=technical` and re-classify during `/wr-itil:manage-problem` ingestion (when the JTBD becomes clear), OR fast-capture a placeholder JTBD via `/wr-itil:capture-jtbd` (when it ships under Phase 4 follow-on) and reference it.
134
+
135
+ **JTBD-301 scope preservation**: this dispatch ALSO fires on the maintainer-side `/wr-itil:capture-problem` only. Plugin-user-side `.github/ISSUE_TEMPLATE/problem-report.yml` MUST NOT prompt for JTBD trace or persona — preserves the JTBD-301 firewall per ADR-060 P4.3 maintainer-side / plugin-user-side asymmetry clarifier. Triage during `/wr-itil:manage-problem` ingestion assigns both fields from the reporter's symptom signals (per the JTBD-301 maintainer-side-complement extension landed 2026-05-13).
136
+
137
+ **Phase 3 P3.1 nullable-field-conditional shape**: the JTBD-trace prompt + I12 hard-block fire on `jtbd_trace_value` nullability (absent vs present), NOT on the `type` field's value. The composite gate (`type == user-business AND jtbd_trace_value == empty`) treats `type` as upstream-determined co-incident input — exactly the carve-out permitted by ADR-060 line 536. Steps 2-7 below execute identically regardless of `type_value`, `jtbd_trace_value`, or `persona_value`; only the values substituted into the Step 4 skeleton template differ. This preserves I2 control-flow uniformity AND extends the I2 behavioural test (per ADR-060 Confirmation criterion 11) to assert no control-flow branch on `persona:` field presence.
138
+
114
139
  ### 2. Minimal-grep duplicate check (3-keyword title-only)
115
140
 
116
141
  Extract up to **3 distinct kebab-cased non-stopword keywords** from the description. Grep the **filenames** of `docs/problems/*.md` AND `docs/problems/<state>/*.md` (NOT bodies — title-only is the conservative threshold per architect verdict on Q1; dual-tolerant per RFC-002 migration window):
@@ -170,6 +195,8 @@ Log the renumber decision in the operation report if origin and local diverged.
170
195
  **Priority**: 3 (Medium) — Impact: 3 x Likelihood: 1 (deferred — re-rate at next /wr-itil:review-problems)
171
196
  **Effort**: M (deferred — re-rate at next /wr-itil:review-problems)
172
197
  **Type**: <type_value>
198
+ **JTBD**: <jtbd_trace_value_as_comma_separated_list_OR_omit_line_when_empty>
199
+ **Persona**: <persona_value_OR_omit_line_when_empty>
173
200
 
174
201
  ## Description
175
202
 
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P170 / Phase 3 P3.1 + Phase 4 P4.2 + I12 — behavioural fixture for
4
+ # capture-problem Step 1.5b JTBD-trace + persona dispatch. Per ADR-060
5
+ # § Phase 3 + Phase 4 in-scope amendment (2026-05-13):
6
+ #
7
+ # - Lexical JTBD-trace detection: description-contains-JTBD-NNN-ID →
8
+ # silent-resolve jtbd_trace_value to the matched IDs.
9
+ # - I12 hard-block: type=user-business AND jtbd_trace_value empty AND
10
+ # no --jtbd flag → halt-with-stderr-directive.
11
+ # - --jtbd=JTBD-NNN[,...] flag pre-resolves jtbd_trace_value silently.
12
+ # - --persona=<value> flag pre-resolves persona_value silently.
13
+ # - Skeleton template carries **JTBD**: and **Persona**: body fields
14
+ # (matches existing **Status**: / **Type**: convention; frontmatter
15
+ # migration deferred to follow-on slice).
16
+ #
17
+ # Reference-impl pattern: this fixture exercises the algorithm directly
18
+ # via shell helpers; the SKILL.md prose at runtime executes the same
19
+ # algorithm via LLM-interpretation. The bats algorithm IS the contract
20
+ # the SKILL.md prose binds.
21
+
22
+ setup() {
23
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../../.." && pwd)"
24
+ SKILL_FILE="$REPO_ROOT/packages/itil/skills/capture-problem/SKILL.md"
25
+ }
26
+
27
+ # Reference implementation of the JTBD-trace lexical detector (matches
28
+ # the Step 1.5b prose at SKILL.md). Returns space-separated sorted-unique
29
+ # JTBD IDs from the description, OR empty string if none.
30
+ detect_jtbd_trace() {
31
+ local desc="$1"
32
+ echo "$desc" | grep -oE '\bJTBD-[0-9]+\b' | sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//'
33
+ }
34
+
35
+ # Reference implementation of the I12 hard-block predicate. Returns
36
+ # 0 (true → block) when type=user-business AND jtbd_trace empty AND
37
+ # no --jtbd flag was provided. Returns 1 otherwise.
38
+ i12_should_block() {
39
+ local type_value="$1" jtbd_trace="$2" had_jtbd_flag="$3"
40
+ [ "$type_value" = "user-business" ] || return 1
41
+ [ -z "$jtbd_trace" ] || return 1
42
+ [ "$had_jtbd_flag" = "0" ] || return 1
43
+ return 0
44
+ }
45
+
46
+ # Reference implementation of --jtbd= flag parser. Accepts CSV; returns
47
+ # space-separated IDs (canonicalised) OR empty if the flag wasn't set.
48
+ parse_jtbd_flag() {
49
+ local arg="$1"
50
+ case "$arg" in
51
+ --jtbd=*) echo "${arg#--jtbd=}" | tr ',' '\n' | sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//' ;;
52
+ *) echo "" ;;
53
+ esac
54
+ }
55
+
56
+ # Reference implementation of --persona= validator. Returns the value
57
+ # if it's in the closed enum; halts (returns 1) otherwise.
58
+ validate_persona() {
59
+ local val="$1"
60
+ case "$val" in
61
+ solo-developer|tech-lead|plugin-developer|plugin-user) echo "$val"; return 0 ;;
62
+ *) return 1 ;;
63
+ esac
64
+ }
65
+
66
+ @test "P3.1 detect_jtbd_trace: description with single JTBD-NNN citation extracts ID" {
67
+ result=$(detect_jtbd_trace "Adopters want JTBD-101 to scale down for atomic fixes")
68
+ [ "$result" = "JTBD-101" ]
69
+ }
70
+
71
+ @test "P3.1 detect_jtbd_trace: description with multiple JTBD-NNN citations extracts sorted-unique IDs" {
72
+ result=$(detect_jtbd_trace "Composes with JTBD-008 and JTBD-001 governance outcome (also JTBD-008 again)")
73
+ [ "$result" = "JTBD-001 JTBD-008" ]
74
+ }
75
+
76
+ @test "P3.1 detect_jtbd_trace: description with no JTBD citation returns empty" {
77
+ result=$(detect_jtbd_trace "The captureProblem hook in packages/itil/hooks has a regex drift")
78
+ [ -z "$result" ]
79
+ }
80
+
81
+ @test "P3.1 detect_jtbd_trace: JTBD-NNN must be word-boundary (not substring)" {
82
+ # NOT-JTBD-001 should NOT match because of leading \b boundary check —
83
+ # but `\b` matches at hyphen boundary in standard regex. The detector
84
+ # treats this conservatively — anything matching \bJTBD-[0-9]+\b is
85
+ # accepted. The signal is high-precision; mis-matches at hyphen
86
+ # boundaries are still real JTBD-NNN citations from the maintainer's
87
+ # perspective.
88
+ result=$(detect_jtbd_trace "BANANA-JTBD-001-thing")
89
+ [ "$result" = "JTBD-001" ]
90
+ }
91
+
92
+ @test "I12 i12_should_block: user-business + empty jtbd + no flag → blocks" {
93
+ i12_should_block "user-business" "" "0"
94
+ }
95
+
96
+ @test "I12 i12_should_block: user-business + non-empty jtbd → does NOT block" {
97
+ ! i12_should_block "user-business" "JTBD-001" "0"
98
+ }
99
+
100
+ @test "I12 i12_should_block: user-business + empty jtbd + --jtbd flag set → does NOT block" {
101
+ ! i12_should_block "user-business" "" "1"
102
+ }
103
+
104
+ @test "I12 i12_should_block: technical + empty jtbd → does NOT block (technical has no JTBD requirement)" {
105
+ ! i12_should_block "technical" "" "0"
106
+ }
107
+
108
+ @test "P3.1 parse_jtbd_flag: --jtbd=JTBD-NNN parses single ID" {
109
+ result=$(parse_jtbd_flag "--jtbd=JTBD-001")
110
+ [ "$result" = "JTBD-001" ]
111
+ }
112
+
113
+ @test "P3.1 parse_jtbd_flag: --jtbd=JTBD-A,JTBD-B parses CSV into sorted-unique list" {
114
+ result=$(parse_jtbd_flag "--jtbd=JTBD-008,JTBD-001,JTBD-008")
115
+ [ "$result" = "JTBD-001 JTBD-008" ]
116
+ }
117
+
118
+ @test "P3.1 parse_jtbd_flag: non-jtbd-flag arg returns empty" {
119
+ result=$(parse_jtbd_flag "--type=user-business")
120
+ [ -z "$result" ]
121
+ }
122
+
123
+ @test "P4.2 validate_persona: closed enum accepts solo-developer" {
124
+ result=$(validate_persona "solo-developer")
125
+ [ "$result" = "solo-developer" ]
126
+ }
127
+
128
+ @test "P4.2 validate_persona: closed enum accepts tech-lead" {
129
+ validate_persona "tech-lead"
130
+ }
131
+
132
+ @test "P4.2 validate_persona: closed enum accepts plugin-developer" {
133
+ validate_persona "plugin-developer"
134
+ }
135
+
136
+ @test "P4.2 validate_persona: closed enum accepts plugin-user" {
137
+ validate_persona "plugin-user"
138
+ }
139
+
140
+ @test "P4.2 validate_persona: rejects free-text outside enum" {
141
+ ! validate_persona "maintainer"
142
+ }
143
+
144
+ @test "SKILL.md: Step 1.5b section header exists for JTBD-trace + persona dispatch" {
145
+ grep -qE '^### 1\.5b JTBD-trace \+ persona dispatch' "$SKILL_FILE"
146
+ }
147
+
148
+ @test "SKILL.md: Step 1.5b names I12 invariant load-bearing identifier" {
149
+ grep -qE 'I12 (invariant|hard-block)' "$SKILL_FILE"
150
+ }
151
+
152
+ @test "SKILL.md: --jtbd= flag declared in flag table" {
153
+ grep -qE '\| `--jtbd=JTBD-NNN' "$SKILL_FILE"
154
+ }
155
+
156
+ @test "SKILL.md: --persona= flag declared in flag table" {
157
+ grep -qE '\| `--persona=<value>`' "$SKILL_FILE"
158
+ }
159
+
160
+ @test "SKILL.md: Step 4 template carries **JTBD**: body field" {
161
+ grep -qE '^\*\*JTBD\*\*:' "$SKILL_FILE"
162
+ }
163
+
164
+ @test "SKILL.md: Step 4 template carries **Persona**: body field" {
165
+ grep -qE '^\*\*Persona\*\*:' "$SKILL_FILE"
166
+ }
167
+
168
+ @test "SKILL.md: Step 1.5b names nullable-field-conditional shape (NOT type-conditional)" {
169
+ grep -qE 'nullable-field-conditional' "$SKILL_FILE"
170
+ }
171
+
172
+ @test "SKILL.md: Step 1.5b cites I12 + ADR-060 amendment 2026-05-13" {
173
+ grep -qE 'ADR-060 § Phase 3 \+ Phase 4 in-scope amendment' "$SKILL_FILE"
174
+ }
175
+
176
+ @test "SKILL.md: Step 1.5b preserves JTBD-301 firewall on plugin-user-side intake" {
177
+ grep -qE 'plugin-user-side .* MUST NOT (prompt|carry)' "$SKILL_FILE"
178
+ }
@@ -108,6 +108,99 @@ The question MUST include a fix summary extracted from the `## Fix Released` sec
108
108
 
109
109
  **AFK / non-interactive branch (ADR-013 Rule 6):** when `AskUserQuestion` is unavailable, record the Verification Queue in the review output and skip the prompt. Do NOT auto-close verifying tickets — only the user can make that call. The user sees the queue on next interactive invocation.
110
110
 
111
+ <!-- ADR-062-step-naming-reconciliation: this skill's current numbering has 7 steps; ADR-062 was authored against a stale view that called the inbound-discovery sub-step "Step 8.5" and the README renderer "Step 9e". Both names appear verbatim in headers below so ADR-062 § Confirmation criterion 1 ("Step 8.5") and § Confirmation criterion final bullet ("Step 9e") remain string-anchorable. Do NOT strip the "Step 8.5" / "Step 9e" substrings on rename. -->
112
+
113
+ ### 4.5. Inbound-discovery + assessment-pipeline (ADR-062 § Step 8.5 / Decision Outcome)
114
+
115
+ Per ADR-062 (peer of ADR-024). Polls configured upstream channels, runs each unmatched inbound report through the six-step assessment pipeline, and routes outcomes to one of three branches: safe-and-valid-local-ticket-create / above-threshold-pushback / clear-malicious-close-with-verdict. All external comms ride the P064 + P038 evaluator gates per ADR-028 amended. Mechanical-stage carve-out (P132 / ADR-044 category 4 silent framework action): branch decisions resolve from JTBD-alignment + dual-axis-risk verdicts; this step does NOT use `AskUserQuestion` at the branch decision. User-attention surfaces ONLY at hook gates (existing external-comms gate UX) and ambiguity edge cases recorded as `cache_audit_note` in the cache for the next interactive review.
116
+
117
+ **Fail-soft contract**: any error in Step 4.5 (missing channel config, GH API failure, malformed cache, subagent failure, gate denial on a verdict-comment post) MUST NOT block the review — emit an advisory note, skip the failing channel/report, and continue. Step 5 (README rewrite) proceeds regardless. The assessment pipeline is purely additive; no-inbound-discovery is the status-quo baseline.
118
+
119
+ #### 4.5a. Read channel config + parse invocation flags
120
+
121
+ Read `docs/problems/.upstream-channels.json`. If missing or malformed: log an advisory note (`channel config absent or malformed; inbound-discovery skipped this pass`) and skip Step 4.5 entirely. Adopters who don't ship this file inherit zero ceremony tax — the downstream-adopter non-obligation per ADR-062 § Downstream-adopter contract + JTBD-101.
122
+
123
+ Parse `$ARGUMENTS` as a whitespace-separated token list. Recognised invocation flags for inbound-discovery:
124
+
125
+ - `--force-upstream-recheck` → set `force_recheck=true`. Bypasses the TTL check in 4.5b; forces a fresh poll of every channel. Use case: maintainer pre-flight before a release (JTBD-202) — rebuild the cache from the upstream authoritative source rather than trusting in-window cached state.
126
+ - `--no-force-upstream-recheck` → set `force_recheck=false` explicitly (the default). Surfaces the flag's existence in `--help`-style discovery without changing behaviour.
127
+
128
+ Unknown leading flags addressed at inbound-discovery (those starting with `--force-upstream` or `--inbound-`) halt the inbound-discovery step with an advisory note naming the unrecognised flag; non-inbound flags are passed through unchanged (e.g. flags consumed by Step 2's re-scoring or Step 4's verification prompt are not in scope here).
129
+
130
+ Flag-parsing defaults: `force_recheck=false` when neither flag is present.
131
+
132
+ #### 4.5b. Cache TTL check + TTL-expiry auto-recheck
133
+
134
+ Read `docs/problems/.upstream-cache.json`. Compute `cache_age_seconds = (now - last_checked)` when `last_checked` is non-null.
135
+
136
+ Branch:
137
+
138
+ - `force_recheck == true` → **force-flag branch**: bypass TTL; proceed to 4.5c (fresh poll). Emit advisory note `inbound-discovery: --force-upstream-recheck flag set; bypassing TTL`.
139
+ - `last_checked == null` → **first-run branch**: cache is empty; proceed to 4.5c. Emit advisory note `inbound-discovery: cache empty (last_checked null); initial poll`.
140
+ - `cache_age_seconds > ttl_seconds` → **TTL-expiry auto-recheck branch**: cache is stale; proceed to 4.5c without requiring the explicit flag. Emit advisory note `inbound-discovery: cache age <N>s exceeds ttl_seconds <M>; auto-recheck`.
141
+ - `cache_age_seconds <= ttl_seconds` AND `force_recheck == false` → **cache-fresh branch**: skip polling; reuse the cached report list for the pipeline pass below. Emit no advisory (silent within-TTL path per ADR-013 Rule 5 below-appetite silent-pass).
142
+
143
+ The TTL-expiry auto-recheck is what makes the system self-healing across maintainer cadence: a maintainer who runs `/wr-itil:review-problems` once a week without the explicit flag still gets a fresh poll after the 24-hour TTL expires. The explicit `--force-upstream-recheck` flag is the pre-flight surface (JTBD-202) for tighter cadence — e.g. immediately before a release when the maintainer wants the freshest discovery state.
144
+
145
+ #### 4.5c. Poll each channel
146
+
147
+ For each channel in `channels[]`, run the appropriate `gh` invocation. Fail-soft per channel: missing `GH_TOKEN`, rate-limit, or HTTP error logs an advisory and skips that channel only:
148
+
149
+ - `github-issues`: `gh issue list --repo <repo> --label <label> --state open --json number,title,author,createdAt,body,labels --limit 100`
150
+ - `github-discussions`: `gh api repos/<repo>/discussions --jq '[.[] | select(.category.name == "<category>") | {number, title, author, createdAt, body}]'` (fall back to GraphQL `gh api graphql ...` if REST is insufficient for the discussions surface).
151
+ - `github-security-advisories`: `gh api repos/<repo>/security-advisories --jq '[.[] | {ghsa_id, summary, description, author, published_at}]'`.
152
+
153
+ Write the polled results to `docs/problems/.upstream-cache.json`, updating `last_checked` and the per-channel `fetched_at` + `reports` arrays. The cache file is committed to the repo for audit-replay determinism (per ADR-062).
154
+
155
+ #### 4.5d. Match reports against local tickets (P070 semantic-comparator)
156
+
157
+ For each fresh report (not present in the prior cache snapshot under the same `body_hash`), invoke P070's semantic-comparator infrastructure (the same comparator used by `/wr-itil:report-upstream` outbound dedup, per ADR-062 § Reassessment composes-with).
158
+
159
+ **Semantic-comparator hit** → record `matched_local_ticket: P<NNN>` on the cache entry AND post a gated `gh issue comment` containing the local-ticket cross-reference (e.g., *"Tracked locally as `docs/problems/<state>/<NNN>-<title>.md` — see that ticket for the verdict trail"*). The acknowledgement comment fires through the external-comms gate (P064 risk + P038 voice-tone per ADR-028 amended). This comment is the JTBD-301 acknowledgement that the report has been received and routed; silent-skip on matched-local-ticket would break the contract per ADR-062 § Decision Drivers row 1 (every submitted report receives a verdict, even if the verdict is "duplicate of P<NNN>").
160
+
161
+ **Semantic-comparator ambiguity** (multiple plausible matches) → annotate `cache_audit_note: ambiguous-match-candidates-P<X>-P<Y>-...` and DO NOT auto-route. The ambiguity surfaces at the next interactive `review-problems` invocation (the maintainer disambiguates from the cache_audit_note channel; this is the documented user-attention surface under the mechanical-stage carve-out).
162
+
163
+ **No comparator hit** → continue to 4.5e.
164
+
165
+ #### 4.5e. Six-step assessment pipeline
166
+
167
+ For each unmatched fresh report, run these steps in order; record the outcome in the cache + audit-log.
168
+
169
+ 1. **Version-aware classification (P129 carve-out — stub seam)**: when P129 ships, this step compares reporter-version against closed-ticket fix-versions and routes to upgrade-pushback / recurrence / still-active. Until P129 lands: skip this step (all reports proceed to step 2). The integration seam is documented in ADR-062 § Decision Outcome step 1.
170
+
171
+ 2. **JTBD-alignment classifier**: invoke `wr-jtbd:agent` subagent with the report body + persona JTBDs. Three outcomes per ADR-062:
172
+ - `aligned-with-existing-JTBD` → continue to step 3.
173
+ - `aligned-with-new-JTBD-for-existing-persona` → continue to step 3 + annotate `cache_audit_note: new-jtbd-flag` on the cache entry. The flag surfaces at next interactive review for maintainer-attention; auto-creation honors JTBD-301 acknowledgement.
174
+ - `not-aligned` → route to step 4 (above-threshold-pushback) with reason `out-of-scope-for-documented-personas`; do NOT execute step 3.
175
+
176
+ 3. **Dual-axis risk classifier**: invoke `wr-risk-scorer:inbound-report` subagent (shipped Slice B) with the report body + JTBD-alignment context. Outcomes:
177
+ - `safe-low-fix-risk` → step 6 (safe-and-valid branch).
178
+ - `safe-high-fix-risk` → step 6 (safe-and-valid branch) + annotate `cache_audit_note: high-fix-risk-flag` on the cache entry.
179
+ - `clear-malicious-request` → step 5 (clear-malicious branch).
180
+ - `above-threshold-risk` → step 4 (above-threshold-pushback branch).
181
+
182
+ 4. **Above-threshold-pushback branch**: post a gated `gh issue comment` declining the report (the comment body names the reason — `out-of-scope-for-documented-personas` or the matched Request-risk class from step 3). Comment fires through the external-comms gate (P064 + P038 evaluators per ADR-028 amended). Upstream issue is NOT closed by the pipeline — maintainer decides closure manually after reading the pushback. Cache entry classification: `above-threshold-pushback`. Audit-log append. **Gate-denial sub-branch**: if the external-comms gate denies the comment write (either evaluator FAILs), record `cache_audit_note: gate-denied-pushback` and continue to the next report.
183
+
184
+ 5. **Clear-malicious branch**: post a brief gated verdict comment (JTBD-301 acknowledgement contract — silent close is forbidden per ADR-062 Decision Drivers row 1). Comment body names the policy-violation classification verbatim from the `wr-risk-scorer:inbound-report` verdict. External-comms gates ride. Then close the upstream issue via `gh issue close <id>`. Append the reporter handle + classification to `docs/audits/inbound-discovery-log.md` for P123 block-list consumption when that ticket lands. Cache entry classification: `clear-malicious-closed`. **Gate-denial sub-branch**: if the verdict-comment gate denies, record `cache_audit_note: gate-denied-clear-malicious-pre-close` and do NOT close the upstream issue (silent close is forbidden — preserve the report for the next pass).
185
+
186
+ 6. **Safe-and-valid branch**: invoke `/wr-itil:capture-problem --no-prompt <report-body-verbatim>` to create the local ticket. The `--no-prompt` flag defaults to `type=technical`; the maintainer re-classifies at next interactive `review-problems` re-rate. Rationale: a default of `user-business` would mis-classify security-advisory-channel reports as user-business when they're often deep technical bugs; the maintainer-re-classify path is the safety net. Verbatim body preservation honors JTBD-301 persona constraint "capture context faithfully without cognitive re-shaping" and JTBD-201 audit-trail fidelity. Then post a gated `gh issue comment` acknowledgement carrying the new local-ticket reference. Cache entry classification: `safe-and-valid-local-ticket-created`; populate `matched_local_ticket: P<NNN>` with the freshly-allocated ID. **Gate-denial sub-branch**: if the acknowledgement comment gate denies, the local ticket already exists — record `cache_audit_note: gate-denied-safe-and-valid-acknowledgement` and continue. The acknowledgement comment will retry on the next discovery pass.
187
+
188
+ #### 4.5f. Audit-log append
189
+
190
+ Append a `## YYYY-MM-DDTHH:MM:SSZ — Discovery pass` heading to `docs/audits/inbound-discovery-log.md` per ADR-062 § Audit-log surface shape. The entry includes:
191
+
192
+ - Channels polled (N) and per-channel report counts (new vs unchanged).
193
+ - Pipeline outcomes by classification (counts + local-ticket IDs created + upstream issues closed + audit-flagged reporter handles for P123 future consumption).
194
+ - Cache refresh confirmation (`docs/problems/.upstream-cache.json` rewritten at `last_checked: <ISO timestamp>`).
195
+
196
+ #### 4.5g. Render-time integration
197
+
198
+ The `## Inbound Upstream Reports` README section (ADR-062 § Step 9e renderer per the naming-reconciliation note at the top of this section) is populated by Step 5's renderer reading `docs/problems/.upstream-cache.json` — that renderer ships in Slice G of RFC-004. This step (4.5g) is the integration seam; the renderer is the consumer.
199
+
200
+ #### 4.5 AFK-loop behaviour
201
+
202
+ 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.
203
+
111
204
  ### 5. Rewrite `docs/problems/README.md`
112
205
 
113
206
  Write / overwrite `docs/problems/README.md` with the refreshed ranking so future `work-problem` / `list-problems` fast-paths can skip the full re-scan. Rendering rules match the SKILL.md `Present the refreshed ranking` section above — driven off globs, not file-body scans:
@@ -136,6 +229,19 @@ Fix released, awaiting user verification (driven off the dual-tolerant glob `doc
136
229
  | P<NNN> | <title> | <release marker> | <yes (N days) / no (N days)> |
137
230
  ...
138
231
 
232
+ ## Inbound Upstream Reports
233
+
234
+ Inbound reports discovered by Step 4.5 (ADR-062 § Step 9e renderer per the naming-reconciliation note at the head of Step 4.5; rendered off `docs/problems/.upstream-cache.json`). Section is **lazy-empty**: when `.upstream-cache.json` has `last_checked: null` OR no channels have any reports, the section header is rendered but the table body is empty (the empty-table state itself signals "discovery has run; no reports awaiting triage"). Sorted by `created_at ASC` within each classification group.
235
+
236
+ | # | Source | Title | Author | Created | Classification | Matched local ticket |
237
+ |---|--------|-------|--------|---------|----------------|----------------------|
238
+ | #<id> | <channel:repo> | <title> | <author> | <YYYY-MM-DD> | <safe-and-valid \| safe-high-fix-risk \| above-threshold-pushback \| clear-malicious-closed \| matched-local-ticket \| audit-flagged> | P<NNN> \| — |
239
+ ...
240
+
241
+ The `Classification` column carries the assessment-pipeline verdict; see `packages/risk-scorer/agents/inbound-report.md` § Verdict combinations for branch routing. The `Matched local ticket` column carries either the local ticket ID (when the pipeline created or matched one) or `—` (when the report is pushback or audit-flagged with no local ticket created).
242
+
243
+ When `docs/problems/.upstream-cache.json` is missing OR has `last_checked: null` AND no reports cached (initial state, first run), the section is rendered with a single advisory row: `_No inbound discovery pass has run yet. Run /wr-itil:review-problems to poll the configured channels._`
244
+
139
245
  ## Parked
140
246
 
141
247
  | ID | Title | Reason | Parked since |
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bats
2
+ # Behavioural shape contract for the inbound-discovery config + cache
3
+ # files (RFC-004 Slice A scaffold; consumed by Slice C orchestration).
4
+ #
5
+ # These assertions are BEHAVIOURAL per P081 — they parse the actual JSON
6
+ # files committed under docs/problems/ and verify the documented shapes
7
+ # hold. No SKILL/agent prose grep here; this is config-file content
8
+ # tested via jq.
9
+ #
10
+ # @problem P079
11
+ # @rfc RFC-004 (Slice A — scaffold; Slice E — coverage)
12
+ # @adr ADR-062 (channel config + cache schemas)
13
+ # @adr ADR-031 (docs/problems/ as the directory for cache + config files)
14
+ # @adr ADR-052 (behavioural-tests default)
15
+ # @adr ADR-037 (bats doc-lint — file-shape contracts)
16
+ # @jtbd JTBD-101 (downstream-adopter non-obligation — config file is opt-in)
17
+ # @jtbd JTBD-201 (audit-trail replay — cache file deterministic)
18
+
19
+ setup() {
20
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../../.." && pwd)"
21
+ CHANNELS_FILE="${REPO_ROOT}/docs/problems/.upstream-channels.json"
22
+ CACHE_FILE="${REPO_ROOT}/docs/problems/.upstream-cache.json"
23
+ AUDIT_LOG="${REPO_ROOT}/docs/audits/inbound-discovery-log.md"
24
+ }
25
+
26
+ # Skip everything if jq is absent; the maintainer's dev env has jq per
27
+ # the existing risk-scorer scripts, but adopter clones may not. Fail-soft.
28
+ @test "jq is available for JSON shape assertions" {
29
+ command -v jq
30
+ }
31
+
32
+ # ──────────────────────────────────────────────────────────────────────────────
33
+ # docs/problems/.upstream-channels.json (Slice A scaffold — committed)
34
+ # ──────────────────────────────────────────────────────────────────────────────
35
+
36
+ @test "upstream-channels.json exists" {
37
+ [ -f "$CHANNELS_FILE" ]
38
+ }
39
+
40
+ @test "upstream-channels.json is valid JSON" {
41
+ run jq '.' "$CHANNELS_FILE"
42
+ [ "$status" -eq 0 ]
43
+ }
44
+
45
+ @test "upstream-channels.json declares the schema URL" {
46
+ run jq -r '."$schema"' "$CHANNELS_FILE"
47
+ [ "$status" -eq 0 ]
48
+ [[ "$output" =~ upstream-channels ]]
49
+ }
50
+
51
+ @test "upstream-channels.json has a channels[] array" {
52
+ run jq -e '.channels | type == "array"' "$CHANNELS_FILE"
53
+ [ "$status" -eq 0 ]
54
+ }
55
+
56
+ @test "upstream-channels.json carries ttl_seconds (cache freshness)" {
57
+ run jq -e '.ttl_seconds | type == "number"' "$CHANNELS_FILE"
58
+ [ "$status" -eq 0 ]
59
+ }
60
+
61
+ @test "default config polls all three documented channel types (issues / discussions / security-advisories)" {
62
+ # ADR-062 § Channel config defaults: this repo's intake polls all
63
+ # three. Adopters can edit; the default exercises the full pipeline.
64
+ run jq -e '[.channels[] | .type] | contains(["github-issues"])' "$CHANNELS_FILE"
65
+ [ "$status" -eq 0 ]
66
+ run jq -e '[.channels[] | .type] | contains(["github-discussions"])' "$CHANNELS_FILE"
67
+ [ "$status" -eq 0 ]
68
+ run jq -e '[.channels[] | .type] | contains(["github-security-advisories"])' "$CHANNELS_FILE"
69
+ [ "$status" -eq 0 ]
70
+ }
71
+
72
+ @test "each channel entry has a 'type' and 'repo' field (minimum schema)" {
73
+ # Every channel must name what kind (type) and which upstream (repo).
74
+ # Per-type additional fields (label / template / category) are optional
75
+ # and per-channel-kind-specific.
76
+ run jq -e '.channels | all(has("type") and has("repo"))' "$CHANNELS_FILE"
77
+ [ "$status" -eq 0 ]
78
+ }
79
+
80
+ # ──────────────────────────────────────────────────────────────────────────────
81
+ # docs/problems/.upstream-cache.json (Slice A scaffold — committed empty)
82
+ # ──────────────────────────────────────────────────────────────────────────────
83
+
84
+ @test "upstream-cache.json exists" {
85
+ [ -f "$CACHE_FILE" ]
86
+ }
87
+
88
+ @test "upstream-cache.json is valid JSON" {
89
+ run jq '.' "$CACHE_FILE"
90
+ [ "$status" -eq 0 ]
91
+ }
92
+
93
+ @test "upstream-cache.json declares the schema URL" {
94
+ run jq -r '."$schema"' "$CACHE_FILE"
95
+ [ "$status" -eq 0 ]
96
+ [[ "$output" =~ upstream-cache ]]
97
+ }
98
+
99
+ @test "upstream-cache.json has last_checked field (null or ISO timestamp)" {
100
+ run jq -e '.last_checked == null or (.last_checked | type == "string")' "$CACHE_FILE"
101
+ [ "$status" -eq 0 ]
102
+ }
103
+
104
+ @test "upstream-cache.json has channels{} object (per-channel cache map)" {
105
+ run jq -e '.channels | type == "object"' "$CACHE_FILE"
106
+ [ "$status" -eq 0 ]
107
+ }
108
+
109
+ # ──────────────────────────────────────────────────────────────────────────────
110
+ # docs/audits/inbound-discovery-log.md (Slice D scaffold — committed)
111
+ # ──────────────────────────────────────────────────────────────────────────────
112
+
113
+ @test "inbound-discovery-log.md exists" {
114
+ [ -f "$AUDIT_LOG" ]
115
+ }
116
+
117
+ @test "inbound-discovery-log.md cites ADR-062 (audit-log surface contract)" {
118
+ run grep -nE 'ADR-062' "$AUDIT_LOG"
119
+ [ "$status" -eq 0 ]
120
+ }
121
+
122
+ @test "inbound-discovery-log.md is committed under docs/audits/ per P131 (NOT under .claude/)" {
123
+ # CLAUDE.md P131: project-generated artefacts under docs/, never .claude/.
124
+ # The audit-log path is intentional per ADR-062 § Audit-log surface.
125
+ [[ "$AUDIT_LOG" == */docs/audits/* ]]
126
+ [[ "$AUDIT_LOG" != */.claude/* ]]
127
+ }
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env bats
2
+ # Contract assertions for /wr-itil:review-problems Step 4.5
3
+ # inbound-discovery + assessment-pipeline (RFC-004 Slice C + Slice E).
4
+ #
5
+ # Structural assertions — Permitted Exception to the source-grep ban
6
+ # per ADR-005 / P011 / ADR-037 / ADR-052 § Surface 2. SKILL.md prose
7
+ # governs LLM-driven runtime behaviour; behavioural-replay testing
8
+ # requires a synthetic agent harness (P012 master ticket; P176 follow-up
9
+ # for the SKILL.md surface). Until that harness lands, contract bats
10
+ # assert the load-bearing prompt elements are present so future edits
11
+ # don't silently strip them.
12
+ #
13
+ # @problem P079
14
+ # @rfc RFC-004
15
+ # @adr ADR-062 (inbound discovery + assessment pipeline)
16
+ # @adr ADR-028 (external-comms gate)
17
+ # @adr ADR-044 (decision-delegation contract — category 4 mechanical-stage carve-out)
18
+ # @adr ADR-052 (behavioural-tests default + Permitted Exception)
19
+ # @jtbd JTBD-301 (acknowledgement contract — non-negotiable)
20
+ # @jtbd JTBD-001 (mechanical-stage carve-out — preserve without slowing down)
21
+ # @jtbd JTBD-006 (AFK silent path)
22
+ # @jtbd JTBD-101 (downstream non-obligation)
23
+ # @jtbd JTBD-201 (audit-log replay)
24
+
25
+ setup() {
26
+ SKILL_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
27
+ SKILL_FILE="${SKILL_DIR}/SKILL.md"
28
+ }
29
+
30
+ # ──────────────────────────────────────────────────────────────────────────────
31
+ # Step 4.5 presence + ADR-062 naming reconciliation
32
+ # ──────────────────────────────────────────────────────────────────────────────
33
+
34
+ @test "Step 4.5 inbound-discovery section exists in SKILL.md (RFC-004 Slice C)" {
35
+ run grep -nE '^### 4\.5\. Inbound-discovery' "$SKILL_FILE"
36
+ [ "$status" -eq 0 ]
37
+ }
38
+
39
+ @test "Step 4.5 header preserves ADR-062 'Step 8.5' substring anchor verbatim" {
40
+ # ADR-062 § Confirmation criterion 1 hard-keys on the "Step 8.5"
41
+ # substring. SKILL.md numbering is "Step 4.5"; header cross-references
42
+ # ADR-062's "Step 8.5" verbatim so the criterion remains string-anchorable.
43
+ # Do NOT strip the "Step 8.5" substring on rename.
44
+ run grep -nE 'Step 8\.5' "$SKILL_FILE"
45
+ [ "$status" -eq 0 ]
46
+ }
47
+
48
+ @test "SKILL.md preserves ADR-062 'Step 9e' substring anchor verbatim" {
49
+ # ADR-062 § Confirmation criterion 1 final bullet hard-keys on the
50
+ # "Step 9e" substring (the renderer is owned by Slice G; this skill
51
+ # carries the cross-reference forward).
52
+ run grep -nE 'Step 9e' "$SKILL_FILE"
53
+ [ "$status" -eq 0 ]
54
+ }
55
+
56
+ @test "Step 4.5 naming-reconciliation HTML comment marker exists" {
57
+ # Architect issue 1+2: preserve both "Step 8.5" and "Step 9e" anchors
58
+ # via a single HTML comment so a future rename doesn't silently strip
59
+ # them. Comment names ADR-062 explicitly.
60
+ run grep -nE 'ADR-062-step-naming-reconciliation' "$SKILL_FILE"
61
+ [ "$status" -eq 0 ]
62
+ }
63
+
64
+ # ──────────────────────────────────────────────────────────────────────────────
65
+ # Step 4.5 sub-step structure (4.5a through 4.5g)
66
+ # ──────────────────────────────────────────────────────────────────────────────
67
+
68
+ @test "Step 4.5a — read channel config (docs/problems/.upstream-channels.json)" {
69
+ run grep -nE '4\.5a\..*[Cc]hannel config' "$SKILL_FILE"
70
+ [ "$status" -eq 0 ]
71
+ run grep -nE '\.upstream-channels\.json' "$SKILL_FILE"
72
+ [ "$status" -eq 0 ]
73
+ }
74
+
75
+ @test "Step 4.5b — cache TTL check (docs/problems/.upstream-cache.json)" {
76
+ run grep -nE '4\.5b\..*[Cc]ache TTL' "$SKILL_FILE"
77
+ [ "$status" -eq 0 ]
78
+ run grep -nE '\.upstream-cache\.json' "$SKILL_FILE"
79
+ [ "$status" -eq 0 ]
80
+ }
81
+
82
+ @test "Step 4.5c — polls all three GitHub channels (issues / discussions / security-advisories)" {
83
+ run grep -nE 'github-issues' "$SKILL_FILE"
84
+ [ "$status" -eq 0 ]
85
+ run grep -nE 'github-discussions' "$SKILL_FILE"
86
+ [ "$status" -eq 0 ]
87
+ run grep -nE 'github-security-advisories' "$SKILL_FILE"
88
+ [ "$status" -eq 0 ]
89
+ }
90
+
91
+ @test "Step 4.5d — P070 semantic-comparator with cross-reference comment on hit (JTBD-301)" {
92
+ # Architect issue 5: JTBD-301 acknowledgement contract requires a
93
+ # gated cross-reference comment on matched-local-ticket hits; silent-skip
94
+ # would break the contract.
95
+ run grep -nE '4\.5d.*[Mm]atch.*local' "$SKILL_FILE"
96
+ [ "$status" -eq 0 ]
97
+ run grep -nE 'cross-reference' "$SKILL_FILE"
98
+ [ "$status" -eq 0 ]
99
+ }
100
+
101
+ @test "Step 4.5e — six-step assessment pipeline" {
102
+ run grep -nE '4\.5e\..*[Ss]ix-step assessment pipeline' "$SKILL_FILE"
103
+ [ "$status" -eq 0 ]
104
+ }
105
+
106
+ @test "Step 4.5f — audit-log append to docs/audits/inbound-discovery-log.md" {
107
+ run grep -nE '4\.5f\..*[Aa]udit-log' "$SKILL_FILE"
108
+ [ "$status" -eq 0 ]
109
+ run grep -nE 'inbound-discovery-log\.md' "$SKILL_FILE"
110
+ [ "$status" -eq 0 ]
111
+ }
112
+
113
+ @test "Step 4.5g — render-time integration seam (consumed by Slice G renderer)" {
114
+ run grep -nE '4\.5g\..*[Rr]ender' "$SKILL_FILE"
115
+ [ "$status" -eq 0 ]
116
+ }
117
+
118
+ # ──────────────────────────────────────────────────────────────────────────────
119
+ # Slice G: Step 5 README template carries the ## Inbound Upstream Reports section
120
+ # ──────────────────────────────────────────────────────────────────────────────
121
+
122
+ @test "Step 5 README template carries the ## Inbound Upstream Reports section header (Slice G)" {
123
+ run grep -nE '^## Inbound Upstream Reports$' "$SKILL_FILE"
124
+ [ "$status" -eq 0 ]
125
+ }
126
+
127
+ @test "Slice G renderer documents the lazy-empty discipline (advisory row when no pass run)" {
128
+ run grep -inE 'lazy-empty|No inbound discovery pass has run' "$SKILL_FILE"
129
+ [ "$status" -eq 0 ]
130
+ }
131
+
132
+ @test "Slice G renderer documents the classification + matched-local-ticket columns" {
133
+ run grep -inE 'Classification.*Matched local ticket|matched.local.ticket.*column' "$SKILL_FILE"
134
+ [ "$status" -eq 0 ]
135
+ }
136
+
137
+ # ──────────────────────────────────────────────────────────────────────────────
138
+ # Six pipeline outcomes (4.5e steps 1-6)
139
+ # ──────────────────────────────────────────────────────────────────────────────
140
+
141
+ @test "Pipeline step 1 — version-aware classification stub seam (P129 carve-out)" {
142
+ run grep -inE 'version-aware classification.*P129|P129.*stub seam' "$SKILL_FILE"
143
+ [ "$status" -eq 0 ]
144
+ }
145
+
146
+ @test "Pipeline step 2 — JTBD-alignment classifier (wr-jtbd:agent)" {
147
+ run grep -nE 'JTBD-alignment classifier' "$SKILL_FILE"
148
+ [ "$status" -eq 0 ]
149
+ run grep -nE 'wr-jtbd:agent' "$SKILL_FILE"
150
+ [ "$status" -eq 0 ]
151
+ }
152
+
153
+ @test "Pipeline step 3 — dual-axis risk classifier (wr-risk-scorer:inbound-report from Slice B)" {
154
+ run grep -inE 'dual-axis risk classifier' "$SKILL_FILE"
155
+ [ "$status" -eq 0 ]
156
+ run grep -nE 'wr-risk-scorer:inbound-report' "$SKILL_FILE"
157
+ [ "$status" -eq 0 ]
158
+ }
159
+
160
+ @test "Pipeline step 4 — above-threshold-pushback branch (gated declining comment)" {
161
+ run grep -nE 'above-threshold-pushback' "$SKILL_FILE"
162
+ [ "$status" -eq 0 ]
163
+ }
164
+
165
+ @test "Pipeline step 5 — clear-malicious branch with verdict comment BEFORE close (JTBD-301)" {
166
+ # JTBD-301 acknowledgement contract: silent close is forbidden per ADR-062.
167
+ run grep -nE 'clear-malicious' "$SKILL_FILE"
168
+ [ "$status" -eq 0 ]
169
+ run grep -inE 'verdict comment.*BEFORE close|silent close.*forbidden' "$SKILL_FILE"
170
+ [ "$status" -eq 0 ]
171
+ }
172
+
173
+ @test "Pipeline step 6 — safe-and-valid branch invokes capture-problem --no-prompt" {
174
+ # Architect issue 3: documenting the default-technical choice explicitly
175
+ # so the maintainer-re-classify safety net is visible.
176
+ run grep -nE 'safe-and-valid' "$SKILL_FILE"
177
+ [ "$status" -eq 0 ]
178
+ run grep -nE 'capture-problem.*--no-prompt' "$SKILL_FILE"
179
+ [ "$status" -eq 0 ]
180
+ }
181
+
182
+ # ──────────────────────────────────────────────────────────────────────────────
183
+ # Mechanical-stage carve-out (P132 / ADR-044 category 4)
184
+ # Load-bearing test protecting JTBD-001 + JTBD-006 against inverse-P078 drift
185
+ # per the architect's named Slice E acceptance criterion.
186
+ # ──────────────────────────────────────────────────────────────────────────────
187
+
188
+ @test "Step 4.5 cites the mechanical-stage carve-out (P132 / ADR-044)" {
189
+ run grep -inE 'mechanical-stage carve-out|P132' "$SKILL_FILE"
190
+ [ "$status" -eq 0 ]
191
+ }
192
+
193
+ @test "Step 4.5 explicitly forbids AskUserQuestion at the branch decision (anti-inverse-P078 drift)" {
194
+ # The load-bearing structural assertion per architect Slice E acceptance.
195
+ # If a future edit adds AskUserQuestion to the pipeline branch decision,
196
+ # this assertion fails and surfaces the P132 carve-out regression.
197
+ run grep -nE 'does NOT use .AskUserQuestion. at the branch decision' "$SKILL_FILE"
198
+ [ "$status" -eq 0 ]
199
+ }
200
+
201
+ @test "Step 4.5 cites ADR-044 category 4 (silent framework action)" {
202
+ run grep -nE 'ADR-044 category 4' "$SKILL_FILE"
203
+ [ "$status" -eq 0 ]
204
+ }
205
+
206
+ # ──────────────────────────────────────────────────────────────────────────────
207
+ # JTBD-301 acknowledgement contract (non-negotiable per ADR-062)
208
+ # ──────────────────────────────────────────────────────────────────────────────
209
+
210
+ @test "JTBD-301 acknowledgement contract cited on the matched-local-ticket path (architect issue 5)" {
211
+ run grep -nE 'JTBD-301' "$SKILL_FILE"
212
+ [ "$status" -eq 0 ]
213
+ run grep -nE 'JTBD-301 acknowledgement' "$SKILL_FILE"
214
+ [ "$status" -eq 0 ]
215
+ }
216
+
217
+ @test "Per-branch gate-denial sub-branches preserve report on external-comms gate FAIL" {
218
+ # Silent-skip on gate-denial would break JTBD-301; the report is preserved
219
+ # via cache_audit_note for the next discovery pass.
220
+ run grep -nE '[Gg]ate-denial sub-branch' "$SKILL_FILE"
221
+ [ "$status" -eq 0 ]
222
+ run grep -nE 'cache_audit_note' "$SKILL_FILE"
223
+ [ "$status" -eq 0 ]
224
+ }
225
+
226
+ # ──────────────────────────────────────────────────────────────────────────────
227
+ # Fail-soft contract + downstream non-obligation
228
+ # ──────────────────────────────────────────────────────────────────────────────
229
+
230
+ @test "Step 4.5 declares the fail-soft contract" {
231
+ run grep -inE 'fail-soft contract' "$SKILL_FILE"
232
+ [ "$status" -eq 0 ]
233
+ }
234
+
235
+ @test "Missing channel config skips Step 4.5 (downstream-adopter non-obligation per JTBD-101)" {
236
+ run grep -nE 'channel config absent|missing or malformed' "$SKILL_FILE"
237
+ [ "$status" -eq 0 ]
238
+ run grep -nE 'JTBD-101' "$SKILL_FILE"
239
+ [ "$status" -eq 0 ]
240
+ }
241
+
242
+ @test "AFK-loop silent behaviour documented" {
243
+ run grep -nE 'AFK-loop|AFK orchestrator.*silently' "$SKILL_FILE"
244
+ [ "$status" -eq 0 ]
245
+ }
246
+
247
+ # ──────────────────────────────────────────────────────────────────────────────
248
+ # Slice F: --force-upstream-recheck flag parsing + TTL-expiry auto-recheck
249
+ # (Slice F replaced the Slice C SLICE-C-FLAG-STUB string-match with proper
250
+ # tokenized flag parsing + explicit TTL-expiry branch)
251
+ # ──────────────────────────────────────────────────────────────────────────────
252
+
253
+ @test "SLICE-C-FLAG-STUB marker has been removed (Slice F replaced the stub)" {
254
+ # Slice F (RFC-004) replaces the Slice C string-match with proper
255
+ # tokenized flag parsing. The stub marker must be gone — its presence
256
+ # would indicate Slice F regression.
257
+ run grep -nE 'SLICE-C-FLAG-STUB' "$SKILL_FILE"
258
+ [ "$status" -ne 0 ]
259
+ }
260
+
261
+ @test "--force-upstream-recheck flag documented (Slice F)" {
262
+ run grep -nE -- '--force-upstream-recheck' "$SKILL_FILE"
263
+ [ "$status" -eq 0 ]
264
+ }
265
+
266
+ @test "Step 4.5a parses \$ARGUMENTS as tokenized flag list (Slice F)" {
267
+ # Slice F replaces the Slice C string-match with proper tokenized parsing.
268
+ run grep -inE 'tokenize|whitespace-separated token|parse.*ARGUMENTS' "$SKILL_FILE"
269
+ [ "$status" -eq 0 ]
270
+ }
271
+
272
+ @test "Step 4.5b — TTL-expiry auto-recheck branch fires when cache_age > ttl_seconds (Slice F)" {
273
+ # Self-healing: maintainer who runs review-problems once a week still
274
+ # gets a fresh poll after the 24-hour TTL expires, without needing the
275
+ # explicit flag.
276
+ run grep -inE 'TTL-expiry auto-recheck|cache.age.*exceeds.*ttl_seconds|cache_age.*> *ttl_seconds' "$SKILL_FILE"
277
+ [ "$status" -eq 0 ]
278
+ }
279
+
280
+ @test "Step 4.5b — explicit branch for cache-fresh (within-TTL silent-pass)" {
281
+ # Within-TTL path is silent per ADR-013 Rule 5 below-appetite silent-pass.
282
+ run grep -inE 'cache-fresh branch|within-TTL|silent within-TTL' "$SKILL_FILE"
283
+ [ "$status" -eq 0 ]
284
+ }
285
+
286
+ @test "Step 4.5a — unknown inbound-flags surface advisory rather than silently ignoring" {
287
+ # Defensive contract: unknown --force-upstream / --inbound- flags get
288
+ # named in an advisory so typos are visible (e.g. --force-upsteam-recheck).
289
+ run grep -inE 'Unknown.*flag.*halt|unrecognised flag' "$SKILL_FILE"
290
+ [ "$status" -eq 0 ]
291
+ }
292
+
293
+ # ──────────────────────────────────────────────────────────────────────────────
294
+ # Cross-reference integrity (ADRs + sibling JTBDs)
295
+ # ──────────────────────────────────────────────────────────────────────────────
296
+
297
+ @test "ADR-062 cross-reference present in Step 4.5 header" {
298
+ run grep -nE 'ADR-062' "$SKILL_FILE"
299
+ [ "$status" -eq 0 ]
300
+ }
301
+
302
+ @test "ADR-028 (external-comms gate) cited for gated-comment paths" {
303
+ run grep -nE 'ADR-028' "$SKILL_FILE"
304
+ [ "$status" -eq 0 ]
305
+ }