@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,320 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# @problem P087 — Phase 2a transcript-axis script behavioural confirmation.
|
|
4
|
+
#
|
|
5
|
+
# Contract under test: `packages/itil/scripts/skill-invocations.sh` reads
|
|
6
|
+
# `~/.claude/projects/**/*.jsonl` (recursive), tallies tool_use invocations
|
|
7
|
+
# by `Skill` / `Agent` / `Bash` (per ADR-058 §Script contracts), and emits
|
|
8
|
+
# one NDJSON record per surface to stdout. Exit code 0 always per ADR-013
|
|
9
|
+
# Rule 6 fail-safe (inaccessible root, opt-out marker, no data all hit the
|
|
10
|
+
# zero-records/stderr-comment path).
|
|
11
|
+
#
|
|
12
|
+
# Confirmation criteria 1-5 from ADR-058 §Confirmation are the load-bearing
|
|
13
|
+
# behavioural assertions in this file. Phase 2b's git-axis script ships
|
|
14
|
+
# criteria 6-8 in a sibling bats file.
|
|
15
|
+
#
|
|
16
|
+
# @adr ADR-058 (Plugin maturity measurement mechanism)
|
|
17
|
+
# @adr ADR-049 (Shim grammar — `wr-itil-skill-invocations` on $PATH)
|
|
18
|
+
# @adr ADR-035 (Privacy posture — opt-out marker, no network primitive,
|
|
19
|
+
# content sanitisation, path-hashing)
|
|
20
|
+
# @adr ADR-052 (Behavioural tests default — NDJSON-output-driven against
|
|
21
|
+
# fixture transcripts, not source-greps on script body; the no-network
|
|
22
|
+
# negative-grep at Confirmation #3 is the documented carve-out)
|
|
23
|
+
# @adr ADR-013 Rule 6 (non-interactive fail-safe — exit 0 always)
|
|
24
|
+
# @jtbd JTBD-101 (Extend the Suite — hardening-prioritisation outcome)
|
|
25
|
+
# @jtbd JTBD-201 (Restore Service Fast — audit-trail composition)
|
|
26
|
+
|
|
27
|
+
setup() {
|
|
28
|
+
SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
29
|
+
SCRIPT="$SCRIPTS_DIR/skill-invocations.sh"
|
|
30
|
+
FIXTURE_DIR="$(mktemp -d)"
|
|
31
|
+
TRANSCRIPT_ROOT="$FIXTURE_DIR/transcripts"
|
|
32
|
+
PROJECT_ROOT="$FIXTURE_DIR/project"
|
|
33
|
+
mkdir -p "$TRANSCRIPT_ROOT" "$PROJECT_ROOT"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
teardown() {
|
|
37
|
+
rm -rf "$FIXTURE_DIR"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Helper: write a synthetic JSONL transcript under the fixture root.
|
|
41
|
+
# Each call appends one assistant message carrying a single tool_use entry.
|
|
42
|
+
# Layout mirrors the live `~/.claude/projects/<encoded-path>/<UUID>.jsonl`
|
|
43
|
+
# shape (recursive rglob discovers it).
|
|
44
|
+
write_skill_invocation() {
|
|
45
|
+
local file="$1"; local skill_name="$2"; local ts="$3"
|
|
46
|
+
mkdir -p "$(dirname "$file")"
|
|
47
|
+
python3 - "$file" "$skill_name" "$ts" <<'PYEOF'
|
|
48
|
+
import json, sys
|
|
49
|
+
file, skill, ts = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
50
|
+
rec = {
|
|
51
|
+
"type": "assistant",
|
|
52
|
+
"timestamp": ts,
|
|
53
|
+
"message": {
|
|
54
|
+
"role": "assistant",
|
|
55
|
+
"content": [
|
|
56
|
+
{"type": "tool_use", "name": "Skill", "input": {"skill": skill}}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
with open(file, "a") as fh:
|
|
61
|
+
fh.write(json.dumps(rec) + "\n")
|
|
62
|
+
PYEOF
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
write_agent_invocation() {
|
|
66
|
+
local file="$1"; local agent_kind="$2"; local ts="$3"
|
|
67
|
+
mkdir -p "$(dirname "$file")"
|
|
68
|
+
python3 - "$file" "$agent_kind" "$ts" <<'PYEOF'
|
|
69
|
+
import json, sys
|
|
70
|
+
file, kind, ts = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
71
|
+
rec = {
|
|
72
|
+
"type": "assistant",
|
|
73
|
+
"timestamp": ts,
|
|
74
|
+
"message": {
|
|
75
|
+
"role": "assistant",
|
|
76
|
+
"content": [
|
|
77
|
+
{"type": "tool_use", "name": "Agent", "input": {"subagent_type": kind}}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
with open(file, "a") as fh:
|
|
82
|
+
fh.write(json.dumps(rec) + "\n")
|
|
83
|
+
PYEOF
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
write_bash_invocation() {
|
|
87
|
+
local file="$1"; local cmd="$2"; local ts="$3"
|
|
88
|
+
mkdir -p "$(dirname "$file")"
|
|
89
|
+
python3 - "$file" "$cmd" "$ts" <<'PYEOF'
|
|
90
|
+
import json, sys
|
|
91
|
+
file, cmd, ts = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
92
|
+
rec = {
|
|
93
|
+
"type": "assistant",
|
|
94
|
+
"timestamp": ts,
|
|
95
|
+
"message": {
|
|
96
|
+
"role": "assistant",
|
|
97
|
+
"content": [
|
|
98
|
+
{"type": "tool_use", "name": "Bash", "input": {"command": cmd}}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
with open(file, "a") as fh:
|
|
103
|
+
fh.write(json.dumps(rec) + "\n")
|
|
104
|
+
PYEOF
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Helper: produce an ISO 8601 timestamp N hours before "now" (UTC).
|
|
108
|
+
recent_iso() {
|
|
109
|
+
python3 -c "import sys, datetime; print((datetime.datetime.utcnow() - datetime.timedelta(hours=int(sys.argv[1]))).strftime('%Y-%m-%dT%H:%M:%SZ'))" "$1"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# ── Existence / executable ──────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
@test "skill-invocations: canonical script exists" {
|
|
115
|
+
[ -f "$SCRIPT" ]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@test "skill-invocations: canonical script is executable" {
|
|
119
|
+
[ -x "$SCRIPT" ]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@test "skill-invocations: shim file exists with ADR-049 grammar" {
|
|
123
|
+
local shim="$SCRIPTS_DIR/../bin/wr-itil-skill-invocations"
|
|
124
|
+
[ -f "$shim" ]
|
|
125
|
+
[ -x "$shim" ]
|
|
126
|
+
grep -q 'exec.*scripts/skill-invocations.sh' "$shim"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# ── Confirmation #1: NDJSON-shape fixture ───────────────────────────────────
|
|
130
|
+
# Seed three Skill invocations of `wr-itil:manage-problem` within the
|
|
131
|
+
# default 30-day window. Assert one NDJSON record on stdout with
|
|
132
|
+
# axis="skill-invocations", surface="wr-itil:manage-problem", kind="skill",
|
|
133
|
+
# plugin="itil", invocations=3. Each line is valid JSON with the expected
|
|
134
|
+
# schema keys.
|
|
135
|
+
|
|
136
|
+
@test "Confirmation #1: NDJSON record for 3 wr-itil:manage-problem invocations" {
|
|
137
|
+
local sess="$TRANSCRIPT_ROOT/proj/aaa.jsonl"
|
|
138
|
+
local ts=$(recent_iso 1)
|
|
139
|
+
write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
|
|
140
|
+
write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
|
|
141
|
+
write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
|
|
142
|
+
|
|
143
|
+
run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
|
|
144
|
+
[ "$status" -eq 0 ]
|
|
145
|
+
# Exactly one record line, no extras, no leading/trailing junk on stdout
|
|
146
|
+
local line_count
|
|
147
|
+
line_count="$(printf '%s' "$output" | grep -c .)"
|
|
148
|
+
[ "$line_count" -eq 1 ]
|
|
149
|
+
# Validate schema fields
|
|
150
|
+
echo "$output" | python3 -c "
|
|
151
|
+
import json, sys
|
|
152
|
+
rec = json.loads(sys.stdin.read().strip())
|
|
153
|
+
assert rec['schema_version'] == '1.0', rec
|
|
154
|
+
assert rec['axis'] == 'skill-invocations', rec
|
|
155
|
+
assert rec['surface'] == 'wr-itil:manage-problem', rec
|
|
156
|
+
assert rec['kind'] == 'skill', rec
|
|
157
|
+
assert rec['plugin'] == 'itil', rec
|
|
158
|
+
assert rec['window_days'] == 30, rec
|
|
159
|
+
assert rec['invocations'] == 3, rec
|
|
160
|
+
assert rec.get('first_invocation_iso') is not None, rec
|
|
161
|
+
assert rec.get('last_invocation_iso') is not None, rec
|
|
162
|
+
"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@test "Confirmation #1: aggregates Skill + Agent + Bash kinds into distinct records" {
|
|
166
|
+
local sess="$TRANSCRIPT_ROOT/proj/mix.jsonl"
|
|
167
|
+
local ts=$(recent_iso 1)
|
|
168
|
+
write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
|
|
169
|
+
write_agent_invocation "$sess" "wr-architect:agent" "$ts"
|
|
170
|
+
write_bash_invocation "$sess" "wr-itil-reconcile-readme docs/problems" "$ts"
|
|
171
|
+
|
|
172
|
+
run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
|
|
173
|
+
[ "$status" -eq 0 ]
|
|
174
|
+
# Three distinct NDJSON lines, one per kind
|
|
175
|
+
local line_count
|
|
176
|
+
line_count="$(printf '%s' "$output" | grep -c .)"
|
|
177
|
+
[ "$line_count" -eq 3 ]
|
|
178
|
+
echo "$output" | grep -q '"kind":"skill"'
|
|
179
|
+
echo "$output" | grep -q '"kind":"agent"'
|
|
180
|
+
echo "$output" | grep -q '"kind":"bash-attributed"'
|
|
181
|
+
echo "$output" | grep -q '"plugin":"itil"'
|
|
182
|
+
echo "$output" | grep -q '"plugin":"architect"'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@test "Confirmation #1: filters unknown short-form skill names from plugin attribution" {
|
|
186
|
+
local sess="$TRANSCRIPT_ROOT/proj/short.jsonl"
|
|
187
|
+
local ts=$(recent_iso 1)
|
|
188
|
+
# Bare names without `wr-<plugin>:` prefix should be excluded from
|
|
189
|
+
# per-plugin attribution per ADR-058 §Script contracts.
|
|
190
|
+
write_skill_invocation "$sess" "commit" "$ts"
|
|
191
|
+
write_skill_invocation "$sess" "loop" "$ts"
|
|
192
|
+
|
|
193
|
+
run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
|
|
194
|
+
[ "$status" -eq 0 ]
|
|
195
|
+
# No records emitted — both invocations are short-form and unattributable
|
|
196
|
+
[ -z "$output" ]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# ── Confirmation #2: opt-out marker fixture ─────────────────────────────────
|
|
200
|
+
|
|
201
|
+
@test "Confirmation #2: opt-out marker disables reads, stderr comment, exit 0" {
|
|
202
|
+
# Seed a transcript that WOULD produce records if read.
|
|
203
|
+
local sess="$TRANSCRIPT_ROOT/proj/optout.jsonl"
|
|
204
|
+
local ts=$(recent_iso 1)
|
|
205
|
+
write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
|
|
206
|
+
# Plant the opt-out marker in the project root.
|
|
207
|
+
mkdir -p "$PROJECT_ROOT/.claude"
|
|
208
|
+
touch "$PROJECT_ROOT/.claude/.skill-metrics-opt-out"
|
|
209
|
+
|
|
210
|
+
# Capture stdout and stderr separately so we can assert: zero records
|
|
211
|
+
# on stdout, opt-out comment on stderr, exit 0.
|
|
212
|
+
local stdout_file="$FIXTURE_DIR/optout.out"
|
|
213
|
+
local stderr_file="$FIXTURE_DIR/optout.err"
|
|
214
|
+
"$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT" >"$stdout_file" 2>"$stderr_file"
|
|
215
|
+
local rc=$?
|
|
216
|
+
[ "$rc" -eq 0 ]
|
|
217
|
+
[ ! -s "$stdout_file" ]
|
|
218
|
+
grep -q "opt-out marker present at" "$stderr_file"
|
|
219
|
+
grep -q "$PROJECT_ROOT/.claude/.skill-metrics-opt-out" "$stderr_file"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# ── Confirmation #3: no-network-primitive (negative grep carve-out) ─────────
|
|
223
|
+
# ADR-052 carves out this case from "behavioural unless declarative-only".
|
|
224
|
+
# Asserts the canonical script body invokes none of the standard exfiltration
|
|
225
|
+
# primitives. Negative-grep is the closest behavioural approximation of
|
|
226
|
+
# "this script is incapable of network egress".
|
|
227
|
+
|
|
228
|
+
@test "Confirmation #3: canonical body contains no network primitives" {
|
|
229
|
+
# Grep for curl, wget, raw `nc ` (space-bounded to avoid the `since`,
|
|
230
|
+
# `synchronise`, etc. false-positives), fetch, http.client, urllib.
|
|
231
|
+
run grep -nE '\bcurl\b|\bwget\b|\bnc[[:space:]]|\bfetch\b|http\.client|\burllib\b' "$SCRIPT"
|
|
232
|
+
# `run` captures exit; grep exits 1 on no matches — that is the pass.
|
|
233
|
+
[ "$status" -eq 1 ]
|
|
234
|
+
[ -z "$output" ]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# ── Confirmation #4: path-hashing / no-content-leak fixture ─────────────────
|
|
238
|
+
|
|
239
|
+
@test "Confirmation #4: literal secret-shaped token never appears in NDJSON" {
|
|
240
|
+
local sess="$TRANSCRIPT_ROOT/proj/secret.jsonl"
|
|
241
|
+
local ts=$(recent_iso 1)
|
|
242
|
+
# Bash command carries an absolute-path-shaped string containing a
|
|
243
|
+
# synthetic secret token. The plugin attribution extracts the wr-itil-*
|
|
244
|
+
# shim name only; the surrounding path and secret are NOT in the
|
|
245
|
+
# emitted NDJSON.
|
|
246
|
+
write_bash_invocation "$sess" "/private/users/foo/password-XXXX-secret/bin/wr-itil-reconcile-readme docs/problems" "$ts"
|
|
247
|
+
|
|
248
|
+
run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
|
|
249
|
+
[ "$status" -eq 0 ]
|
|
250
|
+
# NDJSON contains the surface attribution
|
|
251
|
+
echo "$output" | grep -q '"plugin":"itil"'
|
|
252
|
+
echo "$output" | grep -q '"kind":"bash-attributed"'
|
|
253
|
+
# NDJSON does NOT contain the secret token or the raw absolute path
|
|
254
|
+
! echo "$output" | grep -q "password-XXXX-secret"
|
|
255
|
+
! echo "$output" | grep -q "/private/users/foo"
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@test "Confirmation #4: any surface containing a project path is sha256-12hex hashed" {
|
|
259
|
+
# Structural assertion on the schema: when path-bearing fields are added
|
|
260
|
+
# in future schema versions, they MUST be 12-hex-char sha256 prefixes.
|
|
261
|
+
# The v1.0 schema does not include path-bearing fields; this fixture
|
|
262
|
+
# asserts the structural invariant by verifying no unhashed path shape
|
|
263
|
+
# surfaces. If a future schema bump introduces a `project_path_hash`
|
|
264
|
+
# field, the assertion regex below catches non-hex shapes.
|
|
265
|
+
local sess="$TRANSCRIPT_ROOT/proj/hash.jsonl"
|
|
266
|
+
local ts=$(recent_iso 1)
|
|
267
|
+
write_bash_invocation "$sess" "wr-itil-reconcile-readme docs/problems" "$ts"
|
|
268
|
+
|
|
269
|
+
run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
|
|
270
|
+
[ "$status" -eq 0 ]
|
|
271
|
+
# Any value matching a `path_hash` field (current or future schema)
|
|
272
|
+
# MUST be exactly 12 lowercase hex chars. Negative regex: no field with
|
|
273
|
+
# value matching a raw path shape.
|
|
274
|
+
! echo "$output" | grep -qE '"[a-z_]+":"/[^"]*"'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# ── Confirmation #5: inaccessible-directory fixture ─────────────────────────
|
|
278
|
+
|
|
279
|
+
@test "Confirmation #5: inaccessible --root emits zero records, stderr comment, exit 0" {
|
|
280
|
+
local stdout_file="$FIXTURE_DIR/inacc.out"
|
|
281
|
+
local stderr_file="$FIXTURE_DIR/inacc.err"
|
|
282
|
+
"$SCRIPT" --window-days=30 --root="/nonexistent/path/that/cannot/exist" --project-root="$PROJECT_ROOT" >"$stdout_file" 2>"$stderr_file"
|
|
283
|
+
local rc=$?
|
|
284
|
+
[ "$rc" -eq 0 ]
|
|
285
|
+
[ ! -s "$stdout_file" ]
|
|
286
|
+
grep -q "transcript root inaccessible" "$stderr_file"
|
|
287
|
+
grep -q "/nonexistent/path/that/cannot/exist" "$stderr_file"
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
# ── Forward-extension flag: --category-overrides ────────────────────────────
|
|
291
|
+
# ADR-058 §"Per-category override hook" — ships unused in Phase 2; the flag
|
|
292
|
+
# is accepted-and-validated, no functional effect yet.
|
|
293
|
+
|
|
294
|
+
@test "category-overrides flag is accepted without functional effect" {
|
|
295
|
+
local sess="$TRANSCRIPT_ROOT/proj/cat.jsonl"
|
|
296
|
+
local ts=$(recent_iso 1)
|
|
297
|
+
write_skill_invocation "$sess" "wr-itil:manage-problem" "$ts"
|
|
298
|
+
# Empty JSON file is a valid (no-op) overrides file.
|
|
299
|
+
echo '{}' > "$FIXTURE_DIR/overrides.json"
|
|
300
|
+
|
|
301
|
+
run "$SCRIPT" --window-days=30 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT" --category-overrides="$FIXTURE_DIR/overrides.json"
|
|
302
|
+
[ "$status" -eq 0 ]
|
|
303
|
+
echo "$output" | grep -q '"invocations":1'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# ── Window-days filtering ───────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
@test "window-days filter excludes invocations older than the window" {
|
|
309
|
+
local sess="$TRANSCRIPT_ROOT/proj/old.jsonl"
|
|
310
|
+
# Hours-back exceeds the 1-day window (24h).
|
|
311
|
+
local old_ts=$(recent_iso 48)
|
|
312
|
+
local recent_ts=$(recent_iso 1)
|
|
313
|
+
write_skill_invocation "$sess" "wr-itil:manage-problem" "$old_ts"
|
|
314
|
+
write_skill_invocation "$sess" "wr-itil:manage-problem" "$recent_ts"
|
|
315
|
+
|
|
316
|
+
run "$SCRIPT" --window-days=1 --root="$TRANSCRIPT_ROOT" --project-root="$PROJECT_ROOT"
|
|
317
|
+
[ "$status" -eq 0 ]
|
|
318
|
+
# Only the in-window invocation counts; old one drops.
|
|
319
|
+
echo "$output" | grep -q '"invocations":1'
|
|
320
|
+
}
|