@windyroad/itil 0.23.1 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-itil",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec "$(dirname "$0")/../scripts/check-problems-readme-budget.sh" "$@"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec "$(dirname "$0")/../scripts/reconcile-readme.sh" "$@"
package/hooks/hooks.json CHANGED
@@ -23,6 +23,10 @@
23
23
  {
24
24
  "matcher": "Bash",
25
25
  "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre-publish-intake-gate.sh" }]
26
+ },
27
+ {
28
+ "matcher": "Bash",
29
+ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-changeset-discipline.sh" }]
26
30
  }
27
31
  ],
28
32
  "Stop": [
@@ -0,0 +1,102 @@
1
+ #!/bin/bash
2
+ # P141: PreToolUse:Bash hook — denies `git commit` invocations whose
3
+ # staged set includes `packages/<plugin>/` source files but no
4
+ # `.changeset/*.md` is staged. Hook-level enforcement replaces the
5
+ # unreliable iter-prompt-time changeset reminder (40% miss rate
6
+ # observed in 2026-04-28 AFK loop session — see ticket).
7
+ #
8
+ # Detection delegates to `lib/changeset-detect.sh::detect_changeset_required`.
9
+ # When the helper returns 1, this hook emits PreToolUse deny JSON
10
+ # with the offending plugin slug inline and the literal `bun run
11
+ # changeset` recovery command, satisfying ADR-013 Rule 1's "deny
12
+ # redirects to a recovery path" contract via the mechanical-recovery
13
+ # shape (no skill wrapper required — authoring a changeset is a
14
+ # single command).
15
+ #
16
+ # Allow paths (exit 0 silently per ADR-045 Pattern 1):
17
+ # - tool_name != "Bash" (only Bash invocations are gated)
18
+ # - command does not contain `git commit` substring (non-commit
19
+ # Bash bypasses entirely)
20
+ # - staged set is changeset-clean (helper returns 0)
21
+ # - BYPASS_CHANGESET_GATE=1 env (helper returns 0 first)
22
+ # - outside a git work tree (helper fails-open)
23
+ # - parse failure on stdin (mirrors create-gate.sh fail-open)
24
+ #
25
+ # References:
26
+ # ADR-005 — plugin testing strategy (hook bats live under hooks/test/).
27
+ # ADR-009 — gate marker lifecycle (this hook deliberately does NOT
28
+ # use markers; detection is per-invocation deterministic
29
+ # — same precedent as P125 `p057-staging-trap-detect.sh`).
30
+ # ADR-013 Rule 1 — deny redirects with mechanical recovery.
31
+ # ADR-014 — governance skills commit their own work (the hook keeps
32
+ # iter commits self-contained — no orchestrator-main-turn
33
+ # back-fill needed).
34
+ # ADR-018 — inter-iteration release cadence (the hook strengthens
35
+ # release-cadence integrity by ensuring every publishable
36
+ # iter has a changeset to drain).
37
+ # ADR-038 — progressive disclosure / deny-message terseness budget.
38
+ # ADR-045 — hook injection budget (Pattern 1 silent-on-pass; deny
39
+ # band ≤300 bytes for this hook).
40
+ # P073 — sibling changeset author-time gate (Write/Edit on
41
+ # `.changeset/*.md`); composes-with as defence-in-depth.
42
+ # P125 — sibling staging-trap hook (same enforcement-layer shape).
43
+ # P141 — this hook.
44
+
45
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
46
+ # shellcheck source=lib/changeset-detect.sh
47
+ source "$SCRIPT_DIR/lib/changeset-detect.sh"
48
+
49
+ INPUT=$(cat)
50
+
51
+ TOOL_NAME=$(echo "$INPUT" | python3 -c "
52
+ import sys, json
53
+ try:
54
+ data = json.load(sys.stdin)
55
+ print(data.get('tool_name', ''))
56
+ except:
57
+ print('')
58
+ " 2>/dev/null || echo "")
59
+
60
+ # Only gate Bash. Non-Bash tools bypass entirely.
61
+ if [ "$TOOL_NAME" != "Bash" ]; then
62
+ exit 0
63
+ fi
64
+
65
+ COMMAND=$(echo "$INPUT" | python3 -c "
66
+ import sys, json
67
+ try:
68
+ data = json.load(sys.stdin)
69
+ print(data.get('tool_input', {}).get('command', ''))
70
+ except:
71
+ print('')
72
+ " 2>/dev/null || echo "")
73
+
74
+ # Only fire on `git commit` invocations. Substring match catches common
75
+ # shapes (`git commit -m`, `git commit --amend`, leading `cd && git
76
+ # commit`, etc.) without over-matching unrelated bash.
77
+ case "$COMMAND" in
78
+ *"git commit"*) ;;
79
+ *) exit 0 ;;
80
+ esac
81
+
82
+ # Run detection. Helper echoes offending plugin slug on stdout when
83
+ # detected; returns 1 in that case. Returns 0 (allow) on no-trap,
84
+ # bypass env, or fail-open (non-git tree, parse error).
85
+ TRAPPED_SLUG=$(detect_changeset_required 2>/dev/null) && exit 0
86
+
87
+ # Trap detected — emit deny with terse recovery.
88
+ # Voice-tone budget per ADR-045 deny-band ≤300 bytes total. Names the
89
+ # plugin slug, the literal recovery command (`bun run changeset`), the
90
+ # BYPASS env var escape, and the P141 cite.
91
+ REASON="BLOCKED: P141 changeset discipline. packages/${TRAPPED_SLUG}/ source needs .changeset/*.md. Recovery: bun run changeset. Bypass: BYPASS_CHANGESET_GATE=1."
92
+
93
+ cat <<EOF
94
+ {
95
+ "hookSpecificOutput": {
96
+ "hookEventName": "PreToolUse",
97
+ "permissionDecision": "deny",
98
+ "permissionDecisionReason": "${REASON}"
99
+ }
100
+ }
101
+ EOF
102
+ exit 0
@@ -0,0 +1,164 @@
1
+ #!/bin/bash
2
+ # P141: shared changeset-discipline detection helper.
3
+ #
4
+ # `detect_changeset_required` returns 0 (no change required — allow) /
5
+ # 1 (changeset required but not staged — caller should deny). On 1, the
6
+ # offending plugin slug is echoed on stdout so callers can name it in
7
+ # deny messages without re-parsing diff output.
8
+ #
9
+ # Trap shape (P141):
10
+ # `/wr-itil:work-problems` AFK iter subprocesses receive prompt-time
11
+ # guidance to author a `.changeset/*.md` whenever they ship a
12
+ # `packages/<plugin>/` change. Under context pressure (heavy SKILL.md
13
+ # + ticket body + architect/JTBD prompt content) the reminder is
14
+ # sometimes dropped — observed at 40% miss rate across 5 publishable
15
+ # iters in the 2026-04-28 evidence session. Hook-level detection at
16
+ # `git commit` time replaces the unreliable prompt-time signal.
17
+ #
18
+ # Detection logic:
19
+ # - `git diff --staged --name-only` enumerates staged paths.
20
+ # - Categorise each path:
21
+ # * `.changeset/<name>.md` (excluding `README.md`) — counts as
22
+ # a valid changeset.
23
+ # * `packages/<slug>/...` — examined further:
24
+ # - allow-list: `test/*`, `hooks/test/*`, `scripts/test/*`
25
+ # (test code; no publishable behaviour change).
26
+ # - allow-list: `README.md`.
27
+ # - allow-list: `docs/<anything>.md` (per architect verdict
28
+ # 2026-05-02 — `*.md` under `docs/` only; SKILL.md is the
29
+ # publishable contract per ADR-037 framing and is NOT in
30
+ # the allow-list).
31
+ # - otherwise: publishable source — record the slug.
32
+ # * any other path: ignored (non-publishable surface — `.github/`,
33
+ # root config, top-level `docs/`, etc.).
34
+ # - If any path is publishable source AND no valid changeset is
35
+ # staged, return 1 + echo the slug.
36
+ #
37
+ # Bypass:
38
+ # - `BYPASS_CHANGESET_GATE=1` env var → return 0 (allow). For
39
+ # legitimate non-publishable commits (e.g. CI-only changes
40
+ # bundled with a small source tweak the agent has decided not
41
+ # to release). Audit-traceable via shell history.
42
+ #
43
+ # Fail-open contract:
44
+ # - Outside a git working tree, or when `git diff` fails for any
45
+ # reason (parse error, broken index, permissions), return 0
46
+ # (allow). Mirrors `lib/staging-detect.sh`'s exit-0 fallback —
47
+ # a hook that fails-closed on hostile environments would block
48
+ # legitimate commits in non-git contexts (e.g. agent-driven
49
+ # scripts that happen to mention `git commit` in unrelated
50
+ # contexts).
51
+ #
52
+ # Cost: one `git diff` invocation per check (~10ms on this repo's
53
+ # working tree). Per-invocation deterministic — runs on every
54
+ # `git commit` invocation rather than relying on per-tool-call
55
+ # session state tracking. Mirrors the P125 `staging-detect.sh`
56
+ # precedent (architect-approved no-marker design).
57
+ #
58
+ # References:
59
+ # ADR-005 — plugin testing strategy (hook bats live under
60
+ # `hooks/test/` per P081 behavioural-test discipline).
61
+ # ADR-013 Rule 1 — deny redirects with mechanical recovery (the deny
62
+ # text names the plugin slug + the literal `bun run
63
+ # changeset` command + the BYPASS env var override).
64
+ # ADR-014 — governance skills commit their own work (this hook
65
+ # ensures iter commits stay self-contained per
66
+ # ADR-014 single-commit grain).
67
+ # ADR-018 — inter-iteration release cadence (this hook strengthens
68
+ # the cadence by ensuring every publishable iter has a
69
+ # changeset to drain at release time).
70
+ # ADR-038 — progressive disclosure / deny-message terseness.
71
+ # ADR-045 — hook injection budget (Pattern 1 silent-on-pass; deny
72
+ # band ≤300 bytes for this hook).
73
+ # P073 — sibling changeset author-time gate (different surface:
74
+ # Write/Edit on `.changeset/*.md`). Composes-with as
75
+ # defence-in-depth.
76
+ # P125 — sibling staging-trap helper (same enforcement-layer
77
+ # shape — per-invocation deterministic, no markers).
78
+ # P141 — this helper.
79
+
80
+ # Detect whether the current staged set requires a changeset that is
81
+ # not staged.
82
+ #
83
+ # Echoes the offending plugin slug on stdout when detected.
84
+ #
85
+ # Returns:
86
+ # 0 — no change required, or BYPASS env set, or fail-open (allow)
87
+ # 1 — change required + no changeset staged (caller should deny)
88
+ detect_changeset_required() {
89
+ # Bypass via env var — single most-common legitimate escape.
90
+ if [ "${BYPASS_CHANGESET_GATE:-}" = "1" ]; then
91
+ return 0
92
+ fi
93
+
94
+ # Fail-open if not inside a git working tree.
95
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
96
+
97
+ local staged
98
+ staged=$(git diff --staged --name-only 2>/dev/null) || return 0
99
+
100
+ # No staged paths — nothing to gate.
101
+ [ -n "$staged" ] || return 0
102
+
103
+ local has_changeset=0
104
+ local plugin_source_slug=""
105
+ local path rest slug subpath
106
+
107
+ while IFS= read -r path; do
108
+ [ -n "$path" ] || continue
109
+
110
+ case "$path" in
111
+ .changeset/README.md)
112
+ # README in changeset dir is meta-doc, not a real changeset.
113
+ ;;
114
+ .changeset/*.md)
115
+ has_changeset=1
116
+ ;;
117
+ packages/*)
118
+ rest="${path#packages/}"
119
+ slug="${rest%%/*}"
120
+ # When the path has no further segments (e.g. `packages/foo`),
121
+ # ${rest#*/} returns rest unchanged — defensive subpath fallback.
122
+ if [ "$rest" = "$slug" ]; then
123
+ subpath="$rest"
124
+ else
125
+ subpath="${rest#*/}"
126
+ fi
127
+
128
+ # Allow-list: test paths.
129
+ case "$subpath" in
130
+ test/*|hooks/test/*|scripts/test/*) continue ;;
131
+ esac
132
+
133
+ # Allow-list: package README.
134
+ case "$subpath" in
135
+ README.md) continue ;;
136
+ esac
137
+
138
+ # Allow-list: *.md under docs/ (any nesting depth).
139
+ case "$subpath" in
140
+ docs/*)
141
+ case "$subpath" in
142
+ *.md) continue ;;
143
+ esac
144
+ ;;
145
+ esac
146
+
147
+ # Anything else under packages/<slug>/ is publishable source.
148
+ plugin_source_slug="$slug"
149
+ ;;
150
+ *)
151
+ # Non-packages/ path: always allow.
152
+ ;;
153
+ esac
154
+ done <<EOF
155
+ $staged
156
+ EOF
157
+
158
+ if [ -n "$plugin_source_slug" ] && [ "$has_changeset" -eq 0 ]; then
159
+ printf '%s\n' "$plugin_source_slug"
160
+ return 1
161
+ fi
162
+
163
+ return 0
164
+ }
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P141: itil-changeset-discipline.sh PreToolUse:Bash hook must deny
4
+ # `git commit` invocations whose staged set includes packages/<plugin>/
5
+ # source files but no .changeset/*.md is staged. Hook-level enforcement
6
+ # replaces unreliable iter-prompt-time changeset reminders (40% miss
7
+ # rate observed in 2026-04-28 AFK loop session).
8
+ #
9
+ # Detection logic (per ticket Fix Strategy):
10
+ # On `git commit` invocations, run `git diff --staged --name-only`.
11
+ # If any path matches packages/<plugin>/<non-allow-listed> AND no
12
+ # .changeset/*.md (excluding README.md / config.json) is staged,
13
+ # emit a deny with recovery directive `bun run changeset` and the
14
+ # P141 cite. Allow when at least one valid changeset is staged, when
15
+ # staged packages/<plugin>/ files are entirely allow-listed (test
16
+ # paths or doc paths), or when BYPASS_CHANGESET_GATE=1 is set.
17
+ #
18
+ # Per ADR-005 (plugin testing strategy) — hook bats live under
19
+ # packages/<plugin>/hooks/test/ and assert behaviour on emitted JSON,
20
+ # not source-content. Per P081 — no source-grep on hook text. Simulate
21
+ # the PreToolUse:Bash payload on stdin and assert on the emitted
22
+ # permissionDecision.
23
+ #
24
+ # Per ADR-045 Pattern 1 (silent-on-pass) — allow paths emit 0 bytes.
25
+ # Per ADR-045 deny-band — deny messages target ~245 bytes; cap at 300.
26
+
27
+ setup() {
28
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
29
+ HOOK="$SCRIPT_DIR/itil-changeset-discipline.sh"
30
+ ORIG_DIR="$PWD"
31
+ TEST_DIR=$(mktemp -d)
32
+ cd "$TEST_DIR"
33
+ git init --quiet -b main
34
+ git config user.email "test@example.com"
35
+ git config user.name "Test"
36
+ mkdir -p packages/itil/skills/foo packages/itil/hooks packages/itil/hooks/test \
37
+ packages/itil/hooks/lib packages/itil/scripts/test packages/itil/docs \
38
+ packages/itil/.claude-plugin .changeset .github/workflows docs
39
+ echo "seed" > seed.txt
40
+ git add seed.txt
41
+ git -c commit.gpgsign=false commit --quiet -m "initial"
42
+ unset BYPASS_CHANGESET_GATE
43
+ }
44
+
45
+ teardown() {
46
+ cd "$ORIG_DIR"
47
+ rm -rf "$TEST_DIR"
48
+ unset BYPASS_CHANGESET_GATE
49
+ }
50
+
51
+ run_bash_hook() {
52
+ local cmd="$1"
53
+ local json
54
+ json=$(printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$cmd")
55
+ echo "$json" | bash "$HOOK"
56
+ }
57
+
58
+ # --- Trap detection: the canonical P141 shape ---
59
+
60
+ @test "deny: staged packages/<plugin>/ source without changeset triggers deny on git commit" {
61
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
62
+ git add packages/itil/skills/foo/SKILL.md
63
+ run run_bash_hook "git commit -m 'feat'"
64
+ [ "$status" -eq 0 ]
65
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
66
+ [[ "$output" == *"P141"* ]]
67
+ }
68
+
69
+ @test "deny: staged packages/<plugin>/hooks/ shell source without changeset triggers deny" {
70
+ echo "#!/bin/bash" > packages/itil/hooks/new-hook.sh
71
+ git add packages/itil/hooks/new-hook.sh
72
+ run run_bash_hook "git commit -m 'feat'"
73
+ [ "$status" -eq 0 ]
74
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
75
+ [[ "$output" == *"P141"* ]]
76
+ }
77
+
78
+ @test "deny: staged plugin.json without changeset triggers deny" {
79
+ echo "{}" > packages/itil/.claude-plugin/plugin.json
80
+ git add packages/itil/.claude-plugin/plugin.json
81
+ run run_bash_hook "git commit -m 'feat'"
82
+ [ "$status" -eq 0 ]
83
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
84
+ }
85
+
86
+ @test "deny message names plugin slug, recovery command, P141 cite" {
87
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
88
+ git add packages/itil/skills/foo/SKILL.md
89
+ run run_bash_hook "git commit -m 'feat'"
90
+ [ "$status" -eq 0 ]
91
+ [[ "$output" == *"itil"* ]]
92
+ [[ "$output" == *"changeset"* ]]
93
+ [[ "$output" == *"P141"* ]]
94
+ }
95
+
96
+ @test "deny message stays under ADR-045 deny-band (<300 bytes)" {
97
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
98
+ git add packages/itil/skills/foo/SKILL.md
99
+ run run_bash_hook "git commit -m 'feat'"
100
+ [ "$status" -eq 0 ]
101
+ [ "${#output}" -lt 300 ]
102
+ }
103
+
104
+ # --- Allow paths: each non-trap shape must NOT deny ---
105
+
106
+ @test "allow: staged packages/<plugin>/ source WITH a staged changeset allows the commit" {
107
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
108
+ echo "---" > .changeset/wr-itil-p141.md
109
+ echo '"@windyroad/itil": patch' >> .changeset/wr-itil-p141.md
110
+ echo "---" >> .changeset/wr-itil-p141.md
111
+ echo "fix the thing" >> .changeset/wr-itil-p141.md
112
+ git add packages/itil/skills/foo/SKILL.md .changeset/wr-itil-p141.md
113
+ run run_bash_hook "git commit -m 'feat'"
114
+ [ "$status" -eq 0 ]
115
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
116
+ }
117
+
118
+ @test "allow: staged test-only changes under packages/<plugin>/hooks/test/ allow without changeset" {
119
+ echo "#!/usr/bin/env bats" > packages/itil/hooks/test/new-test.bats
120
+ git add packages/itil/hooks/test/new-test.bats
121
+ run run_bash_hook "git commit -m 'test'"
122
+ [ "$status" -eq 0 ]
123
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
124
+ }
125
+
126
+ @test "allow: staged test-only changes under packages/<plugin>/scripts/test/ allow without changeset" {
127
+ echo "#!/usr/bin/env bats" > packages/itil/scripts/test/new-script-test.bats
128
+ git add packages/itil/scripts/test/new-script-test.bats
129
+ run run_bash_hook "git commit -m 'test'"
130
+ [ "$status" -eq 0 ]
131
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
132
+ }
133
+
134
+ @test "allow: staged packages/<plugin>/README.md alone allows without changeset (doc-only)" {
135
+ echo "# itil" > packages/itil/README.md
136
+ git add packages/itil/README.md
137
+ run run_bash_hook "git commit -m 'docs'"
138
+ [ "$status" -eq 0 ]
139
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
140
+ }
141
+
142
+ @test "allow: staged docs under packages/<plugin>/docs/ allow without changeset (doc-only)" {
143
+ echo "# guide" > packages/itil/docs/guide.md
144
+ git add packages/itil/docs/guide.md
145
+ run run_bash_hook "git commit -m 'docs'"
146
+ [ "$status" -eq 0 ]
147
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
148
+ }
149
+
150
+ @test "deny: staged SKILL.md (NOT in allow-list per architect amendment) triggers deny" {
151
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
152
+ git add packages/itil/skills/foo/SKILL.md
153
+ run run_bash_hook "git commit -m 'feat'"
154
+ [ "$status" -eq 0 ]
155
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
156
+ }
157
+
158
+ @test "allow: staged .github/ workflow change without changeset (non-publishable path)" {
159
+ echo "name: ci" > .github/workflows/ci.yml
160
+ git add .github/workflows/ci.yml
161
+ run run_bash_hook "git commit -m 'ci'"
162
+ [ "$status" -eq 0 ]
163
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
164
+ }
165
+
166
+ @test "allow: staged top-level docs/ change without changeset (non-publishable path)" {
167
+ echo "# briefing" > docs/BRIEFING.md
168
+ git add docs/BRIEFING.md
169
+ run run_bash_hook "git commit -m 'docs'"
170
+ [ "$status" -eq 0 ]
171
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
172
+ }
173
+
174
+ @test "allow: BYPASS_CHANGESET_GATE=1 env var allows packages/<plugin>/ commit without changeset" {
175
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
176
+ git add packages/itil/skills/foo/SKILL.md
177
+ BYPASS_CHANGESET_GATE=1 run run_bash_hook "git commit -m 'feat'"
178
+ [ "$status" -eq 0 ]
179
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
180
+ }
181
+
182
+ # --- Allow path silence (ADR-045 Pattern 1) ---
183
+
184
+ @test "allow path emits 0 bytes (ADR-045 Pattern 1 silent-on-pass)" {
185
+ echo "#!/usr/bin/env bats" > packages/itil/hooks/test/new-test.bats
186
+ git add packages/itil/hooks/test/new-test.bats
187
+ run run_bash_hook "git commit -m 'test'"
188
+ [ "$status" -eq 0 ]
189
+ [ "${#output}" -eq 0 ]
190
+ }
191
+
192
+ # --- Tool-name and command-shape filters ---
193
+
194
+ @test "allow: non-Bash tool exits 0 without deny" {
195
+ run bash -c "echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"foo.md\"}}' | bash $HOOK"
196
+ [ "$status" -eq 0 ]
197
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
198
+ }
199
+
200
+ @test "allow: Bash command that is NOT git commit (e.g., git status) bypasses detection" {
201
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
202
+ git add packages/itil/skills/foo/SKILL.md
203
+ run run_bash_hook "git status"
204
+ [ "$status" -eq 0 ]
205
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
206
+ }
207
+
208
+ # --- Changeset variants: README.md and config.json don't count as a real changeset ---
209
+
210
+ @test "deny: staged .changeset/README.md alone does NOT count as a valid changeset" {
211
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
212
+ echo "# changesets" > .changeset/README.md
213
+ git add packages/itil/skills/foo/SKILL.md .changeset/README.md
214
+ run run_bash_hook "git commit -m 'feat'"
215
+ [ "$status" -eq 0 ]
216
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
217
+ }
218
+
219
+ # --- Mixed staged sets ---
220
+
221
+ @test "deny: staged source + test in same commit still requires changeset (mixed set)" {
222
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
223
+ echo "#!/usr/bin/env bats" > packages/itil/hooks/test/new-test.bats
224
+ git add packages/itil/skills/foo/SKILL.md packages/itil/hooks/test/new-test.bats
225
+ run run_bash_hook "git commit -m 'feat'"
226
+ [ "$status" -eq 0 ]
227
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
228
+ }
229
+
230
+ # --- Parse / fail-open contracts ---
231
+
232
+ @test "allow: empty JSON exits 0 without deny (fail-open on parse-incomplete)" {
233
+ run bash -c "echo '{}' | bash $HOOK"
234
+ [ "$status" -eq 0 ]
235
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
236
+ }
237
+
238
+ @test "allow: outside a git work tree exits 0 without deny (fail-open)" {
239
+ cd "$ORIG_DIR"
240
+ TEMP_NONGIT=$(mktemp -d)
241
+ cd "$TEMP_NONGIT"
242
+ run run_bash_hook "git commit -m 'feat'"
243
+ cd "$TEST_DIR"
244
+ rm -rf "$TEMP_NONGIT"
245
+ [ "$status" -eq 0 ]
246
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
247
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -186,9 +186,11 @@ What "work" means depends on the problem's status:
186
186
  Before parsing the request, run the diagnose-only reconciliation check. The contract here catches **cross-session drift** that per-operation refresh paths (P094 refresh-on-create + P062 refresh-on-transition) cannot retroactively see — if any past session committed a ticket change without staging the README refresh, the next manage-problem invocation reads a stale README that lies about what is open / verifying / closed.
187
187
 
188
188
  ```bash
189
- bash packages/itil/scripts/reconcile-readme.sh docs/problems
189
+ wr-itil-reconcile-readme docs/problems
190
190
  ```
191
191
 
192
+ The `wr-itil-reconcile-readme` command is a `$PATH`-resolved shim shipped in `packages/itil/bin/` that dispatches the canonical `packages/itil/scripts/reconcile-readme.sh` body. ADR-049 — never invoke the canonical script via repo-relative path; the path does not resolve in adopter trees.
193
+
192
194
  Exit-code routing:
193
195
  - **Exit 0 (clean)**: continue to Step 1.
194
196
  - **Exit 1 (drift detected)**: structured diff lines printed to stdout, one per drift entry (≤150 bytes per ADR-038 progressive-disclosure budget). **Halt this invocation** with a directive to invoke `/wr-itil:reconcile-readme` (interactive mode) or auto-route through the same skill in non-interactive mode (per ADR-013 Rule 6, AFK orchestrator). The reconciliation must complete and commit before this manage-problem invocation proceeds — proceeding into ticket creation / update / transition with a stale README would re-encode the drift into the post-operation refresh and propagate the lie.
@@ -462,7 +464,7 @@ The "Last reviewed" line (line 3 of `docs/problems/README.md`) was designed as a
462
464
  1. **Single most-recent fragment only on line 3.** The "Last reviewed" parenthetical names ONE event — the operation this refresh covers. Do NOT prepend a `Prior:` segment, do NOT stack multi-paragraph rationale, do NOT carry history forward inline.
463
465
  2. **Soft cap: ≤ 1024 bytes per fragment.** Authoring guidance — keep the fragment dense and audit-meaningful (ticket ID + verb + one-line summary + ADR/JTBD anchors when load-bearing). Multi-paragraph rationale belongs in retros, ticket bodies, and ADR amendments — never on line 3.
464
466
  3. **Archive sibling: `docs/problems/README-history.md`.** When this refresh would displace prior line-3 content, append the displaced content to `README-history.md` BEFORE writing the new line 3. Forward-chronology — newest fragment goes at the bottom under a date heading (`## YYYY-MM-DD`). The archive is a log; it's grep-and-tail territory, not display-tier (which is why its chronology diverges from the README's reverse-chrono surface convention).
465
- 4. **Hard ceiling: 5120 bytes on line 3.** Matches ADR-040 Tier 3 envelope. Surfaced advisory-only by `packages/itil/scripts/check-problems-readme-budget.sh` — the script emits `OVER docs/problems/README.md line=3 bytes=<N> threshold=<N>` when the ceiling is breached. Always exits 0 (advisory; overflow is signal, not failure).
467
+ 4. **Hard ceiling: 5120 bytes on line 3.** Matches ADR-040 Tier 3 envelope. Surfaced advisory-only by `wr-itil-check-problems-readme-budget` (`$PATH`-resolved shim per ADR-049; canonical body at `packages/itil/scripts/check-problems-readme-budget.sh`) — the script emits `OVER docs/problems/README.md line=3 bytes=<N> threshold=<N>` when the ceiling is breached. Always exits 0 (advisory; overflow is signal, not failure).
466
468
 
467
469
  **Mechanism** (when authoring a refresh):
468
470
 
@@ -474,7 +476,7 @@ The "Last reviewed" line (line 3 of `docs/problems/README.md`) was designed as a
474
476
 
475
477
  **Fast-path interaction**: the Step 9 freshness check uses git-mtime on `docs/problems/README.md`, NOT the prose contents of line 3. Truncating line 3 does NOT degrade the fast-path contract.
476
478
 
477
- **Cross-references**: ADR-040 line 92 (reusable accumulator-doc pattern — explicitly names "problems index"), ADR-038 (progressive disclosure), ADR-014 (single-commit governance), `packages/itil/scripts/check-problems-readme-budget.sh`, `packages/itil/scripts/test/check-problems-readme-budget.bats`.
479
+ **Cross-references**: ADR-040 line 92 (reusable accumulator-doc pattern — explicitly names "problems index"), ADR-038 (progressive disclosure), ADR-014 (single-commit governance), ADR-049 (plugin-bundled scripts via `bin/` on `$PATH`), `wr-itil-check-problems-readme-budget` shim (canonical body at `packages/itil/scripts/check-problems-readme-budget.sh`), `packages/itil/scripts/test/check-problems-readme-budget.bats`.
478
480
 
479
481
  ### 6. For updates: Edit the existing file
480
482
 
@@ -41,9 +41,11 @@ Three invocation surfaces, all routed through this skill so the agent-applied-ed
41
41
  Invoke the mechanical drift detector:
42
42
 
43
43
  ```bash
44
- bash packages/itil/scripts/reconcile-readme.sh docs/problems
44
+ wr-itil-reconcile-readme docs/problems
45
45
  ```
46
46
 
47
+ The `wr-itil-reconcile-readme` command is a `$PATH`-resolved shim shipped in `packages/itil/bin/` that dispatches the canonical `packages/itil/scripts/reconcile-readme.sh` body. ADR-049 — never invoke the canonical script via repo-relative path; the path does not resolve in adopter trees.
48
+
47
49
  Exit codes:
48
50
  - `0` — clean. No drift; nothing to do. Report "Reconciliation: clean (0 drift entries)" and exit.
49
51
  - `1` — drift detected. The script prints one structured row per drift entry to stdout (each ≤ 150 bytes per ADR-038 progressive-disclosure budget). Continue to Step 2.
@@ -86,9 +86,11 @@ Step 6.75 treats a Step-0-resolved-with-user-confirmation state as `dirty-for-kn
86
86
  After the session-continuity detection pass, Step 0 MUST run the diagnose-only README reconciliation check. The orchestrator reads `docs/problems/README.md`'s WSJF Rankings table to pick the highest-WSJF actionable ticket (Step 3); if that table lies about which tickets are open vs verifying vs closed, the orchestrator burns iterations on no-op tickets — exactly the failure class P118 captures (a prior session committed a ticket transition without staging the README refresh, and no subsequent session systematically reconciled).
87
87
 
88
88
  ```bash
89
- bash packages/itil/scripts/reconcile-readme.sh docs/problems
89
+ wr-itil-reconcile-readme docs/problems
90
90
  ```
91
91
 
92
+ The `wr-itil-reconcile-readme` command is a `$PATH`-resolved shim shipped in `packages/itil/bin/` that dispatches the canonical `packages/itil/scripts/reconcile-readme.sh` body. ADR-049 — never invoke the canonical script via repo-relative path; the path does not resolve in adopter trees.
93
+
92
94
  Exit-code routing:
93
95
  - **Exit 0 (clean)**: continue to Step 1.
94
96
  - **Exit 1 (drift detected)**: structured diff lines printed to stdout, one per drift entry (≤150 bytes per ADR-038 progressive-disclosure budget). Per ADR-013 Rule 6 (non-interactive AFK fail-safe), invoke `/wr-itil:reconcile-readme` to apply the corrections + commit a `chore(problems): reconcile README ...` commit, then proceed to Step 1. The reconciled README is the orchestrator's source of truth for Step 3 ranking — a stale read at Step 1 would propagate the lie into the iteration's selection.