@windyroad/itil 0.27.1 → 0.28.0-preview.304

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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +9 -1
  3. package/bin/wr-itil-reconcile-stories +2 -0
  4. package/bin/wr-itil-reconcile-story-maps +2 -0
  5. package/hooks/hooks.json +4 -0
  6. package/hooks/itil-readme-refresh-discipline.sh +118 -0
  7. package/hooks/lib/readme-refresh-detect.sh +161 -0
  8. package/hooks/test/itil-readme-refresh-discipline.bats +261 -0
  9. package/lib/migrate-problems-layout.sh +128 -0
  10. package/package.json +1 -1
  11. package/scripts/reconcile-stories.sh +236 -0
  12. package/scripts/reconcile-story-maps.sh +98 -0
  13. package/scripts/test/reconcile-stories.bats +173 -0
  14. package/scripts/test/reconcile-story-maps.bats +74 -0
  15. package/scripts/test/rfc-stories-extension.bats +173 -0
  16. package/scripts/test/update-problem-references-section.bats +195 -0
  17. package/scripts/test/update-references-section-sibling-helpers.bats +80 -0
  18. package/scripts/test/working-the-problem-traversal.bats +109 -0
  19. package/scripts/update-jtbd-references-section.sh +131 -0
  20. package/scripts/update-problem-references-section.sh +284 -0
  21. package/scripts/update-rfc-references-section.sh +152 -0
  22. package/scripts/update-story-references-section.sh +128 -0
  23. package/skills/capture-rfc/SKILL.md +28 -3
  24. package/skills/capture-story/SKILL.md +373 -0
  25. package/skills/capture-story/test/capture-story-behavioural.bats +227 -0
  26. package/skills/capture-story-map/SKILL.md +229 -0
  27. package/skills/capture-story-map/test/capture-story-map-behavioural.bats +98 -0
  28. package/skills/list-stories/SKILL.md +151 -0
  29. package/skills/list-stories/test/list-stories-contract.bats +127 -0
  30. package/skills/list-story-maps/SKILL.md +93 -0
  31. package/skills/list-story-maps/test/list-story-maps-contract.bats +46 -0
  32. package/skills/manage-problem/SKILL.md +42 -4
  33. package/skills/manage-problem/test/manage-problem-auto-migrate-step.bats +53 -0
  34. package/skills/manage-rfc/SKILL.md +12 -0
  35. package/skills/manage-story/SKILL.md +242 -0
  36. package/skills/manage-story/test/manage-story-contract.bats +171 -0
  37. package/skills/manage-story-map/SKILL.md +158 -0
  38. package/skills/manage-story-map/test/manage-story-map-contract.bats +63 -0
  39. package/skills/reconcile-stories/SKILL.md +110 -0
  40. package/skills/reconcile-story-maps/SKILL.md +70 -0
  41. package/skills/work-problem/SKILL.md +1 -1
  42. package/skills/work-problems/SKILL.md +25 -0
  43. package/skills/work-problems/test/work-problems-auto-migrate-step.bats +57 -0
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-itil",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
package/README.md CHANGED
@@ -88,6 +88,14 @@ See [ADR-011](../../docs/decisions/011-manage-incident-skill.proposed.md) for th
88
88
  | `/wr-itil:report-upstream` | Report a local problem as a structured issue against an upstream repository (ADR-024) |
89
89
  | `/wr-itil:capture-rfc` | Lightweight RFC-capture skill — mandatory problem-trace per ADR-060 I1 invariant; opens a coordinated multi-commit change traceable to ≥ 1 driving problem (Phase 1 of the Problem-RFC-Story framework, P170 / ADR-060) |
90
90
  | `/wr-itil:manage-rfc` | Heavyweight RFC intake + lifecycle management — proposed → accepted → in-progress → verifying → closed; sibling to `manage-problem` at the RFC tier (ADR-060) |
