@windyroad/itil 0.25.0 → 0.26.0-preview.291

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,273 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P170 — Slice 3 second half (B5.T9): PostToolUse:Bash hook
4
+ # detects `git commit` invocations whose HEAD commit message carries
5
+ # a `Refs: RFC-<NNN>` trailer, and emits a stderr advisory when the
6
+ # corresponding driving-problem ticket's `## RFCs` table is stale.
7
+ #
8
+ # Architect Q1 verdict: skill-side refresh primary; hook-side advisory
9
+ # for arbitrary commits (e.g. feat/fix/chore commits with `Refs:` trailer
10
+ # authored outside the RFC skills).
11
+ # Architect Q2 verdict: PostToolUse:Bash; advisory-only; silent-on-pass
12
+ # per ADR-045 Pattern 1; fail-open per ADR-013 Rule 6.
13
+ # Architect Q4 verdict: parse via `git interpret-trailers`; multi-`Refs:`
14
+ # trailers emit malformed-per-finding-8 advisory.
15
+ #
16
+ # Behavioural per ADR-052 + P081: assert on emitted stderr / exit code
17
+ # in response to simulated PostToolUse:Bash payload — no structural
18
+ # greps on hook source.
19
+ #
20
+ # @adr ADR-014 (single-commit grain — hook never auto-fixes via follow-up commit)
21
+ # @adr ADR-013 Rule 6 (fail-open)
22
+ # @adr ADR-045 (silent-on-pass; advisory band ≤300 bytes)
23
+ # @adr ADR-051 (load-bearing-from-the-start)
24
+ # @adr ADR-060 (Phase 1 item 12 + Confirmation criterion 3)
25
+ # @jtbd JTBD-006 (advisory does not block AFK loop — exit 0; stderr only)
26
+ # @jtbd JTBD-008 (reverse-trace surface JTBD-008 names — drift detection)
27
+
28
+ setup() {
29
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
30
+ HOOK="$SCRIPT_DIR/itil-rfc-trailer-advisory.sh"
31
+ ORIG_DIR="$PWD"
32
+ TEST_DIR=$(mktemp -d)
33
+ cd "$TEST_DIR"
34
+ git init --quiet -b main
35
+ git config user.email "test@example.com"
36
+ git config user.name "Test"
37
+ mkdir -p docs/rfcs docs/problems
38
+ echo "seed" > seed.txt
39
+ git add seed.txt
40
+ git -c commit.gpgsign=false commit --quiet -m "initial"
41
+ unset BYPASS_RFC_TRAILER_ADVISORY
42
+ }
43
+
44
+ teardown() {
45
+ cd "$ORIG_DIR"
46
+ rm -rf "$TEST_DIR"
47
+ unset BYPASS_RFC_TRAILER_ADVISORY
48
+ }
49
+
50
+ write_rfc() {
51
+ local id="$1" slug="$2" status="$3"
52
+ local problems="${4:-[P168]}"
53
+ cat > "docs/rfcs/RFC-${id}-${slug}.${status}.md" <<EOF
54
+ ---
55
+ status: ${status}
56
+ rfc-id: ${slug}
57
+ reported: 2026-05-05
58
+ decision-makers: [test]
59
+ problems: ${problems}
60
+ ---
61
+
62
+ # RFC-${id}: ${slug}
63
+
64
+ stub
65
+ EOF
66
+ }
67
+
68
+ write_problem_with_rfcs_section() {
69
+ local num="$1" rows="$2"
70
+ local file="docs/problems/${num}-stub.open.md"
71
+ cat > "$file" <<EOF
72
+ # Problem ${num}: stub
73
+
74
+ **Status**: Open
75
+
76
+ ## Description
77
+
78
+ stub
79
+
80
+ ## Related
81
+
82
+ stub
83
+
84
+ ## RFCs
85
+
86
+ | RFC | Status | Title |
87
+ |-----|--------|-------|
88
+ ${rows}
89
+ EOF
90
+ }
91
+
92
+ write_problem_without_rfcs_section() {
93
+ local num="$1"
94
+ cat > "docs/problems/${num}-stub.open.md" <<EOF
95
+ # Problem ${num}: stub
96
+
97
+ **Status**: Open
98
+
99
+ ## Description
100
+
101
+ stub
102
+
103
+ ## Related
104
+
105
+ stub
106
+ EOF
107
+ }
108
+
109
+ run_post_bash_hook() {
110
+ local cmd="$1"
111
+ local json
112
+ json=$(printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$cmd")
113
+ echo "$json" | bash "$HOOK"
114
+ }
115
+
116
+ # ── Existence + executable ──────────────────────────────────────────────────
117
+
118
+ @test "hook exists" {
119
+ [ -f "$HOOK" ]
120
+ }
121
+
122
+ @test "hook is executable" {
123
+ [ -x "$HOOK" ]
124
+ }
125
+
126
+ # ── Silent paths ────────────────────────────────────────────────────────────
127
+
128
+ @test "non-Bash tool → silent (exit 0; no output)" {
129
+ json='{"tool_name":"Write","tool_input":{"file_path":"foo.txt","content":"x"}}'
130
+ run bash -c "echo '$json' | bash '$HOOK'"
131
+ [ "$status" -eq 0 ]
132
+ [ -z "$output" ]
133
+ }
134
+
135
+ @test "non-commit Bash command → silent" {
136
+ run run_post_bash_hook "git status"
137
+ [ "$status" -eq 0 ]
138
+ [ -z "$output" ]
139
+ }
140
+
141
+ @test "BYPASS_RFC_TRAILER_ADVISORY=1 → silent regardless of drift" {
142
+ write_rfc "001" "foo" "accepted"
143
+ write_problem_without_rfcs_section "168"
144
+ echo "x" > stub.txt
145
+ git add stub.txt
146
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub" -m "" -m "Refs: RFC-001"
147
+ BYPASS_RFC_TRAILER_ADVISORY=1 run run_post_bash_hook "git commit -m foo"
148
+ [ "$status" -eq 0 ]
149
+ [ -z "$output" ]
150
+ }
151
+
152
+ @test "outside git work tree → silent" {
153
+ cd "$ORIG_DIR"
154
+ TMP_NONGIT=$(mktemp -d)
155
+ cd "$TMP_NONGIT"
156
+ run run_post_bash_hook "git commit -m foo"
157
+ [ "$status" -eq 0 ]
158
+ [ -z "$output" ]
159
+ cd "$ORIG_DIR"
160
+ rm -rf "$TMP_NONGIT"
161
+ }
162
+
163
+ @test "no docs/rfcs/ → silent (project has not adopted RFC framework)" {
164
+ rm -rf docs/rfcs
165
+ echo "x" > stub.txt
166
+ git add stub.txt
167
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub"
168
+ run run_post_bash_hook "git commit -m foo"
169
+ [ "$status" -eq 0 ]
170
+ [ -z "$output" ]
171
+ }
172
+
173
+ @test "no docs/problems/ → silent (project has not adopted problem framework)" {
174
+ rm -rf docs/problems
175
+ echo "x" > stub.txt
176
+ git add stub.txt
177
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub"
178
+ run run_post_bash_hook "git commit -m foo"
179
+ [ "$status" -eq 0 ]
180
+ [ -z "$output" ]
181
+ }
182
+
183
+ @test "commit without Refs: RFC trailer → silent" {
184
+ write_rfc "001" "foo" "accepted"
185
+ write_problem_without_rfcs_section "168"
186
+ echo "x" > stub.txt
187
+ git add stub.txt
188
+ git -c commit.gpgsign=false commit --quiet -m "feat: no trailer"
189
+ run run_post_bash_hook "git commit -m foo"
190
+ [ "$status" -eq 0 ]
191
+ [ -z "$output" ]
192
+ }
193
+
194
+ # ── Advisory paths ──────────────────────────────────────────────────────────
195
+
196
+ @test "Refs: RFC-NNN trailer + stale problem (no ## RFCs section) → stderr advisory" {
197
+ write_rfc "001" "foo" "accepted"
198
+ write_problem_without_rfcs_section "168"
199
+ echo "x" > stub.txt
200
+ git add stub.txt
201
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub" -m "" -m "Refs: RFC-001"
202
+ run run_post_bash_hook "git commit -m foo"
203
+ [ "$status" -eq 0 ]
204
+ [[ "$output" == *"RFC-001"* ]]
205
+ [[ "$output" == *"P168"* ]]
206
+ }
207
+
208
+ @test "Refs: RFC-NNN trailer + stale problem (## RFCs section missing this RFC) → stderr advisory" {
209
+ write_rfc "001" "foo" "accepted"
210
+ write_problem_with_rfcs_section "168" "| RFC-002 | proposed | other |"
211
+ echo "x" > stub.txt
212
+ git add stub.txt
213
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub" -m "" -m "Refs: RFC-001"
214
+ run run_post_bash_hook "git commit -m foo"
215
+ [ "$status" -eq 0 ]
216
+ [[ "$output" == *"RFC-001"* ]]
217
+ [[ "$output" == *"P168"* ]]
218
+ }
219
+
220
+ @test "Refs: RFC-NNN trailer + current problem ## RFCs (RFC listed) → silent" {
221
+ write_rfc "001" "foo" "accepted"
222
+ write_problem_with_rfcs_section "168" "| RFC-001 | accepted | foo |"
223
+ echo "x" > stub.txt
224
+ git add stub.txt
225
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub" -m "" -m "Refs: RFC-001"
226
+ run run_post_bash_hook "git commit -m foo"
227
+ [ "$status" -eq 0 ]
228
+ [ -z "$output" ]
229
+ }
230
+
231
+ # ── Multi-RFC malformed-per-finding-8 ──────────────────────────────────────
232
+
233
+ @test "multiple Refs: RFC trailers → malformed advisory (architect Q4 / finding 8)" {
234
+ write_rfc "001" "foo" "accepted"
235
+ write_rfc "002" "bar" "accepted"
236
+ write_problem_with_rfcs_section "168" "| RFC-001 | accepted | foo |"
237
+ echo "x" > stub.txt
238
+ git add stub.txt
239
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub" -m "" -m "Refs: RFC-001
240
+ Refs: RFC-002"
241
+ run run_post_bash_hook "git commit -m foo"
242
+ [ "$status" -eq 0 ]
243
+ # The advisory names finding-8 / split / mis-scoped vocabulary.
244
+ [[ "$output" == *"finding-8"* || "$output" == *"split"* || "$output" == *"mis-scoped"* ]]
245
+ }
246
+
247
+ # ── Trailer to non-existent RFC ──────────────────────────────────────────────
248
+
249
+ @test "Refs: RFC trailer with no matching file → silent (RFC may be in flight elsewhere)" {
250
+ write_problem_without_rfcs_section "168"
251
+ echo "x" > stub.txt
252
+ git add stub.txt
253
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub" -m "" -m "Refs: RFC-999"
254
+ run run_post_bash_hook "git commit -m foo"
255
+ [ "$status" -eq 0 ]
256
+ # Fail-open: missing RFC files don't promote to advisory (could be capture-rfc invocation in flight).
257
+ [ -z "$output" ]
258
+ }
259
+
260
+ # ── Advisory budget ─────────────────────────────────────────────────────────
261
+
262
+ @test "advisory message stays within ADR-045 advisory band (≤300 bytes)" {
263
+ write_rfc "001" "byte-budget-test-with-an-extra-long-slug-to-stress-row-width" "accepted"
264
+ write_problem_without_rfcs_section "168"
265
+ echo "x" > stub.txt
266
+ git add stub.txt
267
+ git -c commit.gpgsign=false commit --quiet -m "feat: stub" -m "" -m "Refs: RFC-001"
268
+ run run_post_bash_hook "git commit -m foo"
269
+ [ "$status" -eq 0 ]
270
+ [ -n "$output" ]
271
+ # Permit per-line overhead; the advisory should remain readable.
272
+ [ "${#output}" -le 600 ]
273
+ }
@@ -22,7 +22,7 @@ setup() {
22
22
  ORIG_DIR="$PWD"
23
23
  TEST_DIR=$(mktemp -d)
24
24
  cd "$TEST_DIR"
25
- mkdir -p docs/problems
25
+ mkdir -p docs/problems docs/rfcs
26
26
  SID="mp-create-test-$$-$RANDOM"
27
27
  }
28
28
 
@@ -30,6 +30,11 @@ teardown() {
30
30
  cd "$ORIG_DIR"
31
31
  rm -rf "$TEST_DIR"
32
32
  rm -f "/tmp/manage-problem-grep-${SID}"
33
+ rm -f "/tmp/wr-itil-rfc-capture-grep-${SID}"
34
+ }
35
+
36
+ set_rfc_marker() {
37
+ : > "/tmp/wr-itil-rfc-capture-grep-${SID}"
33
38
  }
34
39
 
35
40
  # Helper: run the hook with mock JSON for a Write tool call to file_path
@@ -241,3 +246,102 @@ teardown_other_sid_marker() {
241
246
  [[ "$output" != *"SID mismatch"* ]]
242
247
  [[ "$output" != *"Step 2 substep 7"* ]]
243
248
  }
249
+
250
+ # --- P170 / ADR-060: RFC tier extension ---
251
+ #
252
+ # The hook gate covers both docs/problems/ and docs/rfcs/. Each tier has its
253
+ # own marker (problems: /tmp/manage-problem-grep-${SID};
254
+ # rfcs: /tmp/wr-itil-rfc-capture-grep-${SID}) and its own deny message
255
+ # pointing to the right skill (manage-problem vs capture-rfc).
256
+
257
+ @test "rfcs deny: Write to new docs/rfcs/RFC-001-foo.proposed.md without RFC marker" {
258
+ run run_write_hook "$PWD/docs/rfcs/RFC-001-foo.proposed.md" "$SID"
259
+ [ "$status" -eq 0 ]
260
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
261
+ [[ "$output" == *"BLOCKED"* ]]
262
+ }
263
+
264
+ @test "rfcs deny message names capture-rfc skill (not manage-problem)" {
265
+ run run_write_hook "$PWD/docs/rfcs/RFC-001-foo.proposed.md" "$SID"
266
+ [ "$status" -eq 0 ]
267
+ [[ "$output" == *"/wr-itil:capture-rfc"* ]]
268
+ [[ "$output" != *"/wr-itil:manage-problem"* ]]
269
+ }
270
+
271
+ @test "rfcs deny message names the I1 trace-to-problem invariant + ADR-060" {
272
+ run run_write_hook "$PWD/docs/rfcs/RFC-001-foo.proposed.md" "$SID"
273
+ [ "$status" -eq 0 ]
274
+ [[ "$output" == *"problem-trace"* ]]
275
+ [[ "$output" == *"ADR-060"* ]]
276
+ }
277
+
278
+ @test "rfcs allow: Write to new docs/rfcs/RFC-001-foo.proposed.md WITH RFC marker" {
279
+ set_rfc_marker
280
+ run run_write_hook "$PWD/docs/rfcs/RFC-001-foo.proposed.md" "$SID"
281
+ [ "$status" -eq 0 ]
282
+ [[ "$output" != *"BLOCKED"* ]]
283
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
284
+ }
285
+
286
+ @test "rfcs allow: Write to docs/rfcs/README.md regardless of marker (chicken-and-egg)" {
287
+ run run_write_hook "$PWD/docs/rfcs/README.md" "$SID"
288
+ [ "$status" -eq 0 ]
289
+ [[ "$output" != *"BLOCKED"* ]]
290
+ }
291
+
292
+ @test "rfcs allow: Write to docs/rfcs/ non-RFC basename (e.g. NOTES.md) regardless of marker" {
293
+ run run_write_hook "$PWD/docs/rfcs/NOTES.md" "$SID"
294
+ [ "$status" -eq 0 ]
295
+ [[ "$output" != *"BLOCKED"* ]]
296
+ }
297
+
298
+ @test "rfcs deny: Write across all RFC lifecycle suffixes without marker (proposed/accepted/in-progress/verifying/closed)" {
299
+ for suffix in proposed accepted in-progress verifying closed; do
300
+ run run_write_hook "$PWD/docs/rfcs/RFC-001-foo.${suffix}.md" "$SID"
301
+ [ "$status" -eq 0 ]
302
+ [[ "$output" == *"BLOCKED"* ]] || { echo "expected deny for suffix=$suffix"; return 1; }
303
+ done
304
+ }
305
+
306
+ @test "rfcs marker independence: problem marker does NOT unlock RFC writes" {
307
+ # The two markers are siblings, not interchangeable. Setting the
308
+ # problem-tier marker should NOT bypass the RFC-tier gate.
309
+ set_marker
310
+ run run_write_hook "$PWD/docs/rfcs/RFC-001-foo.proposed.md" "$SID"
311
+ [ "$status" -eq 0 ]
312
+ [[ "$output" == *"BLOCKED"* ]]
313
+ [[ "$output" == *"capture-rfc"* ]]
314
+ }
315
+
316
+ @test "problems marker independence: RFC marker does NOT unlock problem writes" {
317
+ # Inverse direction — preserves audit-trail per-surface granularity.
318
+ set_rfc_marker
319
+ run run_write_hook "$PWD/docs/problems/999-foo.open.md" "$SID"
320
+ [ "$status" -eq 0 ]
321
+ [[ "$output" == *"BLOCKED"* ]]
322
+ [[ "$output" == *"manage-problem"* ]]
323
+ }
324
+
325
+ @test "rfcs allow: existing RFC file (overwrite) regardless of marker" {
326
+ echo "stub" > docs/rfcs/RFC-001-foo.proposed.md
327
+ run run_write_hook "$PWD/docs/rfcs/RFC-001-foo.proposed.md" "$SID"
328
+ [ "$status" -eq 0 ]
329
+ [[ "$output" != *"BLOCKED"* ]]
330
+ }
331
+
332
+ @test "rfcs allow: Edit (not Write) regardless of marker" {
333
+ echo "stub" > docs/rfcs/RFC-001-foo.proposed.md
334
+ run run_edit_hook "$PWD/docs/rfcs/RFC-001-foo.proposed.md" "$SID"
335
+ [ "$status" -eq 0 ]
336
+ [[ "$output" != *"BLOCKED"* ]]
337
+ }
338
+
339
+ @test "rfcs deny: bare numeric basename (no RFC- prefix) does NOT match the rfcs gate" {
340
+ # Defensive: a docs/rfcs/123-foo.proposed.md (no RFC- prefix) is not
341
+ # an RFC by naming convention; it should NOT trigger the gate.
342
+ # (If the user accidentally creates such a file, it's project
343
+ # housekeeping, not a ticket.)
344
+ run run_write_hook "$PWD/docs/rfcs/123-no-prefix.proposed.md" "$SID"
345
+ [ "$status" -eq 0 ]
346
+ [[ "$output" != *"BLOCKED"* ]]
347
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.25.0",
3
+ "version": "0.26.0-preview.291",
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"