@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.
@@ -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