91
+ | `/wr-itil:capture-story` | Lightweight story-capture skill — mandatory problem-trace AND JTBD-trace per ADR-060 I6 + I9 invariants; optional `--rfc` / `--story-map` flags (I7 + I8 enforce at `accepted` transition); drafts an INVEST-shaped sub-workstream entity under a parent RFC (Phase 2 of the Problem-RFC-Story framework, P170 / ADR-060) |
92
+ | `/wr-itil:list-stories` | Read-only display of stories grouped by lifecycle state, with optional `--rfc RFC-<NNN>` filter rendering the RFC's ordered story list per ADR-060 line 259 (Phase 2 / P170) |
93
+ | `/wr-itil:reconcile-stories` | Detect and correct drift between `docs/stories/README.md` and on-disk story inventory + reverse-trace `## Stories` sections on driving problems / RFCs / JTBDs (Phase 2 / P170) |
94
+ | `/wr-itil:manage-story` | Heavyweight story lifecycle management — draft → accepted → in-progress → done → archived; I7+I8+I10 hard-block at accepted transition; INVEST 4-axis check; auto-transitions on `Refs: STORY-NNN` commit trailer + linked RFC closure (Phase 2 / P170) |
95
+ | `/wr-itil:capture-story-map` | Lightweight story-map-capture skill — mandatory problem-trace AND JTBD-trace per ADR-060 I3 + I4 invariants; HTML skeleton at `docs/story-maps/draft/STORY-MAP-NNN-<slug>.html` per ADR-060 § Phase 2 encoding amendment 2026-05-12 (Phase 2 / P170) |
96
+ | `/wr-itil:manage-story-map` | Heavyweight story-map lifecycle management — draft → accepted → in-progress → completed → archived; backbone/ribs/slices authoring guidance; reverse-trace `## Story Maps` refresh on driving problems + JTBDs (Phase 2 / P170) |
97
+ | `/wr-itil:reconcile-story-maps` | Detect and correct drift between `docs/story-maps/README.md` and on-disk story-map HTML inventory (Phase 2 / P170) |
98
+ | `/wr-itil:list-story-maps` | Read-only display of story-maps grouped by lifecycle state; no WSJF (I5 invariant — maps are planning artefacts, not work items) (Phase 2 / P170) |
91
99
  | `/wr-itil:manage-incident` | Declare, triage, mitigate, and close an incident with evidence-first discipline |
92
100
  | `/wr-itil:list-incidents` | Read-only display of active incidents by severity |
93
101
  | `/wr-itil:mitigate-incident` / `/wr-itil:restore-incident` / `/wr-itil:close-incident` / `/wr-itil:link-incident` | Incident lifecycle transitions (ADR-011) |
@@ -108,7 +116,7 @@ This plugin serves the [Jobs to be Done](../../docs/jtbd/) below. Per [ADR-051](
108
116
  ### Solo developer
109
117
 
110
118
  - **[JTBD-006 Progress the Backlog While I'm Away](../../docs/jtbd/solo-developer/JTBD-006-work-backlog-afk.proposed.md)** — `/wr-itil:work-problems` is the AFK orchestrator that loops through the WSJF-ranked backlog, working tickets without interactive input until quota or a stop condition fires.
111
- - **[JTBD-008 Decompose a Fix Into Coordinated Changes](../../docs/jtbd/solo-developer/JTBD-008-decompose-fix-into-coordinated-changes.proposed.md)** — `/wr-itil:capture-rfc` + `/wr-itil:manage-rfc` are the capture-time decomposition surface for multi-commit coordinated changes traced to a driving problem; the I1 trace-to-problem invariant is gate-enforced at capture-rfc time (P170 / ADR-060).
119
+ - **[JTBD-008 Decompose a Fix Into Coordinated Changes](../../docs/jtbd/solo-developer/JTBD-008-decompose-fix-into-coordinated-changes.proposed.md)** — `/wr-itil:capture-rfc` + `/wr-itil:manage-rfc` are the capture-time decomposition surface for multi-commit coordinated changes traced to a driving problem (Phase 1); `/wr-itil:capture-story` is the INVEST-shaped sub-workstream surface for individual slices under those coordinated changes (Phase 2 — story tier). The I1 trace-to-problem invariant is gate-enforced at capture-rfc time; I6 + I9 problem-and-JTBD-trace invariants are gate-enforced at capture-story time (P170 / ADR-060).
112
120
 
113
121
  ### Plugin user (currency anchor)
114
122
 
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec "$(dirname "$0")/../scripts/reconcile-stories.sh" "$@"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ exec "$(dirname "$0")/../scripts/reconcile-story-maps.sh" "$@"
package/hooks/hooks.json CHANGED
@@ -35,6 +35,10 @@
35
35
  {
36
36
  "matcher": "Bash",
37
37
  "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-changeset-discipline.sh" }]
38
+ },
39
+ {
40
+ "matcher": "Bash",
41
+ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-readme-refresh-discipline.sh" }]
38
42
  }
39
43
  ],
