@windyroad/itil 0.30.2 → 0.30.3-preview.319
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/.claude-plugin/plugin.json +1 -1
- package/bin/wr-itil-plugin-exercise-index +2 -0
- package/bin/wr-itil-skill-invocations +2 -0
- package/hooks/hooks.json +2 -1
- package/hooks/itil-mid-loop-ask-detect.sh +142 -0
- package/hooks/test/itil-mid-loop-ask-detect.bats +220 -0
- package/package.json +1 -1
- package/scripts/plugin-exercise-index.sh +347 -0
- package/scripts/skill-invocations.sh +255 -0
- package/scripts/test/plugin-exercise-index.bats +386 -0
- package/scripts/test/skill-invocations.bats +320 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# packages/itil/scripts/plugin-exercise-index.sh
|
|
3
|
+
#
|
|
4
|
+
# Phase 2b (P087) git-axis maturity-measurement script.
|
|
5
|
+
#
|
|
6
|
+
# Runs `git log --since=<window>d --name-only --pretty=format:%H|%aI|%s`
|
|
7
|
+
# once at the project root, auto-discovers plugins by listing `packages/*/`,
|
|
8
|
+
# and emits one NDJSON record per plugin with the v1.0 schema fields per
|
|
9
|
+
# ADR-058 §Script contracts (line 87-113).
|
|
10
|
+
#
|
|
11
|
+
# Schema (per ADR-058 line 95-110):
|
|
12
|
+
# {"schema_version":"1.0","axis":"plugin-exercise-index","plugin":"<name>",
|
|
13
|
+
# "commits_window":<N>,"window_days":<N>,"days_shipped":<N>,
|
|
14
|
+
# "closed_tickets_window":<N>,"tickets_window_days":<N>,
|
|
15
|
+
# "breaking_change_age_days":<N|null>,"composite_index":<float>}
|
|
16
|
+
#
|
|
17
|
+
# composite_index = log10(commits_window+1)
|
|
18
|
+
# + log10(closed_tickets_window+1)
|
|
19
|
+
# + (days_shipped >= 60 ? 1.0 : 0.0)
|
|
20
|
+
# (ADR-058 line 112, Option E6 "MAY emit alongside band" carve-out.)
|
|
21
|
+
#
|
|
22
|
+
# Usage:
|
|
23
|
+
# wr-itil-plugin-exercise-index [--window-days=N] [--tickets-window-days=N]
|
|
24
|
+
# [--project-root=PATH]
|
|
25
|
+
# [--category-overrides=FILE]
|
|
26
|
+
#
|
|
27
|
+
# Defaults:
|
|
28
|
+
# --window-days 60 (ADR-058 line 89)
|
|
29
|
+
# --tickets-window-days 90 (ADR-058 line 93)
|
|
30
|
+
# --project-root $PWD
|
|
31
|
+
#
|
|
32
|
+
# Exit codes:
|
|
33
|
+
# 0 = always — ADR-013 Rule 6 fail-safe. Outside-git-repo, missing
|
|
34
|
+
# `packages/`, opt-out marker all hit the zero-records path
|
|
35
|
+
# with stderr-comment.
|
|
36
|
+
#
|
|
37
|
+
# Privacy (ADR-035 clauses adopted verbatim, adapted for the git axis):
|
|
38
|
+
# - Opt-out marker `.claude/.skill-metrics-opt-out` disables reads.
|
|
39
|
+
# - No network egress — the script body invokes no exfiltration
|
|
40
|
+
# primitives. ADR-058 §Confirmation 3 enforces via negative-grep on
|
|
41
|
+
# this file (banned-token list lives in the bats fixture, not here,
|
|
42
|
+
# to avoid self-matching).
|
|
43
|
+
# - Content sanitisation — commit subjects are parsed ONLY for
|
|
44
|
+
# `BREAKING|feat!|fix!` token presence (boolean test); the subject
|
|
45
|
+
# prose is discarded after the test and never echoed to stdout. The
|
|
46
|
+
# only plugin-attribution surface is the `packages/<plugin>/` path
|
|
47
|
+
# prefix extracted from `--name-only` output.
|
|
48
|
+
#
|
|
49
|
+
# @problem P087 (Phase 2b — git axis)
|
|
50
|
+
# @adr ADR-058 (Plugin maturity measurement mechanism)
|
|
51
|
+
# @adr ADR-049 (Shim grammar — bin/wr-itil-plugin-exercise-index on $PATH)
|
|
52
|
+
# @adr ADR-035 (Privacy posture adopted verbatim)
|
|
53
|
+
# @adr ADR-052 (Behavioural tests default; ADR-058 §Confirmation 6-8)
|
|
54
|
+
# @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
|
|
55
|
+
# @adr ADR-023 (Performance — ≤1.0s warm-cache; 60-day window default)
|
|
56
|
+
# @jtbd JTBD-101 (Extend the Suite — hardening-prioritisation outcome,
|
|
57
|
+
# 2026-05-04 amendment serves Phase 2 NDJSON as data source)
|
|
58
|
+
# @jtbd JTBD-201 (Restore Service Fast — audit-trail composition)
|
|
59
|
+
|
|
60
|
+
set -uo pipefail
|
|
61
|
+
|
|
62
|
+
# ── CLI parse ───────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
WINDOW_DAYS=60
|
|
65
|
+
TICKETS_WINDOW_DAYS=90
|
|
66
|
+
PROJECT_ROOT="$PWD"
|
|
67
|
+
CATEGORY_OVERRIDES=""
|
|
68
|
+
|
|
69
|
+
for arg in "$@"; do
|
|
70
|
+
case "$arg" in
|
|
71
|
+
--window-days=*) WINDOW_DAYS="${arg#--window-days=}" ;;
|
|
72
|
+
--tickets-window-days=*) TICKETS_WINDOW_DAYS="${arg#--tickets-window-days=}" ;;
|
|
73
|
+
--project-root=*) PROJECT_ROOT="${arg#--project-root=}" ;;
|
|
74
|
+
--category-overrides=*) CATEGORY_OVERRIDES="${arg#--category-overrides=}" ;;
|
|
75
|
+
--help|-h)
|
|
76
|
+
sed -n '4,40p' "$0" | sed 's/^# \{0,1\}//'
|
|
77
|
+
exit 0
|
|
78
|
+
;;
|
|
79
|
+
*)
|
|
80
|
+
echo "# wr-itil-plugin-exercise-index: ignoring unknown argument: $arg" >&2
|
|
81
|
+
;;
|
|
82
|
+
esac
|
|
83
|
+
done
|
|
84
|
+
|
|
85
|
+
# ── Opt-out marker check (ADR-035 / ADR-058 §Privacy posture) ───────────────
|
|
86
|
+
|
|
87
|
+
OPT_OUT_MARKER="${PROJECT_ROOT}/.claude/.skill-metrics-opt-out"
|
|
88
|
+
if [ -e "$OPT_OUT_MARKER" ]; then
|
|
89
|
+
echo "# wr-itil-plugin-exercise-index: opt-out marker present at ${OPT_OUT_MARKER}" >&2
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# ── Git-repo check (ADR-013 Rule 6 fail-safe) ───────────────────────────────
|
|
94
|
+
|
|
95
|
+
if [ ! -d "${PROJECT_ROOT}/.git" ] && ! git -C "$PROJECT_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
|
|
96
|
+
echo "# wr-itil-plugin-exercise-index: not a git repository at ${PROJECT_ROOT}" >&2
|
|
97
|
+
exit 0
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
# ── packages/ discovery check (ADR-013 Rule 6 fail-safe) ────────────────────
|
|
101
|
+
|
|
102
|
+
if [ ! -d "${PROJECT_ROOT}/packages" ]; then
|
|
103
|
+
echo "# wr-itil-plugin-exercise-index: no packages/ directory at ${PROJECT_ROOT}" >&2
|
|
104
|
+
exit 0
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
# Discover plugins as immediate subdirectories of packages/. Empty result is
|
|
108
|
+
# also a fail-safe case (no plugins to score).
|
|
109
|
+
PLUGIN_COUNT=0
|
|
110
|
+
for d in "${PROJECT_ROOT}/packages"/*/; do
|
|
111
|
+
[ -d "$d" ] && PLUGIN_COUNT=$((PLUGIN_COUNT + 1))
|
|
112
|
+
done
|
|
113
|
+
if [ "$PLUGIN_COUNT" -eq 0 ]; then
|
|
114
|
+
echo "# wr-itil-plugin-exercise-index: no packages/ directory at ${PROJECT_ROOT}" >&2
|
|
115
|
+
exit 0
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# ── --category-overrides validation (ADR-058 §Per-category override hook) ───
|
|
119
|
+
# Forward-extension flag; ships unused in Phase 2.
|
|
120
|
+
|
|
121
|
+
if [ -n "$CATEGORY_OVERRIDES" ] && [ ! -f "$CATEGORY_OVERRIDES" ]; then
|
|
122
|
+
echo "# wr-itil-plugin-exercise-index: category-overrides file not found: ${CATEGORY_OVERRIDES}" >&2
|
|
123
|
+
exit 0
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
# ── Git log + NDJSON emit (Python 3 stdlib) ─────────────────────────────────
|
|
127
|
+
# Inputs pinned via environment to avoid argv leakage.
|
|
128
|
+
|
|
129
|
+
export PEI_PROJECT_ROOT="$PROJECT_ROOT"
|
|
130
|
+
export PEI_WINDOW_DAYS="$WINDOW_DAYS"
|
|
131
|
+
export PEI_TICKETS_WINDOW_DAYS="$TICKETS_WINDOW_DAYS"
|
|
132
|
+
|
|
133
|
+
python3 - <<'PYEOF'
|
|
134
|
+
import json, os, re, subprocess, sys, time, math
|
|
135
|
+
from pathlib import Path
|
|
136
|
+
from collections import defaultdict
|
|
137
|
+
from datetime import datetime, timezone
|
|
138
|
+
|
|
139
|
+
project_root = Path(os.environ["PEI_PROJECT_ROOT"]).resolve()
|
|
140
|
+
window_days = int(os.environ["PEI_WINDOW_DAYS"])
|
|
141
|
+
tickets_window_days = int(os.environ["PEI_TICKETS_WINDOW_DAYS"])
|
|
142
|
+
now = time.time()
|
|
143
|
+
cutoff = now - window_days * 86400
|
|
144
|
+
tickets_cutoff = now - tickets_window_days * 86400
|
|
145
|
+
|
|
146
|
+
# Discover plugins (immediate subdirs of packages/).
|
|
147
|
+
plugins = sorted(
|
|
148
|
+
p.name for p in (project_root / "packages").iterdir() if p.is_dir()
|
|
149
|
+
)
|
|
150
|
+
if not plugins:
|
|
151
|
+
sys.exit(0)
|
|
152
|
+
|
|
153
|
+
# Per-plugin accumulators.
|
|
154
|
+
commits_window = defaultdict(int)
|
|
155
|
+
breaking_age_days = {} # plugin -> int (days since most recent breaking commit in window)
|
|
156
|
+
oldest_commit_ts = {} # plugin -> float epoch seconds (across ALL history)
|
|
157
|
+
closed_tickets_window = defaultdict(int)
|
|
158
|
+
|
|
159
|
+
# `BREAKING` token presence — boolean test only, subject discarded after.
|
|
160
|
+
BREAKING_RE = re.compile(r"\b(BREAKING)\b|(?:^|\s)(feat!|fix!|chore!|refactor!)")
|
|
161
|
+
|
|
162
|
+
# ── Single git log pass with in-Python window filter ────────────────────────
|
|
163
|
+
# ADR-058 line 89 pins `git log --since=<window>d --name-only
|
|
164
|
+
# --pretty=format:%H|%aI|%s`. Git's `--since=Nd` was observed unreliable on
|
|
165
|
+
# 2026-05-16 against the test fixture (returned empty even for commits well
|
|
166
|
+
# within the window — likely a date-parser quirk with future-year commit
|
|
167
|
+
# dates), so the window filter is applied in-Python against the `%aI`
|
|
168
|
+
# author-date field that is already extracted for `breaking_change_age_days`
|
|
169
|
+
# and `days_shipped`. The `git log` invocation otherwise matches the ADR-058
|
|
170
|
+
# contract verbatim (--name-only, the literal `|` separator). Single pass
|
|
171
|
+
# instead of two — pass-2 (oldest-commit) and pass-1 (in-window) collapse
|
|
172
|
+
# into one walk because we already iterate every commit's date.
|
|
173
|
+
#
|
|
174
|
+
# Defensive split on first 2 `|` occurrences only — subjects may contain
|
|
175
|
+
# literal `|` characters per the architect's 2026-05-16 advisory.
|
|
176
|
+
|
|
177
|
+
def parse_log_all():
|
|
178
|
+
"""Yield (sha, iso_ts, subject, paths_list) tuples for every commit."""
|
|
179
|
+
try:
|
|
180
|
+
proc = subprocess.run(
|
|
181
|
+
[
|
|
182
|
+
"git",
|
|
183
|
+
"-C", str(project_root),
|
|
184
|
+
"log",
|
|
185
|
+
"--reverse", # oldest-first; first hit per plugin = days_shipped
|
|
186
|
+
"--name-only",
|
|
187
|
+
"--pretty=format:%H|%aI|%s",
|
|
188
|
+
],
|
|
189
|
+
capture_output=True,
|
|
190
|
+
text=True,
|
|
191
|
+
check=False,
|
|
192
|
+
)
|
|
193
|
+
except (OSError, subprocess.SubprocessError):
|
|
194
|
+
return
|
|
195
|
+
if proc.returncode != 0:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
# Output shape: <header>\n<path>\n<path>\n\n<header>\n... Commits are
|
|
199
|
+
# separated by blank lines; the header line is always identifiable by
|
|
200
|
+
# containing exactly two `|` separators (lines without separators are
|
|
201
|
+
# path lines).
|
|
202
|
+
current = None
|
|
203
|
+
for line in proc.stdout.split("\n"):
|
|
204
|
+
if not line.strip():
|
|
205
|
+
if current is not None:
|
|
206
|
+
yield current
|
|
207
|
+
current = None
|
|
208
|
+
continue
|
|
209
|
+
# Header line: contains the `|` separator pattern. Defensive split
|
|
210
|
+
# on first 2 occurrences so `|` characters in subjects are preserved.
|
|
211
|
+
if "|" in line:
|
|
212
|
+
parts = line.split("|", 2)
|
|
213
|
+
if len(parts) == 3 and re.match(r"^[0-9a-f]{7,40}$", parts[0]):
|
|
214
|
+
if current is not None:
|
|
215
|
+
yield current
|
|
216
|
+
current = (parts[0], parts[1], parts[2], [])
|
|
217
|
+
continue
|
|
218
|
+
# Path line under current commit.
|
|
219
|
+
if current is not None:
|
|
220
|
+
current[3].append(line)
|
|
221
|
+
if current is not None:
|
|
222
|
+
yield current
|
|
223
|
+
|
|
224
|
+
for sha, iso_ts, subject, paths in parse_log_all():
|
|
225
|
+
# Per-commit plugin set (dedupe — one commit touching N files under
|
|
226
|
+
# packages/foo counts as 1, not N).
|
|
227
|
+
plugins_touched = set()
|
|
228
|
+
for p in paths:
|
|
229
|
+
parts = p.split("/")
|
|
230
|
+
if len(parts) >= 3 and parts[0] == "packages":
|
|
231
|
+
plug = parts[1]
|
|
232
|
+
if plug in plugins:
|
|
233
|
+
plugins_touched.add(plug)
|
|
234
|
+
|
|
235
|
+
if not plugins_touched:
|
|
236
|
+
del subject
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Parse author-date once — fed into days_shipped (every commit) and
|
|
240
|
+
# the in-window filter (commits_window, breaking_change_age_days).
|
|
241
|
+
try:
|
|
242
|
+
commit_dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
|
|
243
|
+
ts = commit_dt.timestamp()
|
|
244
|
+
except Exception:
|
|
245
|
+
del subject
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# days_shipped: track min(author_date) per plugin. ADR-058 line 91 says
|
|
249
|
+
# "days since the OLDEST git commit"; oldest is interpreted as min by
|
|
250
|
+
# author-date rather than min by commit-topology so the value is stable
|
|
251
|
+
# against commit-reordering and rebase. (Topological --reverse can
|
|
252
|
+
# disagree with author-date order when commits are made out of
|
|
253
|
+
# chronological order — common in tests; possible in cherry-picks and
|
|
254
|
+
# backports in production.)
|
|
255
|
+
for plug in plugins_touched:
|
|
256
|
+
cur = oldest_commit_ts.get(plug)
|
|
257
|
+
if cur is None or ts < cur:
|
|
258
|
+
oldest_commit_ts[plug] = ts
|
|
259
|
+
|
|
260
|
+
# In-window filter — applied in Python (ADR-058 line 89 contract for the
|
|
261
|
+
# git invocation; window cutoff in-process for portability).
|
|
262
|
+
if ts < cutoff:
|
|
263
|
+
del subject
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Commit-window tally.
|
|
267
|
+
for plug in plugins_touched:
|
|
268
|
+
commits_window[plug] += 1
|
|
269
|
+
|
|
270
|
+
# Breaking-marker test on subject (boolean test; subject discarded after).
|
|
271
|
+
if BREAKING_RE.search(subject):
|
|
272
|
+
age_days = int((now - ts) / 86400)
|
|
273
|
+
if age_days >= 0:
|
|
274
|
+
for plug in plugins_touched:
|
|
275
|
+
cur = breaking_age_days.get(plug)
|
|
276
|
+
# Want the YOUNGEST (smallest age_days) breaking commit.
|
|
277
|
+
if cur is None or age_days < cur:
|
|
278
|
+
breaking_age_days[plug] = age_days
|
|
279
|
+
# subject explicitly not retained beyond this point.
|
|
280
|
+
del subject
|
|
281
|
+
|
|
282
|
+
# ── Pass 3: closed/verifying ticket scan (citation match + 90-day mtime) ────
|
|
283
|
+
# Tolerate both layouts:
|
|
284
|
+
# (a) suffix-based: docs/problems/**/<NNN>-*.closed.md, *.verifying.md
|
|
285
|
+
# (b) directory-based: docs/problems/closed/<NNN>-*.md, verifying/<NNN>-*.md
|
|
286
|
+
|
|
287
|
+
problems_root = project_root / "docs" / "problems"
|
|
288
|
+
ticket_files = []
|
|
289
|
+
if problems_root.is_dir():
|
|
290
|
+
# Suffix-based (recursive).
|
|
291
|
+
ticket_files.extend(problems_root.rglob("*.closed.md"))
|
|
292
|
+
ticket_files.extend(problems_root.rglob("*.verifying.md"))
|
|
293
|
+
# Directory-based.
|
|
294
|
+
for subdir in ("closed", "verifying"):
|
|
295
|
+
d = problems_root / subdir
|
|
296
|
+
if d.is_dir():
|
|
297
|
+
for f in d.rglob("*.md"):
|
|
298
|
+
if not (f.name.endswith(".closed.md") or f.name.endswith(".verifying.md")):
|
|
299
|
+
ticket_files.append(f)
|
|
300
|
+
|
|
301
|
+
# Dedupe (a file may match both globs in pathological cases).
|
|
302
|
+
ticket_files = sorted(set(ticket_files))
|
|
303
|
+
|
|
304
|
+
for ticket in ticket_files:
|
|
305
|
+
try:
|
|
306
|
+
st = ticket.stat()
|
|
307
|
+
except OSError:
|
|
308
|
+
continue
|
|
309
|
+
if st.st_mtime < tickets_cutoff:
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
body = ticket.read_text(encoding="utf-8", errors="replace")
|
|
313
|
+
except OSError:
|
|
314
|
+
continue
|
|
315
|
+
for plug in plugins:
|
|
316
|
+
# Citation marker — any occurrence of `packages/<plugin>/` in body.
|
|
317
|
+
if f"packages/{plug}/" in body:
|
|
318
|
+
closed_tickets_window[plug] += 1
|
|
319
|
+
|
|
320
|
+
# ── Emit one NDJSON record per discovered plugin ────────────────────────────
|
|
321
|
+
|
|
322
|
+
for plug in plugins:
|
|
323
|
+
cw = commits_window.get(plug, 0)
|
|
324
|
+
ctw = closed_tickets_window.get(plug, 0)
|
|
325
|
+
if plug in oldest_commit_ts:
|
|
326
|
+
ds = int((now - oldest_commit_ts[plug]) / 86400)
|
|
327
|
+
else:
|
|
328
|
+
ds = 0
|
|
329
|
+
bca = breaking_age_days.get(plug)
|
|
330
|
+
bonus = 1.0 if ds >= 60 else 0.0
|
|
331
|
+
composite = round(math.log10(cw + 1) + math.log10(ctw + 1) + bonus, 2)
|
|
332
|
+
record = {
|
|
333
|
+
"schema_version": "1.0",
|
|
334
|
+
"axis": "plugin-exercise-index",
|
|
335
|
+
"plugin": plug,
|
|
336
|
+
"commits_window": cw,
|
|
337
|
+
"window_days": window_days,
|
|
338
|
+
"days_shipped": ds,
|
|
339
|
+
"closed_tickets_window": ctw,
|
|
340
|
+
"tickets_window_days": tickets_window_days,
|
|
341
|
+
"breaking_change_age_days": bca,
|
|
342
|
+
"composite_index": composite,
|
|
343
|
+
}
|
|
344
|
+
sys.stdout.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
345
|
+
PYEOF
|
|
346
|
+
|
|
347
|
+
exit 0
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# packages/itil/scripts/skill-invocations.sh
|
|
3
|
+
#
|
|
4
|
+
# Phase 2a (P087) transcript-axis maturity-measurement script.
|
|
5
|
+
#
|
|
6
|
+
# Reads `~/.claude/projects/**/*.jsonl` (recursive), filters to
|
|
7
|
+
# `type=assistant` messages whose `message.content` array carries a
|
|
8
|
+
# `tool_use` entry, tallies invocations by `Skill` / `Agent` / `Bash` per
|
|
9
|
+
# ADR-058 §Script contracts, and emits one NDJSON record per surface to
|
|
10
|
+
# stdout.
|
|
11
|
+
#
|
|
12
|
+
# Schema (per ADR-058 line 70-82):
|
|
13
|
+
# {"schema_version":"1.0","axis":"skill-invocations","surface":"<name>",
|
|
14
|
+
# "kind":"skill|agent|bash-attributed","plugin":"<name>",
|
|
15
|
+
# "window_days":<N>,"invocations":<N>,
|
|
16
|
+
# "first_invocation_iso":"<ISO>","last_invocation_iso":"<ISO>"}
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# wr-itil-skill-invocations [--window-days=N] [--root=PATH]
|
|
20
|
+
# [--project-root=PATH]
|
|
21
|
+
# [--category-overrides=FILE]
|
|
22
|
+
#
|
|
23
|
+
# Defaults:
|
|
24
|
+
# --window-days 30 (ADR-058 line 67)
|
|
25
|
+
# --root ~/.claude/projects
|
|
26
|
+
# --project-root $PWD (opt-out marker location: <project-root>/.claude/.skill-metrics-opt-out)
|
|
27
|
+
#
|
|
28
|
+
# Exit codes:
|
|
29
|
+
# 0 = always — ADR-013 Rule 6 fail-safe. Opt-out marker / inaccessible
|
|
30
|
+
# root / missing data all hit the zero-records path with
|
|
31
|
+
# stderr-comment.
|
|
32
|
+
#
|
|
33
|
+
# Privacy (ADR-035 clauses adopted verbatim):
|
|
34
|
+
# - Opt-out marker `.claude/.skill-metrics-opt-out` disables reads.
|
|
35
|
+
# - No network egress — the script body invokes no exfiltration
|
|
36
|
+
# primitives. ADR-058 §Confirmation 3 enforces via negative-grep
|
|
37
|
+
# on this file (banned-token list lives in the bats fixture, not
|
|
38
|
+
# here, to avoid self-matching).
|
|
39
|
+
# - Content sanitisation — only fixed-pattern surface names extracted
|
|
40
|
+
# via tight regex from tool inputs are emitted. Raw user content,
|
|
41
|
+
# paths, and secrets are never copied to stdout.
|
|
42
|
+
# - Path-hashing — if a path-bearing field is added in a future schema
|
|
43
|
+
# version, values MUST be sha256-prefix-hashed (first 12 hex chars).
|
|
44
|
+
# The v1.0 schema has no path-bearing field; the hashing function is
|
|
45
|
+
# internal defence-in-depth, exercised structurally by Confirmation #4.
|
|
46
|
+
#
|
|
47
|
+
# @problem P087 (Phase 2a — transcript axis)
|
|
48
|
+
# @adr ADR-058 (Plugin maturity measurement mechanism)
|
|
49
|
+
# @adr ADR-049 (Shim grammar — bin/wr-itil-skill-invocations on $PATH)
|
|
50
|
+
# @adr ADR-035 (Privacy posture adopted verbatim)
|
|
51
|
+
# @adr ADR-052 (Behavioural tests default; ADR-058 §Confirmation 1-5)
|
|
52
|
+
# @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
|
|
53
|
+
# @adr ADR-023 (Performance — < 1.5s warm-cache; 30-day window default)
|
|
54
|
+
# @jtbd JTBD-101 (Extend the Suite — hardening-prioritisation outcome,
|
|
55
|
+
# 2026-05-04 amendment serves Phase 2 NDJSON as data source)
|
|
56
|
+
# @jtbd JTBD-201 (Restore Service Fast — audit-trail composition)
|
|
57
|
+
|
|
58
|
+
set -uo pipefail
|
|
59
|
+
|
|
60
|
+
# ── CLI parse ───────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
WINDOW_DAYS=30
|
|
63
|
+
TRANSCRIPT_ROOT="${HOME}/.claude/projects"
|
|
64
|
+
PROJECT_ROOT="$PWD"
|
|
65
|
+
CATEGORY_OVERRIDES=""
|
|
66
|
+
|
|
67
|
+
for arg in "$@"; do
|
|
68
|
+
case "$arg" in
|
|
69
|
+
--window-days=*) WINDOW_DAYS="${arg#--window-days=}" ;;
|
|
70
|
+
--root=*) TRANSCRIPT_ROOT="${arg#--root=}" ;;
|
|
71
|
+
--project-root=*) PROJECT_ROOT="${arg#--project-root=}" ;;
|
|
72
|
+
--category-overrides=*) CATEGORY_OVERRIDES="${arg#--category-overrides=}" ;;
|
|
73
|
+
--help|-h)
|
|
74
|
+
sed -n '4,40p' "$0" | sed 's/^# \{0,1\}//'
|
|
75
|
+
exit 0
|
|
76
|
+
;;
|
|
77
|
+
*)
|
|
78
|
+
echo "# wr-itil-skill-invocations: ignoring unknown argument: $arg" >&2
|
|
79
|
+
;;
|
|
80
|
+
esac
|
|
81
|
+
done
|
|
82
|
+
|
|
83
|
+
# ── Opt-out marker check (ADR-035 / ADR-058 §Privacy posture) ───────────────
|
|
84
|
+
|
|
85
|
+
OPT_OUT_MARKER="${PROJECT_ROOT}/.claude/.skill-metrics-opt-out"
|
|
86
|
+
if [ -e "$OPT_OUT_MARKER" ]; then
|
|
87
|
+
echo "# wr-itil-skill-invocations: opt-out marker present at ${OPT_OUT_MARKER}" >&2
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# ── Transcript root accessibility (ADR-013 Rule 6 fail-safe) ────────────────
|
|
92
|
+
|
|
93
|
+
if [ ! -d "$TRANSCRIPT_ROOT" ]; then
|
|
94
|
+
echo "# wr-itil-skill-invocations: transcript root inaccessible at ${TRANSCRIPT_ROOT}" >&2
|
|
95
|
+
exit 0
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
# ── --category-overrides validation (ADR-058 §Per-category override hook) ───
|
|
99
|
+
# Forward-extension flag; ships unused in Phase 2. Validated for path
|
|
100
|
+
# existence; not yet consumed by the body.
|
|
101
|
+
|
|
102
|
+
if [ -n "$CATEGORY_OVERRIDES" ] && [ ! -f "$CATEGORY_OVERRIDES" ]; then
|
|
103
|
+
echo "# wr-itil-skill-invocations: category-overrides file not found: ${CATEGORY_OVERRIDES}" >&2
|
|
104
|
+
exit 0
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
# ── JSONL parse + NDJSON emit (Python 3 stdlib — ADR-058 line 127) ──────────
|
|
108
|
+
# Inputs pinned via environment to avoid argv leakage of secrets.
|
|
109
|
+
|
|
110
|
+
export SI_TRANSCRIPT_ROOT="$TRANSCRIPT_ROOT"
|
|
111
|
+
export SI_WINDOW_DAYS="$WINDOW_DAYS"
|
|
112
|
+
|
|
113
|
+
python3 - <<'PYEOF'
|
|
114
|
+
import json, os, sys, time, re, hashlib
|
|
115
|
+
from pathlib import Path
|
|
116
|
+
from datetime import datetime, timezone
|
|
117
|
+
from collections import defaultdict
|
|
118
|
+
|
|
119
|
+
root = Path(os.environ["SI_TRANSCRIPT_ROOT"])
|
|
120
|
+
window_days = int(os.environ["SI_WINDOW_DAYS"])
|
|
121
|
+
now = time.time()
|
|
122
|
+
cutoff = now - window_days * 86400
|
|
123
|
+
|
|
124
|
+
# Bin shim grammar from ADR-049: wr-<plugin>-<kebab-script-name>.
|
|
125
|
+
# Anchor on word boundary; allow alnum + hyphens; non-greedy plugin token
|
|
126
|
+
# stops at the first hyphen so `wr-itil-reconcile-readme` attributes to
|
|
127
|
+
# `itil`, not `itil-reconcile`.
|
|
128
|
+
BIN_RE = re.compile(r"\bwr-([a-z0-9]+)-[a-z0-9-]+")
|
|
129
|
+
|
|
130
|
+
def plugin_from_skill(name):
|
|
131
|
+
"""`wr-itil:manage-problem` -> `itil`. Non-wr-prefixed or short-form
|
|
132
|
+
names like `commit`, `loop` return None (excluded from per-plugin
|
|
133
|
+
attribution per ADR-058 line 64)."""
|
|
134
|
+
if not name or ":" not in name:
|
|
135
|
+
return None
|
|
136
|
+
prefix = name.split(":", 1)[0]
|
|
137
|
+
if prefix.startswith("wr-"):
|
|
138
|
+
return prefix[3:]
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def plugin_from_agent(name):
|
|
142
|
+
return plugin_from_skill(name)
|
|
143
|
+
|
|
144
|
+
def hash_path(p):
|
|
145
|
+
"""sha256-prefix-12hex per ADR-035 path-hashing convention. Reserved
|
|
146
|
+
for future schema bumps that emit path-bearing fields; not consumed
|
|
147
|
+
by the v1.0 schema."""
|
|
148
|
+
return hashlib.sha256(str(p).encode("utf-8")).hexdigest()[:12]
|
|
149
|
+
|
|
150
|
+
# Aggregate keyed by (kind, surface).
|
|
151
|
+
counts = defaultdict(lambda: {"invocations": 0, "first": None, "last": None, "plugin": None})
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
jsonl_iter = root.rglob("*.jsonl")
|
|
155
|
+
except OSError:
|
|
156
|
+
sys.exit(0)
|
|
157
|
+
|
|
158
|
+
for jsonl in jsonl_iter:
|
|
159
|
+
try:
|
|
160
|
+
st = jsonl.stat()
|
|
161
|
+
except OSError:
|
|
162
|
+
continue
|
|
163
|
+
if st.st_mtime < cutoff:
|
|
164
|
+
# File hasn't been touched in the window; skip without parsing.
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
fh = jsonl.open("r", encoding="utf-8", errors="replace")
|
|
168
|
+
except OSError:
|
|
169
|
+
continue
|
|
170
|
+
with fh:
|
|
171
|
+
for line in fh:
|
|
172
|
+
try:
|
|
173
|
+
rec = json.loads(line)
|
|
174
|
+
except Exception:
|
|
175
|
+
continue
|
|
176
|
+
if not isinstance(rec, dict) or rec.get("type") != "assistant":
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# Per-message timestamp filter (more accurate than file mtime).
|
|
180
|
+
ts = rec.get("timestamp")
|
|
181
|
+
ts_iso = None
|
|
182
|
+
if ts:
|
|
183
|
+
try:
|
|
184
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
185
|
+
if dt.timestamp() < cutoff:
|
|
186
|
+
continue
|
|
187
|
+
ts_iso = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
188
|
+
except Exception:
|
|
189
|
+
ts_iso = None
|
|
190
|
+
|
|
191
|
+
msg = rec.get("message") or {}
|
|
192
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
193
|
+
if not isinstance(content, list):
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
for c in content:
|
|
197
|
+
if not isinstance(c, dict) or c.get("type") != "tool_use":
|
|
198
|
+
continue
|
|
199
|
+
name = c.get("name")
|
|
200
|
+
inp = c.get("input") if isinstance(c.get("input"), dict) else {}
|
|
201
|
+
surface = kind = plugin = None
|
|
202
|
+
|
|
203
|
+
if name == "Skill":
|
|
204
|
+
sk = inp.get("skill")
|
|
205
|
+
p = plugin_from_skill(sk)
|
|
206
|
+
if p:
|
|
207
|
+
surface, kind, plugin = sk, "skill", p
|
|
208
|
+
elif name == "Agent":
|
|
209
|
+
sub = inp.get("subagent_type")
|
|
210
|
+
p = plugin_from_agent(sub)
|
|
211
|
+
if p:
|
|
212
|
+
surface, kind, plugin = sub, "agent", p
|
|
213
|
+
elif name == "Bash":
|
|
214
|
+
cmd = inp.get("command", "")
|
|
215
|
+
if not isinstance(cmd, str):
|
|
216
|
+
continue
|
|
217
|
+
m = BIN_RE.search(cmd)
|
|
218
|
+
if m:
|
|
219
|
+
# Surface is the matched bin shim token only. The
|
|
220
|
+
# surrounding command (paths, secrets, args) is
|
|
221
|
+
# discarded — content-sanitisation per ADR-035.
|
|
222
|
+
surface = m.group(0)
|
|
223
|
+
kind = "bash-attributed"
|
|
224
|
+
plugin = m.group(1)
|
|
225
|
+
|
|
226
|
+
if surface and kind and plugin:
|
|
227
|
+
key = (kind, surface)
|
|
228
|
+
info = counts[key]
|
|
229
|
+
info["invocations"] += 1
|
|
230
|
+
info["plugin"] = plugin
|
|
231
|
+
if ts_iso:
|
|
232
|
+
if info["first"] is None or ts_iso < info["first"]:
|
|
233
|
+
info["first"] = ts_iso
|
|
234
|
+
if info["last"] is None or ts_iso > info["last"]:
|
|
235
|
+
info["last"] = ts_iso
|
|
236
|
+
|
|
237
|
+
# Deterministic output order: sort by kind, then surface.
|
|
238
|
+
for key in sorted(counts.keys()):
|
|
239
|
+
kind, surface = key
|
|
240
|
+
info = counts[key]
|
|
241
|
+
record = {
|
|
242
|
+
"schema_version": "1.0",
|
|
243
|
+
"axis": "skill-invocations",
|
|
244
|
+
"surface": surface,
|
|
245
|
+
"kind": kind,
|
|
246
|
+
"plugin": info["plugin"],
|
|
247
|
+
"window_days": window_days,
|
|
248
|
+
"invocations": info["invocations"],
|
|
249
|
+
"first_invocation_iso": info["first"],
|
|
250
|
+
"last_invocation_iso": info["last"],
|
|
251
|
+
}
|
|
252
|
+
sys.stdout.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
253
|
+
PYEOF
|
|
254
|
+
|
|
255
|
+
exit 0
|