@windyroad/itil 0.31.0-preview.325 → 0.32.0-preview.327

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.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec "$(dirname "$0")/../scripts/plugin-maturity-populate.sh" "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.31.0-preview.325",
3
+ "version": "0.32.0-preview.327",
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,477 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/plugin-maturity-populate.sh
3
+ #
4
+ # Phase 3a (P087) plugin-maturity population script.
5
+ #
6
+ # Reads two Phase 2 NDJSON streams — `wr-itil-skill-invocations` output
7
+ # (transcript-axis) + `wr-itil-plugin-exercise-index` output (git-axis) —
8
+ # applies ADR-053 §promotion criteria + §Bootstrapping clause, and writes
9
+ # the `maturity:` field per surface and per plugin root into each
10
+ # `packages/<plugin>/.claude-plugin/plugin.json`. Idempotent: re-running
11
+ # with unchanged inputs and a pinned `--now=` produces byte-identical
12
+ # plugin.json output.
13
+ #
14
+ # Plugin.json schema extension (ADR-063 §plugin.json field schema):
15
+ # {
16
+ # "name": "wr-<plugin>",
17
+ # "version": "...",
18
+ # "description": "...",
19
+ # "maturity": {"schema_version": "1.0", "band": "<Band>"},
20
+ # "skills": {"<name>": {"maturity": {...rich record...}}},
21
+ # "agents": {"<name>": {"maturity": {...rich record...}}},
22
+ # "hooks": {"<name>": {"maturity": {...rich record with null inv...}}},
23
+ # "commands": {"<name>": {"maturity": {...rich record...}}}
24
+ # }
25
+ #
26
+ # Per-surface rich record:
27
+ # {
28
+ # "schema_version": "1.0",
29
+ # "band": "Experimental|Alpha|Beta|Stable|Deprecated",
30
+ # "computed_at": "<ISO>",
31
+ # "evidence": {
32
+ # "invocations_30d": <int|null>, # null for hooks (architect §C)
33
+ # "days_shipped": <int>,
34
+ # "closed_tickets_window": <int>,
35
+ # "breaking_change_age_days": <int|null>
36
+ # }
37
+ # }
38
+ # Deprecated band entries additionally carry "supersededBy": "<pointer>"
39
+ # and are preserved across re-runs — Phase 3a never overwrites a
40
+ # Deprecated record (architect §I).
41
+ #
42
+ # Surface-key normalisation table (architect §B + §F):
43
+ # skill → Phase 2 NDJSON key `wr-<plugin>:<dir-name>`; filesystem
44
+ # `packages/<plugin>/skills/<dir-name>/`
45
+ # agent → Phase 2 NDJSON key `wr-<plugin>:<basename-without-.md>`;
46
+ # filesystem `packages/<plugin>/agents/<basename>.md`
47
+ # hook → Phase 2 NDJSON not transcript-observable (harness-fired);
48
+ # filesystem `packages/<plugin>/hooks/<basename>.sh`
49
+ # — invocations_30d sentinel `null`, band derived from git axis
50
+ # command → Phase 2 NDJSON key `wr-<plugin>:<basename-without-.md>`;
51
+ # filesystem `packages/<plugin>/commands/<basename>.md`
52
+ #
53
+ # Plugin-name attribution (architect §F): the plugin.json `name` field is
54
+ # prefixed `wr-<plugin>`, but Phase 2 NDJSON `plugin:` field is bare
55
+ # (`<plugin>`). This script keys by filesystem path discovery
56
+ # (`packages/<bare-name>/`) and accepts bare-form NDJSON input — no
57
+ # plugin.json `name`-matching required.
58
+ #
59
+ # Bootstrapping clause (ADR-053 §Bootstrapping clause):
60
+ # Active iff `max(days_shipped across plugins) < 60`. Sunset auto-
61
+ # derives from the data (architect §D — no hard-coded calendar date).
62
+ # Under bootstrapping: default = Experimental; provisional Alpha iff
63
+ # invocations_30d ≥ 100 AND days_shipped ≥ 14. Hooks (null invocations)
64
+ # stay Experimental during bootstrapping — provisional-Alpha rule
65
+ # requires a numeric invocation count.
66
+ #
67
+ # Steady-state thresholds (ADR-053 §promotion criteria, post-sunset):
68
+ # Experimental: days <14 OR invocations <10 OR tickets <3
69
+ # Alpha: days 14–60 AND inv 10–100 AND tickets 3–10
70
+ # Beta: days ≥60 AND inv ≥100 AND tickets ≥10 AND breaking ≥30
71
+ # (or null)
72
+ # Stable: days ≥180 AND inv ≥1000 AND breaking ≥90 (or null)
73
+ # Deprecated: author-declared; preserved across re-runs.
74
+ #
75
+ # ADR-044 silent-framework carve-out (scope-limited per ADR-063 §scope):
76
+ # Band recomputation is mechanical, policy-resolved. No `AskUserQuestion`
77
+ # per band recompute. The carve-out does NOT cover author-declared
78
+ # Deprecated assignment, `supersededBy:` authoring, or Phase 4+ gate
79
+ # threshold tuning — those remain AskUserQuestion-eligible per ADR-013
80
+ # Rule 1.
81
+ #
82
+ # Usage:
83
+ # wr-itil-plugin-maturity-populate
84
+ # [--transcript-ndjson=FILE] # default: invoke wr-itil-skill-invocations
85
+ # [--exercise-ndjson=FILE] # default: invoke wr-itil-plugin-exercise-index
86
+ # [--project-root=PATH] # default: $PWD
87
+ # [--now=ISO] # default: current UTC time
88
+ # [--dry-run] # print diff to stdout, do not write
89
+ #
90
+ # Exit codes:
91
+ # 0 = always — ADR-013 Rule 6 fail-safe. Missing NDJSON inputs / no
92
+ # packages/ / opt-out marker all hit the no-write stderr-
93
+ # comment path.
94
+ #
95
+ # Privacy (ADR-035 clauses adopted verbatim):
96
+ # - Opt-out marker `.claude/.skill-metrics-opt-out` disables writes.
97
+ # - No network egress — the script body invokes no exfiltration
98
+ # primitives. Negative-grep enforcement lives in the bats fixture.
99
+ #
100
+ # @problem P237 (Phase 3a — population script)
101
+ # @problem P087 (parent — no maturity signal on plugin features)
102
+ # @adr ADR-063 (Plugin maturity presentation layer — Phase 3a contract)
103
+ # @adr ADR-053 (Plugin maturity taxonomy — promotion criteria +
104
+ # Bootstrapping clause)
105
+ # @adr ADR-058 (Phase 2 NDJSON measurement — input shape)
106
+ # @adr ADR-049 (Shim grammar — `wr-itil-plugin-maturity-populate` on $PATH)
107
+ # @adr ADR-035 (Privacy posture — opt-out marker, no network primitive)
108
+ # @adr ADR-044 (Decision delegation — silent-framework carve-out)
109
+ # @adr ADR-052 (Behavioural tests default)
110
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
111
+ # @jtbd JTBD-201 (Restore Service Fast — rich-record evidence block IS the
112
+ # durable audit-trail surface at the canonical record)
113
+ # @jtbd JTBD-101 (Extend the Suite — band-derivation persists Phase 2
114
+ # transient NDJSON signal as durable plugin.json field)
115
+
116
+ set -uo pipefail
117
+
118
+ # ── CLI parse ───────────────────────────────────────────────────────────────
119
+
120
+ TRANSCRIPT_NDJSON=""
121
+ EXERCISE_NDJSON=""
122
+ PROJECT_ROOT="$PWD"
123
+ NOW_ISO=""
124
+ DRY_RUN=0
125
+
126
+ for arg in "$@"; do
127
+ case "$arg" in
128
+ --transcript-ndjson=*) TRANSCRIPT_NDJSON="${arg#--transcript-ndjson=}" ;;
129
+ --exercise-ndjson=*) EXERCISE_NDJSON="${arg#--exercise-ndjson=}" ;;
130
+ --project-root=*) PROJECT_ROOT="${arg#--project-root=}" ;;
131
+ --now=*) NOW_ISO="${arg#--now=}" ;;
132
+ --dry-run) DRY_RUN=1 ;;
133
+ --help|-h)
134
+ sed -n '4,90p' "$0" | sed 's/^# \{0,1\}//'
135
+ exit 0
136
+ ;;
137
+ *)
138
+ echo "# wr-itil-plugin-maturity-populate: ignoring unknown argument: $arg" >&2
139
+ ;;
140
+ esac
141
+ done
142
+
143
+ # ── Opt-out marker check (ADR-035 / ADR-058 §Privacy posture) ───────────────
144
+
145
+ OPT_OUT_MARKER="${PROJECT_ROOT}/.claude/.skill-metrics-opt-out"
146
+ if [ -e "$OPT_OUT_MARKER" ]; then
147
+ echo "# wr-itil-plugin-maturity-populate: opt-out marker present at ${OPT_OUT_MARKER}" >&2
148
+ exit 0
149
+ fi
150
+
151
+ # ── packages/ discovery check (ADR-013 Rule 6 fail-safe) ────────────────────
152
+
153
+ if [ ! -d "${PROJECT_ROOT}/packages" ]; then
154
+ echo "# wr-itil-plugin-maturity-populate: no packages/ directory at ${PROJECT_ROOT}" >&2
155
+ exit 0
156
+ fi
157
+
158
+ # ── Python body ─────────────────────────────────────────────────────────────
159
+ # Inputs pinned via environment to avoid argv leakage.
160
+
161
+ export PMP_TRANSCRIPT_NDJSON="$TRANSCRIPT_NDJSON"
162
+ export PMP_EXERCISE_NDJSON="$EXERCISE_NDJSON"
163
+ export PMP_PROJECT_ROOT="$PROJECT_ROOT"
164
+ export PMP_NOW_ISO="$NOW_ISO"
165
+ export PMP_DRY_RUN="$DRY_RUN"
166
+
167
+ python3 - <<'PYEOF'
168
+ import json, os, sys
169
+ from pathlib import Path
170
+ from datetime import datetime, timezone
171
+
172
+ project_root = Path(os.environ["PMP_PROJECT_ROOT"]).resolve()
173
+ transcript_ndjson = os.environ.get("PMP_TRANSCRIPT_NDJSON", "")
174
+ exercise_ndjson = os.environ.get("PMP_EXERCISE_NDJSON", "")
175
+ now_iso = os.environ.get("PMP_NOW_ISO", "")
176
+ dry_run = os.environ.get("PMP_DRY_RUN", "0") == "1"
177
+
178
+ # ── Resolve `now` ──────────────────────────────────────────────────────────
179
+ # `--now=ISO` testability override (architect §H — pin across idempotency
180
+ # fixtures so byte-equality holds without field-exclusion logic).
181
+ if now_iso:
182
+ try:
183
+ now_dt = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
184
+ except Exception:
185
+ print(f"# wr-itil-plugin-maturity-populate: invalid --now={now_iso!r}, using current time", file=sys.stderr)
186
+ now_dt = datetime.now(timezone.utc)
187
+ else:
188
+ now_dt = datetime.now(timezone.utc)
189
+ now_canonical_iso = now_dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
190
+
191
+ # ── Load NDJSON inputs (fail-soft on missing/unreadable) ───────────────────
192
+
193
+ def load_ndjson(path):
194
+ """Read NDJSON, returning list of records. Missing/unreadable -> []."""
195
+ if not path:
196
+ return []
197
+ p = Path(path)
198
+ if not p.is_file():
199
+ print(f"# wr-itil-plugin-maturity-populate: NDJSON input not found: {path}", file=sys.stderr)
200
+ return []
201
+ records = []
202
+ try:
203
+ with p.open("r", encoding="utf-8", errors="replace") as fh:
204
+ for raw_line in fh:
205
+ line = raw_line.strip()
206
+ if not line:
207
+ continue
208
+ try:
209
+ rec = json.loads(line)
210
+ except Exception:
211
+ continue
212
+ if isinstance(rec, dict):
213
+ records.append(rec)
214
+ except OSError:
215
+ pass
216
+ return records
217
+
218
+ transcript_records = load_ndjson(transcript_ndjson)
219
+ exercise_records = load_ndjson(exercise_ndjson)
220
+
221
+ # Index transcript by (kind, surface) -> invocations.
222
+ # Phase 2a NDJSON `surface` keys:
223
+ # skill -> "wr-<plugin>:<skill-name>"
224
+ # agent -> "wr-<plugin>:<agent-name>" (default "agent")
225
+ # bash -> "wr-<plugin>-<bin-name>"
226
+ inv_by_surface = {}
227
+ for r in transcript_records:
228
+ kind = r.get("kind")
229
+ surface = r.get("surface")
230
+ inv = r.get("invocations")
231
+ if not surface or not isinstance(inv, int):
232
+ continue
233
+ inv_by_surface[(kind, surface)] = inv
234
+
235
+ # Index exercise by plugin (bare name, e.g. "itil").
236
+ exercise_by_plugin = {}
237
+ for r in exercise_records:
238
+ plug = r.get("plugin")
239
+ if not plug:
240
+ continue
241
+ exercise_by_plugin[plug] = {
242
+ "days_shipped": int(r.get("days_shipped", 0) or 0),
243
+ "closed_tickets_window": int(r.get("closed_tickets_window", 0) or 0),
244
+ "breaking_change_age_days": r.get("breaking_change_age_days"),
245
+ }
246
+
247
+ # ── Bootstrapping window sunset auto-derivation (architect §D) ─────────────
248
+ # Active iff max(days_shipped across plugins) < 60. No calendar-date
249
+ # hard-code; the data alone determines the lapse.
250
+
251
+ if exercise_by_plugin:
252
+ suite_oldest_days = max(v["days_shipped"] for v in exercise_by_plugin.values())
253
+ else:
254
+ suite_oldest_days = 0
255
+ bootstrapping_active = suite_oldest_days < 60
256
+
257
+ # ── Plugin discovery (filesystem) ──────────────────────────────────────────
258
+
259
+ packages_dir = project_root / "packages"
260
+ plugin_dirs = sorted(
261
+ [d for d in packages_dir.iterdir() if d.is_dir() and (d / ".claude-plugin" / "plugin.json").is_file()]
262
+ )
263
+ if not plugin_dirs:
264
+ print("# wr-itil-plugin-maturity-populate: no plugins under packages/", file=sys.stderr)
265
+ sys.exit(0)
266
+
267
+ # ── Surface inventory (filesystem) ─────────────────────────────────────────
268
+
269
+ def discover_surfaces(pkg_dir):
270
+ """Returns dict[kind] -> sorted list of surface names."""
271
+ out = {"skill": [], "agent": [], "hook": [], "command": []}
272
+ # Skills: packages/<plugin>/skills/<name>/SKILL.md or just the dir.
273
+ skills_dir = pkg_dir / "skills"
274
+ if skills_dir.is_dir():
275
+ out["skill"] = sorted(d.name for d in skills_dir.iterdir() if d.is_dir())
276
+ # Agents: packages/<plugin>/agents/<name>.md (excludes test/).
277
+ agents_dir = pkg_dir / "agents"
278
+ if agents_dir.is_dir():
279
+ out["agent"] = sorted(f.stem for f in agents_dir.glob("*.md") if f.is_file())
280
+ # Hooks: packages/<plugin>/hooks/<name>.sh (excludes lib/, test/).
281
+ hooks_dir = pkg_dir / "hooks"
282
+ if hooks_dir.is_dir():
283
+ out["hook"] = sorted(f.stem for f in hooks_dir.glob("*.sh") if f.is_file())
284
+ # Commands: packages/<plugin>/commands/<name>.md.
285
+ commands_dir = pkg_dir / "commands"
286
+ if commands_dir.is_dir():
287
+ out["command"] = sorted(f.stem for f in commands_dir.glob("*.md") if f.is_file())
288
+ return out
289
+
290
+ # ── Band-mapping (ADR-053 §promotion criteria + §Bootstrapping clause) ─────
291
+
292
+ # Band ordering, worst-first. "Deprecated" is an overlay axis (ADR-053
293
+ # §granularity contract line 109) — elided from the rollup computation but
294
+ # retained on individual surface entries. Worst-case rollup compares only
295
+ # {Experimental, Alpha, Beta, Stable}.
296
+ BAND_ORDER = ["Experimental", "Alpha", "Beta", "Stable"]
297
+
298
+ def compute_band(evidence):
299
+ """Map a per-surface evidence record -> band per ADR-053.
300
+
301
+ - During bootstrapping (suite-oldest < 60d): default Experimental;
302
+ provisional Alpha iff invocations_30d >= 100 AND days_shipped >= 14.
303
+ Hooks (invocations_30d=None) stay Experimental — the provisional
304
+ rule requires a numeric invocation count.
305
+ - Steady-state (post-sunset): AND-gated bands per the strawman.
306
+ """
307
+ inv = evidence.get("invocations_30d")
308
+ days = evidence.get("days_shipped", 0)
309
+ tickets = evidence.get("closed_tickets_window", 0)
310
+ breaking = evidence.get("breaking_change_age_days") # None or int
311
+
312
+ if bootstrapping_active:
313
+ # Provisional Alpha rule (ADR-053 §Bootstrapping clause line 86).
314
+ if isinstance(inv, int) and inv >= 100 and days >= 14:
315
+ return "Alpha"
316
+ return "Experimental"
317
+
318
+ # Steady-state. Hooks (None invocations) cannot meet inv-gated floors,
319
+ # so they stay Experimental — consistent with the bootstrapping rule.
320
+ inv_val = inv if isinstance(inv, int) else 0
321
+
322
+ # Stable floor.
323
+ stable_breaking_ok = (breaking is None) or (isinstance(breaking, int) and breaking >= 90)
324
+ if days >= 180 and inv_val >= 1000 and stable_breaking_ok:
325
+ return "Stable"
326
+ # Beta floor.
327
+ beta_breaking_ok = (breaking is None) or (isinstance(breaking, int) and breaking >= 30)
328
+ if days >= 60 and inv_val >= 100 and tickets >= 10 and beta_breaking_ok:
329
+ return "Beta"
330
+ # Alpha floor.
331
+ if 14 <= days < 60 and 10 <= inv_val < 100 and 3 <= tickets <= 10:
332
+ return "Alpha"
333
+ return "Experimental"
334
+
335
+ def rollup_band(surface_bands):
336
+ """Worst-case across non-Deprecated bands; Deprecated entries elided.
337
+ A plugin whose ONLY surfaces are Deprecated is itself Deprecated.
338
+ """
339
+ non_dep = [b for b in surface_bands if b in BAND_ORDER]
340
+ if non_dep:
341
+ for band in BAND_ORDER:
342
+ if band in non_dep:
343
+ return band
344
+ return "Stable"
345
+ if surface_bands and all(b == "Deprecated" for b in surface_bands):
346
+ return "Deprecated"
347
+ return None
348
+
349
+ # ── Per-plugin write loop ──────────────────────────────────────────────────
350
+
351
+ def lookup_invocations(plugin_bare, kind, name):
352
+ """Return invocations_30d for a (plugin, kind, name) tuple.
353
+ Hooks are not transcript-observable -> None (sentinel for "n/a").
354
+ """
355
+ if kind == "hook":
356
+ return None
357
+ surface = f"wr-{plugin_bare}:{name}"
358
+ return inv_by_surface.get((kind, surface), 0)
359
+
360
+ def build_surface_record(existing, kind, plugin_bare, name, evidence):
361
+ """Construct the new maturity record for a surface, respecting the
362
+ Deprecated-overlay invariant (architect §I + ADR-053 #6 / #102):
363
+ if existing band is Deprecated, return existing record VERBATIM —
364
+ do NOT recompute, do NOT update computed_at, do NOT overwrite
365
+ supersededBy. All other records get a fresh recompute.
366
+ """
367
+ existing_maturity = existing.get("maturity") if isinstance(existing, dict) else None
368
+ if isinstance(existing_maturity, dict) and existing_maturity.get("band") == "Deprecated":
369
+ return existing_maturity
370
+
371
+ band = compute_band(evidence)
372
+ record = {
373
+ "schema_version": "1.0",
374
+ "band": band,
375
+ "computed_at": now_canonical_iso,
376
+ "evidence": {
377
+ "invocations_30d": evidence["invocations_30d"],
378
+ "days_shipped": evidence["days_shipped"],
379
+ "closed_tickets_window": evidence["closed_tickets_window"],
380
+ "breaking_change_age_days": evidence["breaking_change_age_days"],
381
+ },
382
+ }
383
+ return record
384
+
385
+ wrote_records = 0
386
+ unchanged_records = 0
387
+ total_plugins = 0
388
+
389
+ for pkg_dir in plugin_dirs:
390
+ plugin_bare = pkg_dir.name
391
+ plugin_json_path = pkg_dir / ".claude-plugin" / "plugin.json"
392
+ try:
393
+ plugin_doc = json.loads(plugin_json_path.read_text(encoding="utf-8"))
394
+ except Exception as exc:
395
+ print(f"# wr-itil-plugin-maturity-populate: skipping unreadable plugin.json at {plugin_json_path}: {exc}", file=sys.stderr)
396
+ continue
397
+ if not isinstance(plugin_doc, dict):
398
+ continue
399
+
400
+ exercise = exercise_by_plugin.get(plugin_bare, {
401
+ "days_shipped": 0,
402
+ "closed_tickets_window": 0,
403
+ "breaking_change_age_days": None,
404
+ })
405
+
406
+ surfaces = discover_surfaces(pkg_dir)
407
+ total_plugins += 1
408
+
409
+ # Per-kind surface maps.
410
+ kind_to_key = {"skill": "skills", "agent": "agents", "hook": "hooks", "command": "commands"}
411
+ surface_bands_for_rollup = []
412
+
413
+ for kind, names in surfaces.items():
414
+ if not names:
415
+ continue
416
+ key = kind_to_key[kind]
417
+ existing_map = plugin_doc.get(key, {})
418
+ if not isinstance(existing_map, dict):
419
+ existing_map = {}
420
+ new_map = {}
421
+ for name in names:
422
+ existing_entry = existing_map.get(name, {}) if isinstance(existing_map.get(name), dict) else {}
423
+ evidence = {
424
+ "invocations_30d": lookup_invocations(plugin_bare, kind, name),
425
+ "days_shipped": exercise["days_shipped"],
426
+ "closed_tickets_window": exercise["closed_tickets_window"],
427
+ "breaking_change_age_days": exercise["breaking_change_age_days"],
428
+ }
429
+ maturity_record = build_surface_record(existing_entry, kind, plugin_bare, name, evidence)
430
+ # Preserve any extra keys on the existing entry (forward-compat).
431
+ merged_entry = dict(existing_entry)
432
+ merged_entry["maturity"] = maturity_record
433
+ new_map[name] = merged_entry
434
+ surface_bands_for_rollup.append(maturity_record.get("band"))
435
+ plugin_doc[key] = new_map
436
+ wrote_records += len(new_map)
437
+
438
+ # Plugin root rollup (ADR-063 §rollup schema: schema_version + band only).
439
+ rollup = rollup_band(surface_bands_for_rollup)
440
+ if rollup is not None:
441
+ plugin_doc["maturity"] = {"schema_version": "1.0", "band": rollup}
442
+ else:
443
+ # Plugin with no shipped surfaces -> no plugin-level maturity field
444
+ # (ADR-053 §granularity contract line 110).
445
+ plugin_doc.pop("maturity", None)
446
+
447
+ # Serialise canonically: sorted keys + 2-space indent. Idempotency
448
+ # depends on this stability (architect §H).
449
+ new_text = json.dumps(plugin_doc, indent=2, sort_keys=True) + "\n"
450
+ old_text = plugin_json_path.read_text(encoding="utf-8")
451
+
452
+ if new_text == old_text:
453
+ unchanged_records += 1
454
+ continue
455
+
456
+ if dry_run:
457
+ sys.stdout.write(f"--- {plugin_json_path}\n+++ would-write\n")
458
+ sys.stdout.write(new_text)
459
+ sys.stdout.write("\n")
460
+ else:
461
+ plugin_json_path.write_text(new_text, encoding="utf-8")
462
+
463
+ # ── Operator-facing freshness summary on stderr (JTBD-007 currency aid) ────
464
+ # Non-load-bearing; cheap operator-comfort signal per JTBD review §4.
465
+
466
+ print(
467
+ f"# wr-itil-plugin-maturity-populate: "
468
+ f"plugins={total_plugins} surfaces_written={wrote_records} "
469
+ f"unchanged_plugins={unchanged_records} "
470
+ f"bootstrapping={'active' if bootstrapping_active else 'inactive'} "
471
+ f"suite_oldest_days={suite_oldest_days} "
472
+ f"computed_at={now_canonical_iso}",
473
+ file=sys.stderr,
474
+ )
475
+ PYEOF
476
+
477
+ exit 0
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P237 — Phase 3a population script behavioural confirmation.
4
+ # @problem P087 — parent: plugin maturity battle-hardening signal.
5
+ #
6
+ # Contract under test: `packages/itil/scripts/plugin-maturity-populate.sh`
7
+ # reads two Phase 2 NDJSON streams (`wr-itil-skill-invocations` +
8
+ # `wr-itil-plugin-exercise-index`), applies ADR-053 §promotion criteria +
9
+ # §Bootstrapping clause, and writes `maturity:` field per surface and per
10
+ # plugin root in each `packages/<plugin>/.claude-plugin/plugin.json`.
11
+ # Idempotent — re-running with unchanged inputs (including pinned `--now`)
12
+ # produces byte-identical plugin.json output.
13
+ #
14
+ # Confirmation criteria 1-3 from ADR-063 §Confirmation are the load-bearing
15
+ # behavioural assertions; criteria 4-9 belong to Phase 3b / 3c sibling
16
+ # tickets (P238 / P239).
17
+ #
18
+ # @adr ADR-063 (Plugin maturity presentation layer — Phase 3a contract)
19
+ # @adr ADR-053 (Plugin maturity taxonomy — promotion criteria + Bootstrapping)
20
+ # @adr ADR-058 (Phase 2 NDJSON measurement — input shape)
21
+ # @adr ADR-049 (Shim grammar — `wr-itil-plugin-maturity-populate` on $PATH)
22
+ # @adr ADR-052 (Behavioural tests default — NDJSON-fixture-driven, not
23
+ # structural grep on script body; AskUserQuestion negative-presence is
24
+ # the documented carve-out)
25
+ # @adr ADR-044 (Silent-framework carve-out — Phase 3a band recomputation
26
+ # is mechanical, policy-resolved per ADR-053 §promotion criteria; no
27
+ # `AskUserQuestion` per band recompute)
28
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
29
+ # @jtbd JTBD-201 (Restore Service Fast — audit-trail composition; the
30
+ # per-surface evidence block IS the durable audit-trail surface)
31
+
32
+ setup() {
33
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
34
+ SCRIPT="$SCRIPTS_DIR/plugin-maturity-populate.sh"
35
+ FIXTURE_DIR="$(mktemp -d)"
36
+ PROJECT_ROOT="$FIXTURE_DIR/project"
37
+ mkdir -p "$PROJECT_ROOT/packages"
38
+ }
39
+
40
+ teardown() {
41
+ rm -rf "$FIXTURE_DIR"
42
+ }
43
+
44
+ # Helper: create a synthetic plugin under packages/<name>/ with a
45
+ # minimal plugin.json and the declared surface inventory. Surfaces are
46
+ # passed as `kind:name` tokens — e.g. `skill:manage-problem`,
47
+ # `agent:agent`, `hook:itil-changeset-discipline`.
48
+ make_plugin() {
49
+ local plugin="$1"; shift
50
+ local pkg="$PROJECT_ROOT/packages/$plugin"
51
+ mkdir -p "$pkg/.claude-plugin"
52
+ cat >"$pkg/.claude-plugin/plugin.json" <<EOF
53
+ {
54
+ "name": "wr-$plugin",
55
+ "version": "0.1.0",
56
+ "description": "fixture plugin"
57
+ }
58
+ EOF
59
+ for token in "$@"; do
60
+ local kind="${token%%:*}"
61
+ local name="${token#*:}"
62
+ case "$kind" in
63
+ skill) mkdir -p "$pkg/skills/$name"; echo "fixture" >"$pkg/skills/$name/SKILL.md" ;;
64
+ agent) mkdir -p "$pkg/agents"; echo "fixture" >"$pkg/agents/$name.md" ;;
65
+ hook) mkdir -p "$pkg/hooks"; echo "fixture" >"$pkg/hooks/$name.sh" ;;
66
+ command) mkdir -p "$pkg/commands"; echo "fixture" >"$pkg/commands/$name.md" ;;
67
+ esac
68
+ done
69
+ }
70
+
71
+ # Helper: write a synthetic transcript NDJSON file (`wr-itil-skill-invocations`
72
+ # output shape, schema_version 1.0) with one record per (kind, surface).
73
+ # Args: file, then triples of kind, surface, invocations.
74
+ write_transcript_ndjson() {
75
+ local out="$1"; shift
76
+ : >"$out"
77
+ while [ "$#" -ge 3 ]; do
78
+ local kind="$1"; local surface="$2"; local invocations="$3"
79
+ shift 3
80
+ local plugin
81
+ # Extract plugin name: skill/agent surfaces use `wr-<plugin>:<rest>`,
82
+ # bash-attributed surfaces use `wr-<plugin>-<rest>`.
83
+ if [[ "$surface" == *:* ]]; then
84
+ plugin="${surface%%:*}"
85
+ plugin="${plugin#wr-}"
86
+ else
87
+ plugin="${surface#wr-}"
88
+ plugin="${plugin%%-*}"
89
+ fi
90
+ printf '{"schema_version":"1.0","axis":"skill-invocations","surface":"%s","kind":"%s","plugin":"%s","window_days":30,"invocations":%d,"first_invocation_iso":"2026-04-20T00:00:00Z","last_invocation_iso":"2026-05-17T00:00:00Z"}\n' \
91
+ "$surface" "$kind" "$plugin" "$invocations" >>"$out"
92
+ done
93
+ }
94
+
95
+ # Helper: write a synthetic exercise NDJSON file (`wr-itil-plugin-exercise-index`
96
+ # output shape, schema_version 1.0). Args: file, then 5-tuples of
97
+ # plugin, commits_window, days_shipped, closed_tickets, breaking_age (or NULL).
98
+ write_exercise_ndjson() {
99
+ local out="$1"; shift
100
+ : >"$out"
101
+ while [ "$#" -ge 5 ]; do
102
+ local plugin="$1"; local cw="$2"; local ds="$3"; local ctw="$4"; local bca="$5"
103
+ shift 5
104
+ if [ "$bca" = "NULL" ]; then
105
+ bca="null"
106
+ fi
107
+ printf '{"schema_version":"1.0","axis":"plugin-exercise-index","plugin":"%s","commits_window":%d,"window_days":60,"days_shipped":%d,"closed_tickets_window":%d,"tickets_window_days":90,"breaking_change_age_days":%s,"composite_index":1.0}\n' \
108
+ "$plugin" "$cw" "$ds" "$ctw" "$bca" >>"$out"
109
+ done
110
+ }
111
+
112
+ # Helper: extract a JSON field from a plugin.json via python3 stdlib.
113
+ # Args: plugin-json-path, dotted-path (e.g. `maturity.band` or
114
+ # `skills.manage-problem.maturity.band`).
115
+ get_json_field() {
116
+ local file="$1"; local path="$2"
117
+ python3 -c "
118
+ import json, sys
119
+ with open('$file') as fh:
120
+ obj = json.load(fh)
121
+ for part in '$path'.split('.'):
122
+ if not isinstance(obj, dict) or part not in obj:
123
+ print('MISSING'); sys.exit(0)
124
+ obj = obj[part]
125
+ print(obj if not isinstance(obj, (dict, list)) else json.dumps(obj))
126
+ "
127
+ }
128
+
129
+ # ── Existence / executable ──────────────────────────────────────────────────
130
+
131
+ @test "plugin-maturity-populate: canonical script exists" {
132
+ [ -f "$SCRIPT" ]
133
+ }
134
+
135
+ @test "plugin-maturity-populate: canonical script is executable" {
136
+ [ -x "$SCRIPT" ]
137
+ }
138
+
139
+ @test "plugin-maturity-populate: shim file exists with ADR-049 grammar" {
140
+ local shim="$SCRIPTS_DIR/../bin/wr-itil-plugin-maturity-populate"
141
+ [ -f "$shim" ]
142
+ [ -x "$shim" ]
143
+ grep -q 'exec.*scripts/plugin-maturity-populate.sh' "$shim"
144
+ }
145
+
146
+ # ── Confirmation #1: idempotency (ADR-063 §Confirmation 1) ──────────────────
147
+ # Run twice with the same --now pin; assert byte-identical plugin.json.
148
+
149
+ @test "plugin-maturity-populate: idempotency — second run produces byte-identical plugin.json" {
150
+ make_plugin "fixp" "skill:manage-problem" "agent:agent"
151
+ local trans="$FIXTURE_DIR/transcript.ndjson"
152
+ local exer="$FIXTURE_DIR/exercise.ndjson"
153
+ write_transcript_ndjson "$trans" \
154
+ "skill" "wr-fixp:manage-problem" 50 \
155
+ "agent" "wr-fixp:agent" 200
156
+ write_exercise_ndjson "$exer" \
157
+ "fixp" 30 20 3 NULL
158
+
159
+ run "$SCRIPT" \
160
+ --transcript-ndjson="$trans" \
161
+ --exercise-ndjson="$exer" \
162
+ --project-root="$PROJECT_ROOT" \
163
+ --now=2026-05-17T12:00:00Z
164
+ [ "$status" -eq 0 ]
165
+
166
+ local pj="$PROJECT_ROOT/packages/fixp/.claude-plugin/plugin.json"
167
+ local checksum_1
168
+ checksum_1=$(python3 -c "import hashlib; print(hashlib.sha256(open('$pj','rb').read()).hexdigest())")
169
+
170
+ run "$SCRIPT" \
171
+ --transcript-ndjson="$trans" \
172
+ --exercise-ndjson="$exer" \
173
+ --project-root="$PROJECT_ROOT" \
174
+ --now=2026-05-17T12:00:00Z
175
+ [ "$status" -eq 0 ]
176
+
177
+ local checksum_2
178
+ checksum_2=$(python3 -c "import hashlib; print(hashlib.sha256(open('$pj','rb').read()).hexdigest())")
179
+ [ "$checksum_1" = "$checksum_2" ]
180
+ }
181
+
182
+ # ── Confirmation #2: band-mapping during bootstrapping (ADR-063 #2 first half) ─
183
+ # 20 days_shipped → bootstrapping active (suite-oldest < 60d).
184
+ # 200 invocations + 20 days_shipped → meets provisional Alpha conditions
185
+ # (≥100 invocations + ≥14 days). Lower invocations → Experimental.
186
+
187
+ @test "plugin-maturity-populate: bootstrapping — provisional Alpha on high-invocation surface" {
188
+ make_plugin "alphap" "agent:agent"
189
+ local trans="$FIXTURE_DIR/transcript.ndjson"
190
+ local exer="$FIXTURE_DIR/exercise.ndjson"
191
+ write_transcript_ndjson "$trans" "agent" "wr-alphap:agent" 200
192
+ write_exercise_ndjson "$exer" "alphap" 30 20 3 NULL
193
+
194
+ run "$SCRIPT" \
195
+ --transcript-ndjson="$trans" \
196
+ --exercise-ndjson="$exer" \
197
+ --project-root="$PROJECT_ROOT" \
198
+ --now=2026-05-17T12:00:00Z
199
+ [ "$status" -eq 0 ]
200
+
201
+ local pj="$PROJECT_ROOT/packages/alphap/.claude-plugin/plugin.json"
202
+ local band
203
+ band=$(get_json_field "$pj" "agents.agent.maturity.band")
204
+ [ "$band" = "Alpha" ]
205
+ }
206
+
207
+ @test "plugin-maturity-populate: bootstrapping — Experimental on low-invocation surface" {
208
+ make_plugin "expp" "skill:list-incidents"
209
+ local trans="$FIXTURE_DIR/transcript.ndjson"
210
+ local exer="$FIXTURE_DIR/exercise.ndjson"
211
+ write_transcript_ndjson "$trans" "skill" "wr-expp:list-incidents" 2
212
+ write_exercise_ndjson "$exer" "expp" 5 20 1 NULL
213
+
214
+ run "$SCRIPT" \
215
+ --transcript-ndjson="$trans" \
216
+ --exercise-ndjson="$exer" \
217
+ --project-root="$PROJECT_ROOT" \
218
+ --now=2026-05-17T12:00:00Z
219
+ [ "$status" -eq 0 ]
220
+
221
+ local pj="$PROJECT_ROOT/packages/expp/.claude-plugin/plugin.json"
222
+ local band
223
+ band=$(get_json_field "$pj" "skills.list-incidents.maturity.band")
224
+ [ "$band" = "Experimental" ]
225
+ }
226
+
227
+ # ── Confirmation #2 second half: steady-state post-sunset (ADR-063 #2) ──────
228
+ # Suite-oldest ≥60 days_shipped → bootstrapping inactive.
229
+ # 796 invocations + 200 days_shipped + 15 tickets + null breaking → Beta-
230
+ # floor satisfied, Stable-floor not (invocations <1000). Expect Beta.
231
+
232
+ @test "plugin-maturity-populate: steady-state — Beta on heavy-invocation aged surface" {
233
+ make_plugin "betap" "agent:agent"
234
+ local trans="$FIXTURE_DIR/transcript.ndjson"
235
+ local exer="$FIXTURE_DIR/exercise.ndjson"
236
+ write_transcript_ndjson "$trans" "agent" "wr-betap:agent" 796
237
+ write_exercise_ndjson "$exer" "betap" 100 200 15 NULL
238
+
239
+ run "$SCRIPT" \
240
+ --transcript-ndjson="$trans" \
241
+ --exercise-ndjson="$exer" \
242
+ --project-root="$PROJECT_ROOT" \
243
+ --now=2026-07-01T12:00:00Z
244
+ [ "$status" -eq 0 ]
245
+
246
+ local pj="$PROJECT_ROOT/packages/betap/.claude-plugin/plugin.json"
247
+ local band
248
+ band=$(get_json_field "$pj" "agents.agent.maturity.band")
249
+ [ "$band" = "Beta" ]
250
+ }
251
+
252
+ # ── Confirmation #3: no AskUserQuestion per band recompute ──────────────────
253
+ # ADR-044 silent-framework carve-out (ADR-063 §scope-limited carve-out).
254
+ # Negative-presence check on stdin / stderr — case-insensitive per architect
255
+ # adjustment G.
256
+
257
+ @test "plugin-maturity-populate: no AskUserQuestion invocation during band recompute" {
258
+ make_plugin "silentp" "skill:list-stories"
259
+ local trans="$FIXTURE_DIR/transcript.ndjson"
260
+ local exer="$FIXTURE_DIR/exercise.ndjson"
261
+ write_transcript_ndjson "$trans" "skill" "wr-silentp:list-stories" 50
262
+ write_exercise_ndjson "$exer" "silentp" 10 20 2 NULL
263
+
264
+ run "$SCRIPT" \
265
+ --transcript-ndjson="$trans" \
266
+ --exercise-ndjson="$exer" \
267
+ --project-root="$PROJECT_ROOT" \
268
+ --now=2026-05-17T12:00:00Z
269
+ [ "$status" -eq 0 ]
270
+
271
+ # Case-insensitive scan of combined stdout + stderr for any
272
+ # AskUserQuestion-token spelling.
273
+ printf '%s' "$output" | grep -i -E 'askuserquestion|<askuser' && return 1 || true
274
+ }
275
+
276
+ # ── Schema shape (ADR-063 §plugin.json maturity field schema) ───────────────
277
+
278
+ @test "plugin-maturity-populate: per-surface maturity record carries schema_version + band + computed_at + evidence" {
279
+ make_plugin "shapep" "skill:manage-problem"
280
+ local trans="$FIXTURE_DIR/transcript.ndjson"
281
+ local exer="$FIXTURE_DIR/exercise.ndjson"
282
+ write_transcript_ndjson "$trans" "skill" "wr-shapep:manage-problem" 100
283
+ write_exercise_ndjson "$exer" "shapep" 20 25 5 NULL
284
+
285
+ run "$SCRIPT" \
286
+ --transcript-ndjson="$trans" \
287
+ --exercise-ndjson="$exer" \
288
+ --project-root="$PROJECT_ROOT" \
289
+ --now=2026-05-17T12:00:00Z
290
+ [ "$status" -eq 0 ]
291
+
292
+ local pj="$PROJECT_ROOT/packages/shapep/.claude-plugin/plugin.json"
293
+ [ "$(get_json_field "$pj" "skills.manage-problem.maturity.schema_version")" = "1.0" ]
294
+ [ "$(get_json_field "$pj" "skills.manage-problem.maturity.band")" != "MISSING" ]
295
+ [ "$(get_json_field "$pj" "skills.manage-problem.maturity.computed_at")" != "MISSING" ]
296
+ [ "$(get_json_field "$pj" "skills.manage-problem.maturity.evidence.invocations_30d")" = "100" ]
297
+ [ "$(get_json_field "$pj" "skills.manage-problem.maturity.evidence.days_shipped")" = "25" ]
298
+ [ "$(get_json_field "$pj" "skills.manage-problem.maturity.evidence.closed_tickets_window")" = "5" ]
299
+ }
300
+
301
+ @test "plugin-maturity-populate: plugin root rollup carries schema_version + band only, no evidence" {
302
+ make_plugin "rollupp" "skill:s1" "skill:s2"
303
+ local trans="$FIXTURE_DIR/transcript.ndjson"
304
+ local exer="$FIXTURE_DIR/exercise.ndjson"
305
+ write_transcript_ndjson "$trans" \
306
+ "skill" "wr-rollupp:s1" 200 \
307
+ "skill" "wr-rollupp:s2" 1
308
+ write_exercise_ndjson "$exer" "rollupp" 10 20 2 NULL
309
+
310
+ run "$SCRIPT" \
311
+ --transcript-ndjson="$trans" \
312
+ --exercise-ndjson="$exer" \
313
+ --project-root="$PROJECT_ROOT" \
314
+ --now=2026-05-17T12:00:00Z
315
+ [ "$status" -eq 0 ]
316
+
317
+ local pj="$PROJECT_ROOT/packages/rollupp/.claude-plugin/plugin.json"
318
+ [ "$(get_json_field "$pj" "maturity.schema_version")" = "1.0" ]
319
+ [ "$(get_json_field "$pj" "maturity.band")" != "MISSING" ]
320
+ [ "$(get_json_field "$pj" "maturity.evidence")" = "MISSING" ]
321
+ }
322
+
323
+ # ── Granularity contract (ADR-063 #10): rollup = worst-case among surfaces ──
324
+
325
+ @test "plugin-maturity-populate: rollup band equals worst-case among constituent surfaces" {
326
+ make_plugin "worstp" "skill:hot" "skill:cold"
327
+ local trans="$FIXTURE_DIR/transcript.ndjson"
328
+ local exer="$FIXTURE_DIR/exercise.ndjson"
329
+ # hot → Alpha (200 invocations + 20 days during bootstrapping);
330
+ # cold → Experimental (1 invocation). Worst-case = Experimental.
331
+ write_transcript_ndjson "$trans" \
332
+ "skill" "wr-worstp:hot" 200 \
333
+ "skill" "wr-worstp:cold" 1
334
+ write_exercise_ndjson "$exer" "worstp" 10 20 2 NULL
335
+
336
+ run "$SCRIPT" \
337
+ --transcript-ndjson="$trans" \
338
+ --exercise-ndjson="$exer" \
339
+ --project-root="$PROJECT_ROOT" \
340
+ --now=2026-05-17T12:00:00Z
341
+ [ "$status" -eq 0 ]
342
+
343
+ local pj="$PROJECT_ROOT/packages/worstp/.claude-plugin/plugin.json"
344
+ [ "$(get_json_field "$pj" "maturity.band")" = "Experimental" ]
345
+ }
346
+
347
+ # ── Hook evidence shape (architect adjustment C) — null invocation sentinel ──
348
+ # Hooks are not transcript-observable; invocations_30d MUST be `null`, not 0,
349
+ # to preserve "not measurable" vs "measurably zero" semantics.
350
+
351
+ @test "plugin-maturity-populate: hook surfaces emit null invocations_30d sentinel" {
352
+ make_plugin "hookp" "hook:itil-fictional-defer-detect"
353
+ local trans="$FIXTURE_DIR/transcript.ndjson"
354
+ local exer="$FIXTURE_DIR/exercise.ndjson"
355
+ # Empty transcript NDJSON — hooks never appear in transcript stream.
356
+ : >"$trans"
357
+ write_exercise_ndjson "$exer" "hookp" 5 30 1 NULL
358
+
359
+ run "$SCRIPT" \
360
+ --transcript-ndjson="$trans" \
361
+ --exercise-ndjson="$exer" \
362
+ --project-root="$PROJECT_ROOT" \
363
+ --now=2026-05-17T12:00:00Z
364
+ [ "$status" -eq 0 ]
365
+
366
+ local pj="$PROJECT_ROOT/packages/hookp/.claude-plugin/plugin.json"
367
+ # Read the raw JSON and check that invocations_30d is literally null,
368
+ # not 0. (`get_json_field` collapses null/0 to display; use python3 directly.)
369
+ local raw
370
+ raw=$(python3 -c "
371
+ import json
372
+ obj = json.load(open('$pj'))
373
+ print(json.dumps(obj['hooks']['itil-fictional-defer-detect']['maturity']['evidence']['invocations_30d']))
374
+ ")
375
+ [ "$raw" = "null" ]
376
+ }
377
+
378
+ # ── Deprecated-band overlay preservation (architect adjustment I) ───────────
379
+ # Pre-existing author-declared `band: "Deprecated"` + `supersededBy:` MUST
380
+ # survive recompute unchanged. Recompute does NOT overwrite Deprecated
381
+ # downward.
382
+
383
+ @test "plugin-maturity-populate: preserves author-declared Deprecated band + supersededBy pointer" {
384
+ make_plugin "depp" "skill:oldskill"
385
+ local pj="$PROJECT_ROOT/packages/depp/.claude-plugin/plugin.json"
386
+ # Hand-author the Deprecated entry that the script must preserve.
387
+ python3 <<EOF
388
+ import json
389
+ obj = json.load(open("$pj"))
390
+ obj.setdefault("skills", {})["oldskill"] = {
391
+ "maturity": {
392
+ "schema_version": "1.0",
393
+ "band": "Deprecated",
394
+ "computed_at": "2026-04-01T00:00:00Z",
395
+ "supersededBy": "wr-depp:newskill",
396
+ "evidence": {
397
+ "invocations_30d": 50,
398
+ "days_shipped": 100,
399
+ "closed_tickets_window": 3,
400
+ "breaking_change_age_days": None,
401
+ },
402
+ }
403
+ }
404
+ with open("$pj","w") as fh:
405
+ json.dump(obj, fh, indent=2, sort_keys=True)
406
+ EOF
407
+ local trans="$FIXTURE_DIR/transcript.ndjson"
408
+ local exer="$FIXTURE_DIR/exercise.ndjson"
409
+ write_transcript_ndjson "$trans" "skill" "wr-depp:oldskill" 50
410
+ write_exercise_ndjson "$exer" "depp" 5 100 3 NULL
411
+
412
+ run "$SCRIPT" \
413
+ --transcript-ndjson="$trans" \
414
+ --exercise-ndjson="$exer" \
415
+ --project-root="$PROJECT_ROOT" \
416
+ --now=2026-05-17T12:00:00Z
417
+ [ "$status" -eq 0 ]
418
+
419
+ [ "$(get_json_field "$pj" "skills.oldskill.maturity.band")" = "Deprecated" ]
420
+ [ "$(get_json_field "$pj" "skills.oldskill.maturity.supersededBy")" = "wr-depp:newskill" ]
421
+ }
422
+
423
+ # ── Bootstrapping clause sunset auto-derivation (architect adjustment D) ────
424
+ # Sunset is computed from `max(days_shipped)` ≥ 60 in the exercise NDJSON.
425
+ # Same surface evidence → Experimental during bootstrapping (suite-oldest < 60d);
426
+ # Beta+ once suite-oldest ≥ 60d (no calendar-date hard-code required).
427
+
428
+ @test "plugin-maturity-populate: bootstrapping lapses when max(days_shipped) ≥ 60" {
429
+ make_plugin "p1" "agent:agent"
430
+ make_plugin "p2" "agent:agent"
431
+ local trans="$FIXTURE_DIR/transcript.ndjson"
432
+ local exer="$FIXTURE_DIR/exercise.ndjson"
433
+ # Both plugins have 796 invocations + 200 days_shipped + 15 tickets.
434
+ # In steady-state these would each be Beta. Under bootstrapping (oldest <60d)
435
+ # they would be Alpha. Test: with one plugin aged 200d, bootstrapping is
436
+ # inactive and the band is Beta. Independent of --now (no hard-coded date).
437
+ write_transcript_ndjson "$trans" \
438
+ "agent" "wr-p1:agent" 796 \
439
+ "agent" "wr-p2:agent" 796
440
+ write_exercise_ndjson "$exer" \
441
+ "p1" 100 200 15 NULL \
442
+ "p2" 100 25 15 NULL
443
+
444
+ run "$SCRIPT" \
445
+ --transcript-ndjson="$trans" \
446
+ --exercise-ndjson="$exer" \
447
+ --project-root="$PROJECT_ROOT" \
448
+ --now=2026-05-17T12:00:00Z
449
+ [ "$status" -eq 0 ]
450
+
451
+ local pj1="$PROJECT_ROOT/packages/p1/.claude-plugin/plugin.json"
452
+ local pj2="$PROJECT_ROOT/packages/p2/.claude-plugin/plugin.json"
453
+ # p1 aged 200d → Beta. p2 aged 25d → Beta-floor unmet (days <60), demotes
454
+ # to Alpha steady-state OR Experimental depending on the days_shipped cell;
455
+ # only assert here that bootstrapping is inactive (the p1 outcome).
456
+ [ "$(get_json_field "$pj1" "agents.agent.maturity.band")" = "Beta" ]
457
+ }
458
+
459
+ # ── ADR-013 Rule 6 fail-safe — missing NDJSON inputs ────────────────────────
460
+
461
+ @test "plugin-maturity-populate: exits 0 when transcript NDJSON missing" {
462
+ make_plugin "failsafe" "skill:thing"
463
+ local exer="$FIXTURE_DIR/exercise.ndjson"
464
+ write_exercise_ndjson "$exer" "failsafe" 10 20 2 NULL
465
+
466
+ run "$SCRIPT" \
467
+ --transcript-ndjson="$FIXTURE_DIR/does-not-exist.ndjson" \
468
+ --exercise-ndjson="$exer" \
469
+ --project-root="$PROJECT_ROOT" \
470
+ --now=2026-05-17T12:00:00Z
471
+ [ "$status" -eq 0 ]
472
+ }
473
+
474
+ @test "plugin-maturity-populate: exits 0 when no packages directory exists" {
475
+ rm -rf "$PROJECT_ROOT/packages"
476
+ local trans="$FIXTURE_DIR/transcript.ndjson"
477
+ local exer="$FIXTURE_DIR/exercise.ndjson"
478
+ : >"$trans"; : >"$exer"
479
+
480
+ run "$SCRIPT" \
481
+ --transcript-ndjson="$trans" \
482
+ --exercise-ndjson="$exer" \
483
+ --project-root="$PROJECT_ROOT" \
484
+ --now=2026-05-17T12:00:00Z
485
+ [ "$status" -eq 0 ]
486
+ }
487
+
488
+ # ── Privacy posture (mirrored from ADR-058 §Confirmation #3) ────────────────
489
+ # No network primitive in the script body — defensive negative-grep.
490
+
491
+ @test "plugin-maturity-populate: script body invokes no network egress primitives" {
492
+ ! grep -E '(\bcurl\b|\bwget\b|\bnc\b|fetch\b|http\.client|urllib|socket\.connect)' "$SCRIPT"
493
+ }