@windyroad/itil 0.34.0 → 0.35.0-preview.344

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.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/bin/wr-itil-plugin-maturity-render
3
+ #
4
+ # ADR-049 shim — resolves the canonical body in this package's scripts/
5
+ # dir. The canonical body lives at
6
+ # `packages/itil/scripts/plugin-maturity-render.sh`; this shim is the
7
+ # `$PATH`-resolvable entrypoint that adopter trees invoke.
8
+ #
9
+ # Phase 3b of the P087 plugin maturity rollout.
10
+
11
+ exec "$(dirname "$0")/../scripts/plugin-maturity-render.sh" "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.34.0",
3
+ "version": "0.35.0-preview.344",
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,394 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/plugin-maturity-render.sh
3
+ #
4
+ # Phase 3b (P087 / P238) plugin-maturity README renderer.
5
+ #
6
+ # Reads each `packages/<plugin>/.claude-plugin/plugin.json` `maturity:`
7
+ # field (populated by Phase 3a `wr-itil-plugin-maturity-populate`) and
8
+ # writes:
9
+ #
10
+ # 1. Prose-woven rollup badge into the README.md value-framing lead
11
+ # prose line (the first **bold** line after the H1). Format:
12
+ # Post-bootstrap: `*Maturity: <Band>.*`
13
+ # Bootstrapping window: `*Maturity: <Band> (suite-bootstrap window;
14
+ # <N> invocations / 30d).*`
15
+ # Markdown text only — no shields.io URL, no inline SVG (ADR-063
16
+ # §README badge rendering format).
17
+ #
18
+ # 2. Per-skill `Maturity` column populated in the existing `## Skills`
19
+ # table. Cell value is band name only (no compound — compound stays
20
+ # at the rollup per ADR-063). Adds the column header on first run;
21
+ # replaces cell values on subsequent runs.
22
+ #
23
+ # Idempotent: re-running with unchanged plugin.json + README produces
24
+ # byte-identical README output. Replaces existing `*Maturity: ...*` span
25
+ # rather than appending.
26
+ #
27
+ # Anti-patterns enforced (ADR-063 §Decision Outcome §"README badge
28
+ # rendering format" + §"Bootstrapping clause rendering"):
29
+ # - NEVER emit a standalone `## Maturity` section
30
+ # - NEVER emit a header block immediately after H1 before any prose
31
+ # - NEVER emit a shields.io URL or inline SVG
32
+ # - Compound rendering stays at rollup only — per-skill cell carries
33
+ # band name only
34
+ #
35
+ # ADR-044 silent-framework carve-out: band has already been computed by
36
+ # Phase 3a per ADR-053 §promotion criteria; the renderer is mechanical
37
+ # and never invokes AskUserQuestion.
38
+ #
39
+ # Usage:
40
+ # wr-itil-plugin-maturity-render
41
+ # [--project-root=PATH] # default: $PWD
42
+ # [--dry-run] # print diff to stdout, do not write
43
+ #
44
+ # Exit codes:
45
+ # 0 = always — ADR-013 Rule 6 fail-safe. Missing packages/ / opt-out
46
+ # marker / missing plugin.json / missing README / missing
47
+ # maturity field all hit no-write stderr-comment paths.
48
+ #
49
+ # Privacy (ADR-035 clauses adopted verbatim):
50
+ # - Opt-out marker `.claude/.skill-metrics-opt-out` disables writes.
51
+ # - No network egress — script body invokes no exfiltration primitive.
52
+ #
53
+ # @problem P238 (Phase 3b — README badge renderer + advisory drift detector)
54
+ # @problem P087 (parent — no maturity signal on plugin features)
55
+ # @adr ADR-063 (Plugin maturity presentation layer — Phase 3b contract)
56
+ # @adr ADR-053 (Plugin maturity taxonomy — Bootstrapping clause rendering)
57
+ # @adr ADR-051 (JTBD-anchored README — prose-weaving precedent)
58
+ # @adr ADR-049 (Shim grammar — `wr-itil-plugin-maturity-render` on $PATH)
59
+ # @adr ADR-044 (Decision delegation — silent-framework carve-out)
60
+ # @adr ADR-035 (Privacy posture — opt-out marker, no network primitive)
61
+ # @adr ADR-052 (Behavioural tests default)
62
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
63
+ # @jtbd JTBD-302 (Trust That the README Describes the Plugin I Just
64
+ # Installed — README is the contract surface)
65
+ # @jtbd JTBD-101 (Extend the Suite — clear patterns include stability)
66
+ # @jtbd JTBD-003 (Compose Only the Guardrails I Need — at-a-glance
67
+ # stability for composition decisions)
68
+
69
+ set -uo pipefail
70
+
71
+ # ── CLI parse ───────────────────────────────────────────────────────────────
72
+
73
+ PROJECT_ROOT="$PWD"
74
+ DRY_RUN=0
75
+
76
+ for arg in "$@"; do
77
+ case "$arg" in
78
+ --project-root=*) PROJECT_ROOT="${arg#--project-root=}" ;;
79
+ --dry-run) DRY_RUN=1 ;;
80
+ --help|-h)
81
+ sed -n '4,70p' "$0" | sed 's/^# \{0,1\}//'
82
+ exit 0
83
+ ;;
84
+ *)
85
+ echo "# wr-itil-plugin-maturity-render: ignoring unknown argument: $arg" >&2
86
+ ;;
87
+ esac
88
+ done
89
+
90
+ # ── Opt-out marker check (ADR-035) ──────────────────────────────────────────
91
+
92
+ OPT_OUT_MARKER="${PROJECT_ROOT}/.claude/.skill-metrics-opt-out"
93
+ if [ -e "$OPT_OUT_MARKER" ]; then
94
+ echo "# wr-itil-plugin-maturity-render: opt-out marker present at ${OPT_OUT_MARKER}" >&2
95
+ exit 0
96
+ fi
97
+
98
+ # ── packages/ discovery check (ADR-013 Rule 6 fail-safe) ────────────────────
99
+
100
+ if [ ! -d "${PROJECT_ROOT}/packages" ]; then
101
+ echo "# wr-itil-plugin-maturity-render: no packages/ directory at ${PROJECT_ROOT}" >&2
102
+ exit 0
103
+ fi
104
+
105
+ # ── Python body ─────────────────────────────────────────────────────────────
106
+
107
+ export PMR_PROJECT_ROOT="$PROJECT_ROOT"
108
+ export PMR_DRY_RUN="$DRY_RUN"
109
+
110
+ python3 - <<'PYEOF'
111
+ import json, os, re, sys
112
+ from pathlib import Path
113
+
114
+ project_root = Path(os.environ["PMR_PROJECT_ROOT"]).resolve()
115
+ dry_run = os.environ.get("PMR_DRY_RUN", "0") == "1"
116
+
117
+ packages_dir = project_root / "packages"
118
+ plugin_dirs = sorted(
119
+ [d for d in packages_dir.iterdir()
120
+ if d.is_dir() and (d / ".claude-plugin" / "plugin.json").is_file()]
121
+ )
122
+ if not plugin_dirs:
123
+ print("# wr-itil-plugin-maturity-render: no plugins under packages/", file=sys.stderr)
124
+ sys.exit(0)
125
+
126
+ # Match an existing prose-woven badge: `*Maturity: <body>.*` where body
127
+ # may contain anything except a `*`. Anchored on word `Maturity:` to
128
+ # avoid eating arbitrary italic spans.
129
+ BADGE_RE = re.compile(r"\s*\*Maturity:\s+[^*]+\*")
130
+
131
+ # Match a standalone `## Maturity` heading (anti-pattern enforcement —
132
+ # never emitted; detector catches drift if introduced by hand).
133
+ ANTI_SECTION_RE = re.compile(r"(?m)^##\s+Maturity\s*$")
134
+
135
+
136
+ def format_rollup_badge(maturity_record):
137
+ """Render the prose-woven rollup badge per ADR-063.
138
+
139
+ During the suite-bootstrap window the rollup carries the compound
140
+ form per ADR-053 §Bootstrapping clause rendering; post-sunset it
141
+ renders the band name only.
142
+ """
143
+ band = maturity_record.get("band", "Experimental")
144
+ bootstrapping = bool(maturity_record.get("bootstrapping"))
145
+ inv = maturity_record.get("rollup_invocations_30d")
146
+ if bootstrapping and isinstance(inv, int) and inv > 0:
147
+ return f"*Maturity: {band} (suite-bootstrap window; {inv} invocations / 30d).*"
148
+ return f"*Maturity: {band}.*"
149
+
150
+
151
+ def weave_rollup_into_lead_prose(readme_text, badge):
152
+ """Insert / replace the prose-woven rollup badge in the
153
+ value-framing lead-prose line (the first **bold** paragraph after
154
+ the H1). Returns the updated README text.
155
+
156
+ Idempotent: if the lead-prose line already ends with a `*Maturity:
157
+ ...*` span, replace it; otherwise append `<space><badge>` to the
158
+ end of that line.
159
+ """
160
+ lines = readme_text.split("\n")
161
+ h1_idx = None
162
+ for i, line in enumerate(lines):
163
+ if line.startswith("# "):
164
+ h1_idx = i
165
+ break
166
+ if h1_idx is None:
167
+ return readme_text # no H1 — skip (defensive)
168
+
169
+ # Find first non-empty line after H1 that looks like value-framing
170
+ # prose. Preferred: starts with `**` (bold-lead pattern). Fallback:
171
+ # first non-empty non-heading line.
172
+ lead_idx = None
173
+ fallback_idx = None
174
+ for j in range(h1_idx + 1, len(lines)):
175
+ s = lines[j].strip()
176
+ if not s:
177
+ continue
178
+ if s.startswith("#"):
179
+ continue
180
+ if fallback_idx is None:
181
+ fallback_idx = j
182
+ if s.startswith("**"):
183
+ lead_idx = j
184
+ break
185
+ if lead_idx is None:
186
+ lead_idx = fallback_idx
187
+ if lead_idx is None:
188
+ return readme_text # no prose found — skip (defensive)
189
+
190
+ line = lines[lead_idx]
191
+ # Strip any existing `*Maturity: ...*` span (idempotency contract).
192
+ stripped = BADGE_RE.sub("", line).rstrip()
193
+ new_line = f"{stripped} {badge}"
194
+ lines[lead_idx] = new_line
195
+ return "\n".join(lines)
196
+
197
+
198
+ SKILLS_HEADER_RE = re.compile(r"(?m)^##\s+Skills\s*$")
199
+ TABLE_ROW_RE = re.compile(r"^\|.*\|\s*$")
200
+ SEPARATOR_RE = re.compile(r"^\|\s*[-:]+(\s*\|\s*[-:]+)+\s*\|\s*$")
201
+
202
+
203
+ def split_row(row):
204
+ """Split a markdown table row by `|`. Returns the inner cells
205
+ (skips the leading + trailing empty splits from the outer `|`).
206
+ Cells are NOT stripped — preserves padding for round-trip.
207
+ """
208
+ parts = row.split("|")
209
+ # Drop the leading empty (before first `|`) and trailing empty
210
+ # (after last `|`) when present.
211
+ if parts and parts[0].strip() == "":
212
+ parts = parts[1:]
213
+ if parts and parts[-1].strip() == "":
214
+ parts = parts[:-1]
215
+ return parts
216
+
217
+
218
+ def join_row(cells):
219
+ return "| " + " | ".join(c.strip() for c in cells) + " |"
220
+
221
+
222
+ def extract_skill_name(cell):
223
+ """Extract the skill name from a `## Skills` cell. Skill cells
224
+ typically read `/wr-<plugin>:<name>` or `\`/wr-<plugin>:<name>\``.
225
+ Returns the bare skill name (after the colon), or None if the cell
226
+ doesn't carry a skill identifier.
227
+ """
228
+ text = cell.strip()
229
+ # Strip surrounding backticks.
230
+ text = text.strip("`")
231
+ m = re.search(r"/wr-[a-z0-9-]+:([a-z0-9-]+)", text)
232
+ if m:
233
+ return m.group(1)
234
+ return None
235
+
236
+
237
+ def populate_skills_column(readme_text, skills_map):
238
+ """Insert / populate a `Maturity` column in the `## Skills` table.
239
+
240
+ `skills_map` is a dict[skill-name -> band]. If a cell's skill name
241
+ isn't in the map, the cell value is empty (not omitted). Idempotent:
242
+ if the `Maturity` column header already exists, cells are repopulated.
243
+ """
244
+ if not skills_map:
245
+ return readme_text
246
+
247
+ lines = readme_text.split("\n")
248
+ skills_idx = None
249
+ for i, line in enumerate(lines):
250
+ if SKILLS_HEADER_RE.match(line):
251
+ skills_idx = i
252
+ break
253
+ if skills_idx is None:
254
+ return readme_text # no `## Skills` section — skip
255
+
256
+ # Find the first table row after the heading (header row of the
257
+ # markdown table). Skip blank lines.
258
+ header_idx = None
259
+ for j in range(skills_idx + 1, len(lines)):
260
+ s = lines[j].strip()
261
+ if not s:
262
+ continue
263
+ if TABLE_ROW_RE.match(lines[j]):
264
+ header_idx = j
265
+ break
266
+ # Hit a non-table non-blank line before any table — no table.
267
+ break
268
+ if header_idx is None:
269
+ return readme_text
270
+ if header_idx + 1 >= len(lines):
271
+ return readme_text
272
+ sep_line = lines[header_idx + 1]
273
+ if not SEPARATOR_RE.match(sep_line.rstrip()):
274
+ return readme_text # second row isn't a separator — malformed; skip
275
+
276
+ header_cells = split_row(lines[header_idx])
277
+ sep_cells = split_row(sep_line)
278
+
279
+ # Check whether `Maturity` column already exists.
280
+ norm_headers = [c.strip().lower() for c in header_cells]
281
+ if "maturity" in norm_headers:
282
+ mat_col = norm_headers.index("maturity")
283
+ else:
284
+ mat_col = len(header_cells)
285
+ header_cells.append("Maturity")
286
+ sep_cells.append("---")
287
+
288
+ new_header = join_row(header_cells)
289
+ new_sep = "| " + " | ".join(c.strip() for c in sep_cells) + " |"
290
+ lines[header_idx] = new_header
291
+ lines[header_idx + 1] = new_sep
292
+
293
+ # Walk subsequent table rows until a blank line or non-table line.
294
+ body_idx = header_idx + 2
295
+ while body_idx < len(lines):
296
+ row = lines[body_idx]
297
+ if not row.strip():
298
+ break
299
+ if not TABLE_ROW_RE.match(row):
300
+ break
301
+ cells = split_row(row)
302
+ # Pad / truncate to the column count.
303
+ target_cols = len(header_cells)
304
+ while len(cells) < target_cols:
305
+ cells.append("")
306
+ if len(cells) > target_cols:
307
+ cells = cells[:target_cols]
308
+ # Identify the skill in the row's first cell that names a skill.
309
+ skill_name = None
310
+ for c in cells:
311
+ sn = extract_skill_name(c)
312
+ if sn:
313
+ skill_name = sn
314
+ break
315
+ band = skills_map.get(skill_name, "") if skill_name else ""
316
+ cells[mat_col] = band
317
+ lines[body_idx] = join_row(cells)
318
+ body_idx += 1
319
+
320
+ return "\n".join(lines)
321
+
322
+
323
+ def render_plugin(pkg_dir):
324
+ """Render a single plugin. Returns (changed_bool, new_readme_text)
325
+ or (False, None) when skipped (no maturity, no README, etc).
326
+ """
327
+ plugin_json_path = pkg_dir / ".claude-plugin" / "plugin.json"
328
+ readme_path = pkg_dir / "README.md"
329
+ if not readme_path.is_file():
330
+ return (False, None)
331
+ try:
332
+ plugin_doc = json.loads(plugin_json_path.read_text(encoding="utf-8"))
333
+ except Exception as exc:
334
+ print(f"# wr-itil-plugin-maturity-render: skipping unreadable plugin.json at {plugin_json_path}: {exc}", file=sys.stderr)
335
+ return (False, None)
336
+ if not isinstance(plugin_doc, dict):
337
+ return (False, None)
338
+
339
+ maturity = plugin_doc.get("maturity")
340
+ if not isinstance(maturity, dict) or "band" not in maturity:
341
+ return (False, None) # Phase 3a hasn't run for this plugin
342
+
343
+ badge = format_rollup_badge(maturity)
344
+ readme_text = readme_path.read_text(encoding="utf-8")
345
+ new_text = weave_rollup_into_lead_prose(readme_text, badge)
346
+
347
+ # Build per-skill band map from the plugin.json `skills:` map.
348
+ skills_map = {}
349
+ skills_section = plugin_doc.get("skills", {})
350
+ if isinstance(skills_section, dict):
351
+ for name, entry in skills_section.items():
352
+ if not isinstance(entry, dict):
353
+ continue
354
+ entry_mat = entry.get("maturity")
355
+ if isinstance(entry_mat, dict) and "band" in entry_mat:
356
+ skills_map[name] = entry_mat["band"]
357
+
358
+ new_text = populate_skills_column(new_text, skills_map)
359
+
360
+ if new_text == readme_text:
361
+ return (False, new_text)
362
+ return (True, new_text)
363
+
364
+
365
+ plugins_processed = 0
366
+ plugins_written = 0
367
+ plugins_unchanged = 0
368
+
369
+ for pkg_dir in plugin_dirs:
370
+ plugins_processed += 1
371
+ changed, new_text = render_plugin(pkg_dir)
372
+ if new_text is None:
373
+ continue
374
+ if not changed:
375
+ plugins_unchanged += 1
376
+ continue
377
+ readme_path = pkg_dir / "README.md"
378
+ if dry_run:
379
+ sys.stdout.write(f"--- {readme_path}\n+++ would-write\n")
380
+ sys.stdout.write(new_text)
381
+ sys.stdout.write("\n")
382
+ else:
383
+ readme_path.write_text(new_text, encoding="utf-8")
384
+ plugins_written += 1
385
+
386
+ print(
387
+ f"# wr-itil-plugin-maturity-render: "
388
+ f"plugins={plugins_processed} written={plugins_written} "
389
+ f"unchanged={plugins_unchanged}",
390
+ file=sys.stderr,
391
+ )
392
+ PYEOF
393
+
394
+ exit 0
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P238 — Phase 3b renderer behavioural confirmation.
4
+ # @problem P087 — parent: plugin maturity battle-hardening signal.
5
+ #
6
+ # Contract under test: `packages/itil/scripts/plugin-maturity-render.sh`
7
+ # reads `plugin.json` `maturity:` field (per Phase 3a populate output)
8
+ # and writes a prose-woven rollup badge into each plugin's README.md
9
+ # value-framing lead prose AND populates a per-skill `Maturity` column
10
+ # in the existing `## Skills` table. Idempotent — re-running with
11
+ # unchanged inputs produces byte-identical README output.
12
+ #
13
+ # Anti-patterns enforced (ADR-063 §README badge rendering format):
14
+ # - NO standalone `## Maturity` section
15
+ # - NO header block immediately after H1 before any prose framing
16
+ # - NO shields.io URL or inline SVG (markdown text only)
17
+ #
18
+ # Bootstrapping rendering: rollup carries compound form during the
19
+ # suite-bootstrap window; per-skill column carries band name only
20
+ # (compound stays at rollup, never at cell).
21
+ #
22
+ # @adr ADR-063 (Plugin maturity presentation layer — Phase 3b contract)
23
+ # @adr ADR-053 (Plugin maturity taxonomy — Bootstrapping clause rendering)
24
+ # @adr ADR-051 (JTBD-anchored README — prose-weaving precedent)
25
+ # @adr ADR-049 (Shim grammar — `wr-itil-plugin-maturity-render` on $PATH)
26
+ # @adr ADR-052 (Behavioural tests default)
27
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
28
+
29
+ setup() {
30
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
31
+ SCRIPT="$SCRIPTS_DIR/plugin-maturity-render.sh"
32
+ FIXTURE_DIR="$(mktemp -d)"
33
+ PROJECT_ROOT="$FIXTURE_DIR/project"
34
+ mkdir -p "$PROJECT_ROOT/packages"
35
+ }
36
+
37
+ teardown() {
38
+ rm -rf "$FIXTURE_DIR"
39
+ }
40
+
41
+ # Helper: write a synthetic plugin layout with plugin.json + README.md
42
+ make_plugin() {
43
+ local name="$1"
44
+ local plugin_json="$2"
45
+ local readme="$3"
46
+ local pkg="$PROJECT_ROOT/packages/$name"
47
+ mkdir -p "$pkg/.claude-plugin"
48
+ printf '%s\n' "$plugin_json" > "$pkg/.claude-plugin/plugin.json"
49
+ printf '%s\n' "$readme" > "$pkg/README.md"
50
+ }
51
+
52
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
53
+
54
+ @test "script file exists and is executable" {
55
+ [ -f "$SCRIPT" ]
56
+ [ -x "$SCRIPT" ]
57
+ }
58
+
59
+ @test "missing packages dir exits 0 with stderr comment" {
60
+ run bash "$SCRIPT" --project-root="$FIXTURE_DIR/does-not-exist"
61
+ [ "$status" -eq 0 ]
62
+ }
63
+
64
+ @test "opt-out marker present: skips all writes" {
65
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
66
+ "# @windyroad/stub
67
+
68
+ **Stub plugin description.**
69
+
70
+ ## Skills
71
+
72
+ | Skill | Purpose |
73
+ |-------|---------|
74
+ | /wr-stub:thing | Does a thing |
75
+ "
76
+ mkdir -p "$PROJECT_ROOT/.claude"
77
+ touch "$PROJECT_ROOT/.claude/.skill-metrics-opt-out"
78
+ local before; before="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
79
+
80
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
81
+ [ "$status" -eq 0 ]
82
+
83
+ local after; after="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
84
+ [ "$before" = "$after" ]
85
+ }
86
+
87
+ # ── Confirmation #1 (ADR-063): rollup badge prose-woven into lead prose ─────
88
+
89
+ @test "rollup badge: inserts prose-woven Maturity span into bold lead prose line" {
90
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
91
+ "# @windyroad/stub
92
+
93
+ **Stub plugin description.** Some more prose.
94
+
95
+ ## Skills
96
+
97
+ | Skill | Purpose |
98
+ |-------|---------|
99
+ | /wr-stub:thing | Does a thing |
100
+ "
101
+
102
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
103
+ [ "$status" -eq 0 ]
104
+
105
+ local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
106
+ # Prose-woven Maturity span appears, italicised, in the lead-prose area
107
+ [[ "$out" == *"*Maturity: Alpha"* ]]
108
+ }
109
+
110
+ @test "rollup badge: bootstrapping window renders compound form with invocation count" {
111
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Experimental","bootstrapping":true,"rollup_invocations_30d":796}}' \
112
+ "# @windyroad/stub
113
+
114
+ **Stub plugin description.**
115
+
116
+ ## Skills
117
+
118
+ | Skill | Purpose |
119
+ |-------|---------|
120
+ | /wr-stub:thing | Does a thing |
121
+ "
122
+
123
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
124
+ [ "$status" -eq 0 ]
125
+
126
+ local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
127
+ [[ "$out" == *"Experimental"* ]]
128
+ [[ "$out" == *"suite-bootstrap window"* ]]
129
+ [[ "$out" == *"796 invocations"* ]]
130
+ }
131
+
132
+ @test "rollup badge: post-bootstrap renders band name only (no compound)" {
133
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Beta"}}' \
134
+ "# @windyroad/stub
135
+
136
+ **Stub plugin description.**
137
+
138
+ ## Skills
139
+
140
+ | Skill | Purpose |
141
+ |-------|---------|
142
+ | /wr-stub:thing | Does a thing |
143
+ "
144
+
145
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
146
+ [ "$status" -eq 0 ]
147
+
148
+ local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
149
+ [[ "$out" == *"*Maturity: Beta.*"* ]]
150
+ [[ "$out" != *"suite-bootstrap"* ]]
151
+ [[ "$out" != *"invocations"* ]]
152
+ }
153
+
154
+ # ── Confirmation #2 (ADR-063): per-skill Maturity column populated ──────────
155
+
156
+ @test "per-skill column: adds Maturity column to existing Skills table" {
157
+ make_plugin "stub" '{
158
+ "name":"wr-stub","version":"0.1.0","description":"Stub",
159
+ "maturity":{"schema_version":"1.0","band":"Alpha"},
160
+ "skills":{
161
+ "thing":{"maturity":{"schema_version":"1.0","band":"Alpha","computed_at":"2026-05-18T00:00:00Z","evidence":{"invocations_30d":50,"days_shipped":30,"closed_tickets_window":5,"breaking_change_age_days":null}}},
162
+ "widget":{"maturity":{"schema_version":"1.0","band":"Experimental","computed_at":"2026-05-18T00:00:00Z","evidence":{"invocations_30d":2,"days_shipped":5,"closed_tickets_window":0,"breaking_change_age_days":null}}}
163
+ }
164
+ }' \
165
+ "# @windyroad/stub
166
+
167
+ **Stub plugin description.**
168
+
169
+ ## Skills
170
+
171
+ | Skill | Purpose |
172
+ |-------|---------|
173
+ | /wr-stub:thing | Does a thing |
174
+ | /wr-stub:widget | Widgets things |
175
+ "
176
+
177
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
178
+ [ "$status" -eq 0 ]
179
+
180
+ local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
181
+ # Header row gains Maturity column
182
+ [[ "$out" == *"| Skill | Purpose | Maturity |"* ]]
183
+ # Cells populated
184
+ [[ "$out" == *"/wr-stub:thing"*"| Alpha |"* ]]
185
+ [[ "$out" == *"/wr-stub:widget"*"| Experimental |"* ]]
186
+ # Per-skill cell carries band name ONLY (no compound)
187
+ [[ "$out" != *"| Alpha (suite"* ]]
188
+ }
189
+
190
+ # ── Idempotency: second run produces no diff ────────────────────────────────
191
+
192
+ @test "idempotency: second run against unchanged plugin.json produces byte-equal README" {
193
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
194
+ "# @windyroad/stub
195
+
196
+ **Stub plugin description.**
197
+
198
+ ## Skills
199
+
200
+ | Skill | Purpose |
201
+ |-------|---------|
202
+ | /wr-stub:thing | Does a thing |
203
+ "
204
+
205
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
206
+ [ "$status" -eq 0 ]
207
+ local first; first="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
208
+
209
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
210
+ [ "$status" -eq 0 ]
211
+ local second; second="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
212
+
213
+ [ "$first" = "$second" ]
214
+ }
215
+
216
+ @test "idempotency: existing Maturity badge gets replaced (not appended)" {
217
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Beta"}}' \
218
+ "# @windyroad/stub
219
+
220
+ **Stub plugin description.** *Maturity: Alpha.*
221
+
222
+ ## Skills
223
+
224
+ | Skill | Purpose |
225
+ |-------|---------|
226
+ | /wr-stub:thing | Does a thing |
227
+ "
228
+
229
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
230
+ [ "$status" -eq 0 ]
231
+
232
+ local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
233
+ # Beta replaced Alpha (no duplication)
234
+ [[ "$out" == *"*Maturity: Beta"* ]]
235
+ [[ "$out" != *"*Maturity: Alpha"* ]]
236
+ # Single Maturity span (no duplication)
237
+ local count
238
+ count="$(grep -oE '\*Maturity: [^*]+\*' "$PROJECT_ROOT/packages/stub/README.md" | wc -l)"
239
+ [ "$count" -eq 1 ]
240
+ }
241
+
242
+ # ── Anti-pattern: no standalone ## Maturity section emitted ─────────────────
243
+
244
+ @test "anti-pattern: renderer never emits a standalone ## Maturity section" {
245
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
246
+ "# @windyroad/stub
247
+
248
+ **Stub plugin description.**
249
+
250
+ ## Skills
251
+
252
+ | Skill | Purpose |
253
+ |-------|---------|
254
+ | /wr-stub:thing | Does a thing |
255
+ "
256
+
257
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
258
+ [ "$status" -eq 0 ]
259
+
260
+ local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
261
+ [[ "$out" != *"## Maturity"* ]]
262
+ [[ "$out" != *"# Maturity"* ]]
263
+ }
264
+
265
+ @test "anti-pattern: no shields.io badge URL emitted" {
266
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
267
+ "# @windyroad/stub
268
+
269
+ **Stub plugin description.**
270
+
271
+ ## Skills
272
+
273
+ | Skill | Purpose |
274
+ |-------|---------|
275
+ | /wr-stub:thing | Does a thing |
276
+ "
277
+
278
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
279
+ [ "$status" -eq 0 ]
280
+
281
+ local out; out="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
282
+ [[ "$out" != *"shields.io"* ]]
283
+ [[ "$out" != *"img.shields"* ]]
284
+ }
285
+
286
+ # ── Fail-safe: missing maturity field is a no-op (Phase 3a not yet run) ─────
287
+
288
+ @test "fail-safe: plugin.json without maturity: field is skipped" {
289
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub"}' \
290
+ "# @windyroad/stub
291
+
292
+ **Stub plugin description.**
293
+
294
+ ## Skills
295
+
296
+ | Skill | Purpose |
297
+ |-------|---------|
298
+ | /wr-stub:thing | Does a thing |
299
+ "
300
+ local before; before="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
301
+
302
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
303
+ [ "$status" -eq 0 ]
304
+
305
+ local after; after="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
306
+ [ "$before" = "$after" ]
307
+ }
308
+
309
+ # ── Fail-safe: plugin without README is skipped ─────────────────────────────
310
+
311
+ @test "fail-safe: plugin without README.md is silently skipped" {
312
+ local pkg="$PROJECT_ROOT/packages/no-readme"
313
+ mkdir -p "$pkg/.claude-plugin"
314
+ echo '{"name":"wr-no-readme","version":"0.1.0","description":"No README","maturity":{"schema_version":"1.0","band":"Alpha"}}' > "$pkg/.claude-plugin/plugin.json"
315
+
316
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
317
+ [ "$status" -eq 0 ]
318
+ [ ! -f "$pkg/README.md" ]
319
+ }
320
+
321
+ # ── No-AskUserQuestion: ADR-044 silent-framework carve-out ──────────────────
322
+
323
+ @test "ADR-044: renderer never invokes AskUserQuestion per re-render" {
324
+ # Negative-presence behavioural assertion per ADR-052 §carve-out — the
325
+ # renderer is mechanical (band already computed by Phase 3a) and must
326
+ # not surface a consent gate per re-render. Scans combined stdout +
327
+ # stderr output for any AskUserQuestion-token spelling, case-insensitive
328
+ # per architect adjustment G to plugin-maturity-populate.bats.
329
+ make_plugin "silentp" '{"name":"wr-silentp","version":"0.1.0","description":"Silent","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
330
+ "# @windyroad/silentp
331
+
332
+ **Silent plugin description.**
333
+
334
+ ## Skills
335
+
336
+ | Skill | Purpose |
337
+ |-------|---------|
338
+ | /wr-silentp:thing | Does a thing |
339
+ "
340
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
341
+ [ "$status" -eq 0 ]
342
+ printf '%s' "$output" | grep -i -E 'askuserquestion|<askuser' && return 1 || true
343
+ }
344
+
345
+ # ── No-network primitive: ADR-035 privacy posture ───────────────────────────
346
+
347
+ @test "ADR-035: script body invokes no network primitive" {
348
+ # Negative-presence behavioural assertion — the renderer reads
349
+ # plugin.json + README.md from filesystem only; never reaches a
350
+ # network endpoint. Mirrors plugin-maturity-populate.bats.
351
+ run grep -E "(curl|wget|nc -|netcat|ssh |scp |rsync|http\.client|urllib|requests)" "$SCRIPT"
352
+ [ "$status" -ne 0 ]
353
+ }
354
+
355
+ # ── Multi-plugin: renders each plugin independently ─────────────────────────
356
+
357
+ @test "multi-plugin: renders each packages/<plugin>/README.md independently" {
358
+ make_plugin "alpha" '{"name":"wr-alpha","version":"0.1.0","description":"Alpha","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
359
+ "# @windyroad/alpha
360
+
361
+ **Alpha plugin description.**
362
+
363
+ ## Skills
364
+
365
+ | Skill | Purpose |
366
+ |-------|---------|
367
+ | /wr-alpha:thing | Does a thing |
368
+ "
369
+ make_plugin "bravo" '{"name":"wr-bravo","version":"0.1.0","description":"Bravo","maturity":{"schema_version":"1.0","band":"Beta"}}' \
370
+ "# @windyroad/bravo
371
+
372
+ **Bravo plugin description.**
373
+
374
+ ## Skills
375
+
376
+ | Skill | Purpose |
377
+ |-------|---------|
378
+ | /wr-bravo:thing | Does a thing |
379
+ "
380
+
381
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT"
382
+ [ "$status" -eq 0 ]
383
+
384
+ local out_a; out_a="$(cat "$PROJECT_ROOT/packages/alpha/README.md")"
385
+ local out_b; out_b="$(cat "$PROJECT_ROOT/packages/bravo/README.md")"
386
+ [[ "$out_a" == *"*Maturity: Alpha"* ]]
387
+ [[ "$out_b" == *"*Maturity: Beta"* ]]
388
+ }
389
+
390
+ # ── Dry-run: prints diff to stdout, does not write ──────────────────────────
391
+
392
+ @test "dry-run: --dry-run flag prints intended diff to stdout without modifying README" {
393
+ make_plugin "stub" '{"name":"wr-stub","version":"0.1.0","description":"Stub","maturity":{"schema_version":"1.0","band":"Alpha"}}' \
394
+ "# @windyroad/stub
395
+
396
+ **Stub plugin description.**
397
+
398
+ ## Skills
399
+
400
+ | Skill | Purpose |
401
+ |-------|---------|
402
+ | /wr-stub:thing | Does a thing |
403
+ "
404
+ local before; before="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
405
+
406
+ run bash "$SCRIPT" --project-root="$PROJECT_ROOT" --dry-run
407
+ [ "$status" -eq 0 ]
408
+ [[ "$output" == *"Maturity: Alpha"* ]]
409
+
410
+ local after; after="$(cat "$PROJECT_ROOT/packages/stub/README.md")"
411
+ [ "$before" = "$after" ]
412
+ }