@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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/wr-itil-check-problems-readme-budget +2 -0
- package/bin/wr-itil-reconcile-readme +2 -0
- package/hooks/hooks.json +4 -0
- package/hooks/itil-changeset-discipline.sh +102 -0
- package/hooks/lib/changeset-detect.sh +164 -0
- package/hooks/test/itil-changeset-discipline.bats +247 -0
- package/package.json +1 -1
- package/skills/manage-problem/SKILL.md +5 -3
- package/skills/reconcile-readme/SKILL.md +3 -1
- package/skills/work-problems/SKILL.md +3 -1
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|