@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -1
- package/bin/wr-itil-reconcile-stories +2 -0
- package/bin/wr-itil-reconcile-story-maps +2 -0
- package/hooks/hooks.json +4 -0
- package/hooks/itil-readme-refresh-discipline.sh +118 -0
- package/hooks/lib/readme-refresh-detect.sh +161 -0
- package/hooks/test/itil-readme-refresh-discipline.bats +261 -0
- package/lib/migrate-problems-layout.sh +128 -0
- package/package.json +1 -1
- package/scripts/reconcile-stories.sh +236 -0
- package/scripts/reconcile-story-maps.sh +98 -0
- package/scripts/test/reconcile-stories.bats +173 -0
- package/scripts/test/reconcile-story-maps.bats +74 -0
- package/scripts/test/rfc-stories-extension.bats +173 -0
- package/scripts/test/update-problem-references-section.bats +195 -0
- package/scripts/test/update-references-section-sibling-helpers.bats +80 -0
- package/scripts/test/working-the-problem-traversal.bats +109 -0
- package/scripts/update-jtbd-references-section.sh +131 -0
- package/scripts/update-problem-references-section.sh +284 -0
- package/scripts/update-rfc-references-section.sh +152 -0
- package/scripts/update-story-references-section.sh +128 -0
- package/skills/capture-rfc/SKILL.md +28 -3
- package/skills/capture-story/SKILL.md +373 -0
- package/skills/capture-story/test/capture-story-behavioural.bats +227 -0
- package/skills/capture-story-map/SKILL.md +229 -0
- package/skills/capture-story-map/test/capture-story-map-behavioural.bats +98 -0
- package/skills/list-stories/SKILL.md +151 -0
- package/skills/list-stories/test/list-stories-contract.bats +127 -0
- package/skills/list-story-maps/SKILL.md +93 -0
- package/skills/list-story-maps/test/list-story-maps-contract.bats +46 -0
- package/skills/manage-problem/SKILL.md +42 -4
- package/skills/manage-problem/test/manage-problem-auto-migrate-step.bats +53 -0
- package/skills/manage-rfc/SKILL.md +12 -0
- package/skills/manage-story/SKILL.md +242 -0
- package/skills/manage-story/test/manage-story-contract.bats +171 -0
- package/skills/manage-story-map/SKILL.md +158 -0
- package/skills/manage-story-map/test/manage-story-map-contract.bats +63 -0
- package/skills/reconcile-stories/SKILL.md +110 -0
- package/skills/reconcile-story-maps/SKILL.md +70 -0
- package/skills/work-problem/SKILL.md +1 -1
- package/skills/work-problems/SKILL.md +25 -0
- package/skills/work-problems/test/work-problems-auto-migrate-step.bats +57 -0
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
|
|
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
|
+
}
|