@windyroad/itil 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -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
|
+
}
|