40
44
  "PostToolUse": [
@@ -0,0 +1,118 @@
1
+ #!/bin/bash
2
+ # P165: PreToolUse:Bash hook — denies `git commit` invocations whose
3
+ # staged set includes a `docs/problems/<state>/NNN-*.md` ticket change
4
+ # but does NOT also stage a `docs/problems/README.md` refresh. Hook-
5
+ # level enforcement replaces the declarative-only P094 / P062 contract
6
+ # in manage-problem SKILL.md Step 5 / Step 7 — iter subprocess commits
7
+ # previously could ship a `.verifying.md` rename or Status edit without
8
+ # the corresponding Verification Queue / WSJF Rankings row update in
9
+ # the README, leaving README staleness for the next iter or
10
+ # `/wr-itil:reconcile-readme` to recover.
11
+ #
12
+ # Detection delegates to `lib/readme-refresh-detect.sh::detect_readme_refresh_required`.
13
+ # When the helper returns 1, this hook emits PreToolUse deny JSON with
14
+ # the offending ticket path inline and the literal `git add
15
+ # docs/problems/README.md` recovery command, satisfying ADR-013
16
+ # Rule 1's "deny redirects to a recovery path" contract via the
17
+ # mechanical-recovery shape (no skill wrapper required — staging the
18
+ # README is a single command).
19
+ #
20
+ # Allow paths (exit 0 silently per ADR-045 Pattern 1):
21
+ # - tool_name != "Bash" (only Bash invocations are gated)
22
+ # - command does not contain `git commit` substring (non-commit
23
+ # Bash bypasses entirely)
24
+ # - staged set is README-discipline- (helper returns 0)
25
+ # clean
26
+ # - BYPASS_README_REFRESH_GATE=1 env (helper returns 0 first)
27
+ # - outside a git work tree (helper fails-open)
28
+ # - parse failure on stdin (mirrors create-gate.sh fail-open)
29
+ #
30
+ # References:
31
+ # ADR-005 — plugin testing strategy (hook bats live under hooks/test/).
32
+ # ADR-009 — gate marker lifecycle (this hook deliberately does NOT
33
+ # use markers; detection is per-invocation deterministic
34
+ # — same precedent as P125 + P141).
35
+ # ADR-013 Rule 1 — deny redirects with mechanical recovery.
36
+ # ADR-014 — single-commit grain (the contract this hook enforces).
37
+ # ADR-022 — `.verifying.md` lifecycle status.
38
+ # ADR-038 — progressive disclosure / deny-message terseness budget.
39
+ # ADR-045 — hook injection budget (Pattern 1 silent-on-pass; deny
40
+ # band ≤300 bytes for this hook).
41
+ # P062 — parent (README refresh on transition contract).
42
+ # P094 — parent (README refresh on creation contract).
43
+ # P118 — sibling reconcile-readme recovery path.
44
+ # P125 — sibling staging-trap hook (same enforcement-layer shape).
45
+ # P141 — sibling changeset-discipline hook (same shape).
46
+ # P165 — this hook.
47
+
48
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
49
+ # shellcheck source=lib/readme-refresh-detect.sh
50
+ source "$SCRIPT_DIR/lib/readme-refresh-detect.sh"
51
+
52
+ INPUT=$(cat)
53
+
54
+ TOOL_NAME=$(echo "$INPUT" | python3 -c "
55
+ import sys, json
56
+ try:
57
+ data = json.load(sys.stdin)
58
+ print(data.get('tool_name', ''))
59
+ except:
60
+ print('')
61
+ " 2>/dev/null || echo "")
62
+
63
+ # Only gate Bash. Non-Bash tools bypass entirely.
64
+ if [ "$TOOL_NAME" != "Bash" ]; then
65
+ exit 0
66
+ fi
67
+
68
+ COMMAND=$(echo "$INPUT" | python3 -c "
69
+ import sys, json
70
+ try:
71
+ data = json.load(sys.stdin)
72
+ print(data.get('tool_input', {}).get('command', ''))
73
+ except:
74
+ print('')
75
+ " 2>/dev/null || echo "")
76
+
77
+ # Only fire on `git commit` invocations. Substring match catches common
78
+ # shapes (`git commit -m`, `git commit --amend`, leading `cd && git
79
+ # commit`, etc.) without over-matching unrelated bash.
80
+ case "$COMMAND" in
81
+ *"git commit"*) ;;
82
+ *) exit 0 ;;
83
+ esac
84
+
85
+ # Run detection. Helper echoes offending ticket path on stdout when
86
+ # detected; returns 1 in that case. Returns 0 (allow) on no-trap,
87
+ # bypass env, or fail-open (non-git tree, parse error).
88
+ TRAPPED_TICKET=$(detect_readme_refresh_required 2>/dev/null) && exit 0
89
+
90
+ # Extract the leading ticket-ID digits from the basename so the deny
91
+ # names the ticket as `P<NNN>` rather than the full descriptive path
92
+ # (problem tickets carry long slugs; embedding the full path can
93
+ # exceed ADR-045 deny-band 300 bytes). `git status` reveals the exact
94
+ # staged path for recovery; the deny only needs to name the ticket
95
+ # distinctly.
96
+ BASENAME="${TRAPPED_TICKET##*/}"
97
+ TICKET_NUM="${BASENAME%%-*}"
98
+ case "$TICKET_NUM" in
99
+ ''|*[!0-9]*) TICKET_ID="(staged ticket)" ;;
100
+ *) TICKET_ID="P${TICKET_NUM}" ;;
101
+ esac
102
+
103
+ # Trap detected — emit deny with terse recovery.
104
+ # Voice-tone budget per ADR-045 deny-band ≤300 bytes total. Names the
105
+ # offending ticket ID, the literal recovery command, the BYPASS env
106
+ # var escape, and the P165 cite.
107
+ REASON="BLOCKED: P165. ${TICKET_ID} needs docs/problems/README.md refresh. Run: git add docs/problems/README.md. Bypass: BYPASS_README_REFRESH_GATE=1."
108
+
109
+ cat <<EOF
110
+ {
111
+ "hookSpecificOutput": {
112
+ "hookEventName": "PreToolUse",
113
+ "permissionDecision": "deny",
114
+ "permissionDecisionReason": "${REASON}"
115
+ }
116
+ }
117
+ EOF
118
+ exit 0
@@ -0,0 +1,161 @@
1
+ #!/bin/bash
2
+ # P165: shared README-refresh-discipline detection helper.
3
+ #
4
+ # `detect_readme_refresh_required` returns 0 (no change required —
5
+ # allow) / 1 (ticket change staged but README refresh not staged —
6
+ # caller should deny). On 1, the offending ticket file path is echoed
7
+ # on stdout so callers can name it in deny messages without re-parsing
8
+ # diff output.
9
+ #
10
+ # Trap shape (P165):
11
+ # `manage-problem` SKILL.md Step 5 (P094) and Step 7 (P062) say every
12
+ # ticket creation, ranking-bearing update, and status transition MUST
13
+ # stage the refreshed `docs/problems/README.md` in the same commit as
14
+ # the ticket change (ADR-014 single-commit grain). The contract is
15
+ # declarative; iter subprocess commits have shipped `.verifying.md`
16
+ # renames or Status edits without the README refresh (observed iter
17
+ # 3 commit d28bd51 — P156 row missing from VQ until iter 4 backfill).
18
+ # Hook-level detection at `git commit` time replaces the declarative-
19
+ # only enforcement.
20
+ #
21
+ # Detection logic:
22
+ # - `git diff --staged --name-only` enumerates staged paths.
23
+ # - Categorise each path:
24
+ # * `docs/problems/(open|verifying|closed|known-error|parked)/NNN-*.md`
25
+ # (new state-directory layout per ADR-031) — counts as a
26
+ # ticket-state-transition surface; records the path.
27
+ # * `docs/problems/NNN-*.(open|verifying|closed|known-error|parked).md`
28
+ # (legacy flat layout) — also counts; supports adopter repos
29
+ # and any residual flat-layout tickets.
30
+ # * `docs/problems/README.md` — counts as a README refresh.
31
+ # * `docs/problems/README-history.md` — ignored (rotated history
32
+ # per P134; not a ticket file, not the load-bearing README).
33
+ # * Anything else — ignored (non-ticket surface; the gate has no
34
+ # opinion on retros, ADRs, source, etc.).
35
+ # - If any ticket path is recorded AND README is NOT staged, return
36
+ # 1 + echo the first offending ticket path.
37
+ #
38
+ # Bypass:
39
+ # - `BYPASS_README_REFRESH_GATE=1` env var → return 0 (allow). For
40
+ # legitimate narrative-only ticket-body edits that don't change
41
+ # ranking-bearing fields. 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` + `lib/changeset-detect.sh`
47
+ # fail-open precedent — a hook that fails-closed on hostile
48
+ # environments would block legitimate commits in non-git contexts.
49
+ #
50
+ # Cost: one `git diff` invocation per check (~10ms on this repo's
51
+ # working tree). Per-invocation deterministic — runs on every
52
+ # `git commit` invocation rather than relying on per-tool-call session
53
+ # state tracking. Mirrors P125 `staging-detect.sh` + P141
54
+ # `changeset-detect.sh` precedent (architect-approved no-marker design
55
+ # per ADR-009 carve-out).
56
+ #
57
+ # References:
58
+ # ADR-005 — plugin testing strategy (hook bats live under
59
+ # `hooks/test/` per P081 behavioural-test discipline).
60
+ # ADR-009 — gate marker lifecycle (this helper deliberately does
61
+ # NOT use markers; detection is per-invocation
62
+ # deterministic, not per-session trust window — same
63
+ # precedent as P125 / P141).
64
+ # ADR-013 Rule 1 — deny redirects with mechanical recovery (the deny
65
+ # text names the offending ticket path + the literal
66
+ # `git add docs/problems/README.md` recovery command +
67
+ # the BYPASS env var override).
68
+ # ADR-014 — single-commit grain (this hook enforces it for the
69
+ # ticket-state-transition surface).
70
+ # ADR-022 — `.verifying.md` lifecycle status (one of the surface
71
+ # shapes the hook detects).
72
+ # ADR-031 — per-state-subdir problem ticket layout (the new layout
73
+ # the hook detects).
74
+ # ADR-038 — progressive disclosure / deny-message terseness.
75
+ # ADR-045 — hook injection budget (Pattern 1 silent-on-pass; deny
76
+ # band ≤300 bytes for this hook).
77
+ # P062 — parent (README refresh on transition contract — manage-
78
+ # problem Step 7).
79
+ # P094 — parent (README refresh on creation contract — manage-
80
+ # problem Step 5).
81
+ # P118 — sibling reconcile-readme recovery path (the after-the-
82
+ # fact rescue this hook obviates).
83
+ # P125 — sibling staging-trap helper (same enforcement-layer
84
+ # shape — per-invocation deterministic, no markers).
85
+ # P141 — sibling changeset-discipline helper (same shape).
86
+ # P165 — this helper.
87
+
88
+ # Detect whether the current staged set requires a README refresh that
89
+ # is not staged.
90
+ #
91
+ # Echoes the offending ticket path on stdout when detected.
92
+ #
93
+ # Returns:
94
+ # 0 — no change required, or BYPASS env set, or fail-open (allow)
95
+ # 1 — ticket change staged + README not staged (caller should deny)
96
+ detect_readme_refresh_required() {
97
+ # Bypass via env var — single most-common legitimate escape.
98
+ if [ "${BYPASS_README_REFRESH_GATE:-}" = "1" ]; then
99
+ return 0
100
+ fi
101
+
102
+ # Fail-open if not inside a git working tree.
103
+ git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
104
+
105
+ local staged
106
+ staged=$(git diff --staged --name-only 2>/dev/null) || return 0
107
+
108
+ # No staged paths — nothing to gate.
109
+ [ -n "$staged" ] || return 0
110
+
111
+ local has_readme=0
112
+ local offending_ticket=""
113
+ local path basename
114
+
115
+ while IFS= read -r path; do
116
+ [ -n "$path" ] || continue
117
+
118
+ case "$path" in
119
+ docs/problems/README.md)
120
+ has_readme=1
121
+ ;;
122
+ docs/problems/README-history.md)
123
+ # Rotated history file — not a ticket, not the load-bearing
124
+ # README. Ignored.
125
+ ;;
126
+ docs/problems/open/*.md \
127
+ | docs/problems/verifying/*.md \
128
+ | docs/problems/closed/*.md \
129
+ | docs/problems/known-error/*.md \
130
+ | docs/problems/parked/*.md)
131
+ # New state-directory layout (ADR-031). Filename must start
132
+ # with digits to be a ticket file — exclude any future
133
+ # state-directory-local README or similar.
134
+ basename="${path##*/}"
135
+ case "$basename" in
136
+ [0-9]*.md)
137
+ [ -z "$offending_ticket" ] && offending_ticket="$path"
138
+ ;;
139
+ esac
140
+ ;;
141
+ docs/problems/[0-9]*.md)
142
+ # Legacy flat layout: docs/problems/NNN-*.<state>.md.
143
+ # Excludes README.md and README-history.md (already cased
144
+ # above; both start with `R`, not a digit).
145
+ [ -z "$offending_ticket" ] && offending_ticket="$path"
146
+ ;;
147
+ *)
148
+ # Non-ticket surface: ignored.
149
+ ;;
150
+ esac
151
+ done <<EOF
152
+ $staged
153
+ EOF
154
+
155
+ if [ -n "$offending_ticket" ] && [ "$has_readme" -eq 0 ]; then
156
+ printf '%s\n' "$offending_ticket"
157
+ return 1
158
+ fi
159
+
160
+ return 0
161
+ }
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P165: itil-readme-refresh-discipline.sh PreToolUse:Bash hook must deny
4
+ # `git commit` invocations whose staged set includes any
5
+ # docs/problems/<state>/NNN-*.md (or legacy docs/problems/NNN-*.md) but
6
+ # does NOT also stage docs/problems/README.md. Hook-level enforcement
7
+ # closes the P094/P062 README-refresh enforcement gap — iter subprocess
8
+ # commits could previously ship a `.verifying.md` rename or Status edit
9
+ # without the corresponding Verification Queue / WSJF Rankings row in
10
+ # the README.
11
+ #
12
+ # Detection logic (per ticket Fix Strategy + architect verdict):
13
+ # On `git commit` invocations, run `git diff --staged --name-only`.
14
+ # If any path matches docs/problems/(open|verifying|closed|known-error|parked)/NNN-*.md
15
+ # OR docs/problems/NNN-*.<state>.md (legacy flat layout) AND
16
+ # docs/problems/README.md is NOT staged, emit a deny with recovery
17
+ # directive `git add docs/problems/README.md` and the P165 cite.
18
+ # Allow when README is staged alongside, when no ticket file is
19
+ # staged at all (README-only / retro-only / ADR-only / source-only
20
+ # commits), or when BYPASS_README_REFRESH_GATE=1 is set.
21
+ #
22
+ # Per ADR-005 (plugin testing strategy) — hook bats live under
23
+ # packages/<plugin>/hooks/test/ and assert behaviour on emitted JSON,
24
+ # not source content. Per P081 — no source-grep on hook text. Simulate
25
+ # the PreToolUse:Bash payload on stdin and assert on the emitted
26
+ # permissionDecision.
27
+ #
28
+ # Per ADR-045 Pattern 1 (silent-on-pass) — allow paths emit 0 bytes.
29
+ # Per ADR-045 deny-band — deny messages target ~245 bytes; cap at 300.
30
+
31
+ setup() {
32
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
33
+ HOOK="$SCRIPT_DIR/itil-readme-refresh-discipline.sh"
34
+ ORIG_DIR="$PWD"
35
+ TEST_DIR=$(mktemp -d)
36
+ cd "$TEST_DIR"
37
+ git init --quiet -b main
38
+ git config user.email "test@example.com"
39
+ git config user.name "Test"
40
+ mkdir -p docs/problems/open docs/problems/verifying docs/problems/closed \
41
+ docs/problems/known-error docs/problems/parked docs/retros \
42
+ docs/decisions packages/itil/skills/foo .changeset
43
+ echo "seed" > seed.txt
44
+ git add seed.txt
45
+ git -c commit.gpgsign=false commit --quiet -m "initial"
46
+ # README must exist for the "stage it alongside" tests to work.
47
+ echo "# Problem Backlog" > docs/problems/README.md
48
+ git add docs/problems/README.md
49
+ git -c commit.gpgsign=false commit --quiet -m "seed readme"
50
+ unset BYPASS_README_REFRESH_GATE
51
+ }
52
+
53
+ teardown() {
54
+ cd "$ORIG_DIR"
55
+ rm -rf "$TEST_DIR"
56
+ unset BYPASS_README_REFRESH_GATE
57
+ }
58
+
59
+ run_bash_hook() {
60
+ local cmd="$1"
61
+ local json
62
+ json=$(printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$cmd")
63
+ echo "$json" | bash "$HOOK"
64
+ }
65
+
66
+ # --- Trap detection: the canonical P165 shape ---
67
+
68
+ @test "deny: staged docs/problems/open/NNN-*.md without README refresh triggers deny on git commit" {
69
+ echo "# Problem 999" > docs/problems/open/999-some-new-ticket.md
70
+ git add docs/problems/open/999-some-new-ticket.md
71
+ run run_bash_hook "git commit -m 'feat'"
72
+ [ "$status" -eq 0 ]
73
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
74
+ [[ "$output" == *"P165"* ]]
75
+ }
76
+
77
+ @test "deny: staged docs/problems/verifying/NNN-*.md without README refresh triggers deny" {
78
+ echo "# Problem 999 verifying" > docs/problems/verifying/999-some-ticket.md
79
+ git add docs/problems/verifying/999-some-ticket.md
80
+ run run_bash_hook "git commit -m 'fix'"
81
+ [ "$status" -eq 0 ]
82
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
83
+ [[ "$output" == *"P165"* ]]
84
+ }
85
+
86
+ @test "deny: staged docs/problems/closed/NNN-*.md without README refresh triggers deny" {
87
+ echo "# Problem 999 closed" > docs/problems/closed/999-some-ticket.md
88
+ git add docs/problems/closed/999-some-ticket.md
89
+ run run_bash_hook "git commit -m 'close'"
90
+ [ "$status" -eq 0 ]
91
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
92
+ }
93
+
94
+ @test "deny: staged docs/problems/known-error/NNN-*.md without README refresh triggers deny" {
95
+ echo "# Problem 999 known error" > docs/problems/known-error/999-some-ticket.md
96
+ git add docs/problems/known-error/999-some-ticket.md
97
+ run run_bash_hook "git commit -m 'transition'"
98
+ [ "$status" -eq 0 ]
99
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
100
+ }
101
+
102
+ @test "deny: staged docs/problems/parked/NNN-*.md without README refresh triggers deny" {
103
+ echo "# Problem 999 parked" > docs/problems/parked/999-some-ticket.md
104
+ git add docs/problems/parked/999-some-ticket.md
105
+ run run_bash_hook "git commit -m 'park'"
106
+ [ "$status" -eq 0 ]
107
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
108
+ }
109
+
110
+ @test "deny: staged legacy flat-layout docs/problems/NNN-*.<state>.md without README triggers deny" {
111
+ echo "# Problem 999 flat" > docs/problems/999-some-legacy.open.md
112
+ git add docs/problems/999-some-legacy.open.md
113
+ run run_bash_hook "git commit -m 'feat'"
114
+ [ "$status" -eq 0 ]
115
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
116
+ }
117
+
118
+ @test "deny message names offending ticket ID, recovery command, P165 cite" {
119
+ echo "# Problem 999" > docs/problems/open/999-some-new-ticket.md
120
+ git add docs/problems/open/999-some-new-ticket.md
121
+ run run_bash_hook "git commit -m 'feat'"
122
+ [ "$status" -eq 0 ]
123
+ # Deny names the ticket as `P<NNN>` (not full path — see hook
124
+ # comment: full descriptive ticket slugs exceed ADR-045 deny-band).
125
+ [[ "$output" == *"P999"* ]]
126
+ [[ "$output" == *"docs/problems/README.md"* ]]
127
+ [[ "$output" == *"P165"* ]]
128
+ }
129
+
130
+ @test "deny message stays under ADR-045 deny-band (<300 bytes)" {
131
+ echo "# Problem 999" > docs/problems/open/999-some-ticket.md
132
+ git add docs/problems/open/999-some-ticket.md
133
+ run run_bash_hook "git commit -m 'feat'"
134
+ [ "$status" -eq 0 ]
135
+ [ "${#output}" -lt 300 ]
136
+ }
137
+
138
+ # --- Allow paths: each non-trap shape must NOT deny ---
139
+
140
+ @test "allow: staged ticket file WITH docs/problems/README.md allows the commit" {
141
+ echo "# Problem 999" > docs/problems/open/999-new.md
142
+ echo "# Problem Backlog updated" > docs/problems/README.md
143
+ git add docs/problems/open/999-new.md docs/problems/README.md
144
+ run run_bash_hook "git commit -m 'feat'"
145
+ [ "$status" -eq 0 ]
146
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
147
+ }
148
+
149
+ @test "allow: README-only commit (reconcile-readme path) allows without ticket change" {
150
+ echo "# Problem Backlog reconciled" > docs/problems/README.md
151
+ git add docs/problems/README.md
152
+ run run_bash_hook "git commit -m 'docs: reconcile readme'"
153
+ [ "$status" -eq 0 ]
154
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
155
+ }
156
+
157
+ @test "allow: retro-only commit allows without ticket change or README refresh" {
158
+ echo "# Retro 2026-05-11" > docs/retros/2026-05-11-iter.md
159
+ git add docs/retros/2026-05-11-iter.md
160
+ run run_bash_hook "git commit -m 'docs(retros): iter'"
161
+ [ "$status" -eq 0 ]
162
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
163
+ }
164
+
165
+ @test "allow: ADR-only commit allows without ticket change or README refresh" {
166
+ echo "# ADR 999" > docs/decisions/999-some-decision.proposed.md
167
+ git add docs/decisions/999-some-decision.proposed.md
168
+ run run_bash_hook "git commit -m 'docs(decisions): adr-999'"
169
+ [ "$status" -eq 0 ]
170
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
171
+ }
172
+
173
+ @test "allow: source-only commit (packages/) allows without ticket change or README refresh" {
174
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
175
+ git add packages/itil/skills/foo/SKILL.md
176
+ run run_bash_hook "git commit -m 'feat'"
177
+ [ "$status" -eq 0 ]
178
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
179
+ }
180
+
181
+ @test "allow: BYPASS_README_REFRESH_GATE=1 env var allows ticket commit without README refresh" {
182
+ echo "# Problem 999" > docs/problems/open/999-bypass.md
183
+ git add docs/problems/open/999-bypass.md
184
+ BYPASS_README_REFRESH_GATE=1 run run_bash_hook "git commit -m 'feat'"
185
+ [ "$status" -eq 0 ]
186
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
187
+ }
188
+
189
+ @test "allow: docs/problems/README-history.md edit alone does NOT trigger deny (not a ticket file)" {
190
+ echo "# History" > docs/problems/README-history.md
191
+ git add docs/problems/README-history.md
192
+ run run_bash_hook "git commit -m 'docs: rotate history'"
193
+ [ "$status" -eq 0 ]
194
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
195
+ }
196
+
197
+ # --- Allow path silence (ADR-045 Pattern 1) ---
198
+
199
+ @test "allow path emits 0 bytes (ADR-045 Pattern 1 silent-on-pass)" {
200
+ echo "# Retro" > docs/retros/2026-05-11-iter.md
201
+ git add docs/retros/2026-05-11-iter.md
202
+ run run_bash_hook "git commit -m 'docs'"
203
+ [ "$status" -eq 0 ]
204
+ [ "${#output}" -eq 0 ]
205
+ }
206
+
207
+ # --- Tool-name and command-shape filters ---
208
+
209
+ @test "allow: non-Bash tool exits 0 without deny" {
210
+ run bash -c "echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"foo.md\"}}' | bash $HOOK"
211
+ [ "$status" -eq 0 ]
212
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
213
+ }
214
+
215
+ @test "allow: Bash command that is NOT git commit (e.g., git status) bypasses detection" {
216
+ echo "# Problem 999" > docs/problems/open/999-x.md
217
+ git add docs/problems/open/999-x.md
218
+ run run_bash_hook "git status"
219
+ [ "$status" -eq 0 ]
220
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
221
+ }
222
+
223
+ # --- Mixed staged sets ---
224
+
225
+ @test "deny: staged ticket + ADR (no README) still triggers deny (mixed surface dominance)" {
226
+ echo "# Problem 999" > docs/problems/open/999-x.md
227
+ echo "# ADR 999" > docs/decisions/999-x.proposed.md
228
+ git add docs/problems/open/999-x.md docs/decisions/999-x.proposed.md
229
+ run run_bash_hook "git commit -m 'feat'"
230
+ [ "$status" -eq 0 ]
231
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
232
+ }
233
+
234
+ @test "allow: staged ticket + ADR + README allows (mixed set with README)" {
235
+ echo "# Problem 999" > docs/problems/open/999-x.md
236
+ echo "# ADR 999" > docs/decisions/999-x.proposed.md
237
+ echo "# Problem Backlog updated" > docs/problems/README.md
238
+ git add docs/problems/open/999-x.md docs/decisions/999-x.proposed.md docs/problems/README.md
239
+ run run_bash_hook "git commit -m 'feat'"
240
+ [ "$status" -eq 0 ]
241
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
242
+ }
243
+
244
+ # --- Parse / fail-open contracts ---
245
+
246
+ @test "allow: empty JSON exits 0 without deny (fail-open on parse-incomplete)" {
247
+ run bash -c "echo '{}' | bash $HOOK"
248
+ [ "$status" -eq 0 ]
249
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
250
+ }
251
+
252
+ @test "allow: outside a git work tree exits 0 without deny (fail-open)" {
253
+ cd "$ORIG_DIR"
254
+ TEMP_NONGIT=$(mktemp -d)
255
+ cd "$TEMP_NONGIT"
256
+ run run_bash_hook "git commit -m 'feat'"
257
+ cd "$TEST_DIR"
258
+ rm -rf "$TEMP_NONGIT"
259
+ [ "$status" -eq 0 ]
260
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
261
+ }