@windyroad/itil 0.22.1 → 0.23.0-preview.248
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/hooks/hooks.json +4 -0
- package/hooks/itil-claude-space-protection.sh +98 -0
- package/hooks/lib/block-list.sh +191 -0
- package/hooks/lib/claude-space-gate.sh +177 -0
- package/hooks/test/block-list.bats +153 -0
- package/hooks/test/itil-claude-space-protection.bats +294 -0
- package/package.json +1 -1
- package/skills/manage-problem/SKILL.md +28 -5
- package/skills/reconcile-readme/SKILL.md +13 -5
- package/skills/review-problems/SKILL.md +1 -1
- package/skills/transition-problem/SKILL.md +1 -1
- package/skills/transition-problems/SKILL.md +1 -1
package/hooks/hooks.json
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"matcher": "Write",
|
|
13
13
|
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/manage-problem-enforce-create.sh" }]
|
|
14
14
|
},
|
|
15
|
+
{
|
|
16
|
+
"matcher": "Write|Edit",
|
|
17
|
+
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-claude-space-protection.sh" }]
|
|
18
|
+
},
|
|
15
19
|
{
|
|
16
20
|
"matcher": "Bash",
|
|
17
21
|
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/p057-staging-trap-detect.sh" }]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# P131: PreToolUse:Write|Edit enforcement hook for `.claude/` user-space.
|
|
3
|
+
#
|
|
4
|
+
# DENIES Write|Edit to project-scoped `.claude/` paths that are NOT in the
|
|
5
|
+
# user-space allow-list, unless the user has pre-authorized that specific
|
|
6
|
+
# path via an `.claude/.agent-write-approved-<sha256-of-rel-path>` marker.
|
|
7
|
+
#
|
|
8
|
+
# Why: `.claude/` is user-controlled config space (settings, memory, MCP
|
|
9
|
+
# servers, user-authored skills/hooks/commands/agents, Claude Code's own
|
|
10
|
+
# state in projects/ and worktrees/). Agents misread the architect/JTBD
|
|
11
|
+
# gate-exclusion list as "approved write zones" and write project-generated
|
|
12
|
+
# content (plans, audits, scratch state) under `.claude/`, polluting user
|
|
13
|
+
# space. Project-generated content belongs in `docs/` (plans, audits) or
|
|
14
|
+
# inline in problem-ticket bodies. See P131 + project CLAUDE.md MANDATORY
|
|
15
|
+
# rule.
|
|
16
|
+
#
|
|
17
|
+
# Allow-list (per is_protected_claude_path in lib/claude-space-gate.sh):
|
|
18
|
+
# - .claude/settings.json, settings.local.json, *.local.json (root)
|
|
19
|
+
# - .claude/MEMORY.md
|
|
20
|
+
# - .claude/.install-updates-consent, scheduled_tasks.lock
|
|
21
|
+
# - .claude/skills/, commands/, agents/, hooks/ subtrees
|
|
22
|
+
# - .claude/projects/, worktrees/ subtrees (Claude Code's own state)
|
|
23
|
+
# - .claude/.agent-write-approved-* markers themselves
|
|
24
|
+
#
|
|
25
|
+
# Bypass: user creates `.claude/.agent-write-approved-<sha256>` marker
|
|
26
|
+
# (sha256 of project-relative path). Marker is persistent (no TTL); user
|
|
27
|
+
# pre-authorizes once per path. Distinct from session-scoped review
|
|
28
|
+
# markers (ADR-009) — this is the persistent in-tree shape used by
|
|
29
|
+
# `.claude/.install-updates-consent` (ADR-030 / P120 precedent).
|
|
30
|
+
#
|
|
31
|
+
# Out of scope:
|
|
32
|
+
# - Read|Glob|Grep on .claude/ paths — only Write|Edit gated
|
|
33
|
+
# - Paths outside PWD project root (~/.claude/, other repos' .claude/)
|
|
34
|
+
# - Empty session_id / file_path — fail-open (ADR-013 Rule 6 parity
|
|
35
|
+
# with sibling hooks like manage-problem-enforce-create.sh)
|
|
36
|
+
#
|
|
37
|
+
# References:
|
|
38
|
+
# ADR-009 — gate marker lifecycle (this hook adds a NEW persistent
|
|
39
|
+
# marker class; ADR-009's session-scoped /tmp markers
|
|
40
|
+
# unchanged)
|
|
41
|
+
# ADR-013 Rule 6 — non-interactive fail-safe; deny via stdin JSON
|
|
42
|
+
# ADR-030 — persistent in-tree consent marker precedent
|
|
43
|
+
# ADR-038 — progressive disclosure (deny message <500 bytes)
|
|
44
|
+
# ADR-045 — hook injection budget (silent on allow path)
|
|
45
|
+
# P119 — manage-problem-enforce-create.sh hook shape precedent
|
|
46
|
+
# P131 — driver
|
|
47
|
+
|
|
48
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
49
|
+
# shellcheck source=lib/claude-space-gate.sh
|
|
50
|
+
source "$SCRIPT_DIR/lib/claude-space-gate.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
|
+
FILE_PATH=$(echo "$INPUT" | python3 -c "
|
|
64
|
+
import sys, json
|
|
65
|
+
try:
|
|
66
|
+
data = json.load(sys.stdin)
|
|
67
|
+
print(data.get('tool_input', {}).get('file_path', ''))
|
|
68
|
+
except:
|
|
69
|
+
print('')
|
|
70
|
+
" 2>/dev/null || echo "")
|
|
71
|
+
|
|
72
|
+
# Only gate Write and Edit. Read|Glob|Grep on .claude/ are out of scope.
|
|
73
|
+
case "$TOOL_NAME" in
|
|
74
|
+
Write|Edit) ;;
|
|
75
|
+
*) exit 0 ;;
|
|
76
|
+
esac
|
|
77
|
+
|
|
78
|
+
# Empty file_path — fail-open (parse failure / non-file tool variant).
|
|
79
|
+
if [ -z "$FILE_PATH" ]; then
|
|
80
|
+
exit 0
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
# Apply gate. PWD is the project root at the moment Claude Code invokes
|
|
84
|
+
# the hook (Claude Code spawns hooks in the project directory).
|
|
85
|
+
if ! is_protected_claude_path "$FILE_PATH" "$PWD"; then
|
|
86
|
+
# Either not under .claude/, or in user-space allow-list. Allow.
|
|
87
|
+
exit 0
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# Check for approval marker bypass.
|
|
91
|
+
if has_approval_marker "$FILE_PATH" "$PWD"; then
|
|
92
|
+
exit 0
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
BASENAME=$(basename "$FILE_PATH")
|
|
96
|
+
|
|
97
|
+
claude_space_deny "BLOCKED: Cannot Write|Edit '${BASENAME}' under .claude/. That directory is user-controlled config space — agents must not write project-generated artefacts there. Use docs/plans/ for plans, docs/audits/ for audit logs, or attach inline to the relevant problem ticket. To pre-authorize a specific .claude/ path, create marker '.claude/.agent-write-approved-<sha256-of-rel-path>' (P131; see project CLAUDE.md MANDATORY rule)."
|
|
98
|
+
exit 0
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# P123: shared block-list helper for the inbound-report block mechanism.
|
|
3
|
+
# Per ADR-046 §v1 implementation contract — audit-log-only.
|
|
4
|
+
#
|
|
5
|
+
# Functions:
|
|
6
|
+
# is_blocked(<reporter-id-hash>)
|
|
7
|
+
# Exit 0 if the hash is present in $BLOCK_LIST_FILE; non-zero otherwise.
|
|
8
|
+
# add_block(<reporter-id-hash> <evidence-ticket-P###> <provenance>)
|
|
9
|
+
# Validate hex-shape on the hash; append to $BLOCK_LIST_FILE if absent
|
|
10
|
+
# (idempotent); append a `block`-typed entry to $AUDIT_LOG_FILE.
|
|
11
|
+
# remove_block(<reporter-id-hash> <reason>)
|
|
12
|
+
# Remove the hash from $BLOCK_LIST_FILE if present; append an
|
|
13
|
+
# `unblock`-typed entry to $AUDIT_LOG_FILE recording the reason.
|
|
14
|
+
# list_blocks()
|
|
15
|
+
# Print all currently-blocked hashes, one per line.
|
|
16
|
+
#
|
|
17
|
+
# Helper does NOT compute hashes. Caller supplies an opaque hex string;
|
|
18
|
+
# the helper validates SHA-256-width hex shape (64 chars, [0-9a-f]).
|
|
19
|
+
# Rationale (architect verdict, this iter): keeping the helper GitHub-
|
|
20
|
+
# agnostic means non-GitHub channel adoption (out-of-scope per ADR-046
|
|
21
|
+
# §Reassessment) wouldn't require helper changes — only the hashing
|
|
22
|
+
# step on the caller side would differ.
|
|
23
|
+
#
|
|
24
|
+
# Persistence:
|
|
25
|
+
# $BLOCK_LIST_FILE — default `docs/blocked-reporters.json`. JSON array
|
|
26
|
+
# of hash strings. Tracked in git per ADR-046.
|
|
27
|
+
# $AUDIT_LOG_FILE — default `docs/blocked-reporters.audit.jsonl`.
|
|
28
|
+
# One JSON object per line (append-only). Five-field
|
|
29
|
+
# shape per ADR-046 Q2: type, reporter_id_hash,
|
|
30
|
+
# evidence_ticket, timestamp, author.
|
|
31
|
+
#
|
|
32
|
+
# Both file paths are relative to the caller's CWD so test fixtures
|
|
33
|
+
# can drop a temp `docs/` dir in $TEST_DIR. Production callers run
|
|
34
|
+
# from the repo root; same shape applies.
|
|
35
|
+
#
|
|
36
|
+
# Dependencies: `jq` for JSON read/write. `python3` is acceptable as a
|
|
37
|
+
# fallback if jq is not available, but the audit log JSONL emission
|
|
38
|
+
# uses bash printf for atomicity (one printf per line — no parse-write
|
|
39
|
+
# cycle for append).
|
|
40
|
+
#
|
|
41
|
+
# References:
|
|
42
|
+
# ADR-005 — plugin testing strategy (helper bats live alongside).
|
|
43
|
+
# ADR-014 — governance skills commit their own work (this helper is
|
|
44
|
+
# called by future P079 / report-upstream consumers; their
|
|
45
|
+
# commits include the block-list mutation).
|
|
46
|
+
# ADR-017 — shared-code-sync pattern (helper ships ahead of consumers).
|
|
47
|
+
# ADR-022 — verification-pending status (P123 ships audit-log-only,
|
|
48
|
+
# transitions to verifying; full enforcement in future iters).
|
|
49
|
+
# ADR-029 — diagnose before implement (ADR-046 is the diagnosis).
|
|
50
|
+
# ADR-030 — repo-local skills / per-repo artefact precedent.
|
|
51
|
+
# ADR-037 — skill testing strategy (this is hook-shared-helper, not
|
|
52
|
+
# skill content; bats partition under hooks/test/).
|
|
53
|
+
# ADR-046 — blocked-reporters persistence (proposed → accepted with
|
|
54
|
+
# this iter); §v1 implementation contract names this helper.
|
|
55
|
+
# P123 — primary ticket.
|
|
56
|
+
|
|
57
|
+
# Default paths — caller's CWD relative. Override for tests by exporting
|
|
58
|
+
# BLOCK_LIST_FILE / AUDIT_LOG_FILE before sourcing.
|
|
59
|
+
: "${BLOCK_LIST_FILE:=docs/blocked-reporters.json}"
|
|
60
|
+
: "${AUDIT_LOG_FILE:=docs/blocked-reporters.audit.jsonl}"
|
|
61
|
+
|
|
62
|
+
# SHA-256 hex width (64 chars). Helper rejects any input that doesn't
|
|
63
|
+
# match this shape — per architect verdict, we don't allow alternate
|
|
64
|
+
# hash widths in v1 (single-shape contract is simpler to reason about
|
|
65
|
+
# and the JTBD-101 "decide once, encode it" pattern wins here).
|
|
66
|
+
_BLOCK_LIST_HASH_RE='^[0-9a-f]{64}$'
|
|
67
|
+
|
|
68
|
+
# Validate that a string is SHA-256-width hex. Returns 0 on match.
|
|
69
|
+
_block_list_validate_hash() {
|
|
70
|
+
local hash="$1"
|
|
71
|
+
[[ "$hash" =~ $_BLOCK_LIST_HASH_RE ]]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Ensure the block-list file exists with an empty array on first use.
|
|
75
|
+
# Idempotent — repeated calls leave existing content alone.
|
|
76
|
+
_block_list_ensure_file() {
|
|
77
|
+
if [ ! -f "$BLOCK_LIST_FILE" ]; then
|
|
78
|
+
mkdir -p "$(dirname "$BLOCK_LIST_FILE")"
|
|
79
|
+
printf '[]\n' > "$BLOCK_LIST_FILE"
|
|
80
|
+
fi
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Append one JSONL audit-log entry. Five-field shape per ADR-046 Q2.
|
|
84
|
+
# `evidence_ticket` carries the reason string for unblock entries
|
|
85
|
+
# (type-tagged; the unblock reason reuses the same slot).
|
|
86
|
+
_block_list_audit_append() {
|
|
87
|
+
local type="$1"
|
|
88
|
+
local hash="$2"
|
|
89
|
+
local evidence="$3"
|
|
90
|
+
local author="$4"
|
|
91
|
+
local timestamp
|
|
92
|
+
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
93
|
+
mkdir -p "$(dirname "$AUDIT_LOG_FILE")"
|
|
94
|
+
# JSON-escape the evidence and author fields. Both are user-supplied
|
|
95
|
+
# strings; backslash + double-quote are the load-bearing chars.
|
|
96
|
+
local evidence_esc author_esc
|
|
97
|
+
evidence_esc=${evidence//\\/\\\\}
|
|
98
|
+
evidence_esc=${evidence_esc//\"/\\\"}
|
|
99
|
+
author_esc=${author//\\/\\\\}
|
|
100
|
+
author_esc=${author_esc//\"/\\\"}
|
|
101
|
+
printf '{"type":"%s","reporter_id_hash":"%s","evidence_ticket":"%s","timestamp":"%s","author":"%s"}\n' \
|
|
102
|
+
"$type" "$hash" "$evidence_esc" "$timestamp" "$author_esc" \
|
|
103
|
+
>> "$AUDIT_LOG_FILE"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Returns 0 if the hash is currently in the block list; non-zero
|
|
107
|
+
# otherwise. Empty/missing block list => non-zero.
|
|
108
|
+
is_blocked() {
|
|
109
|
+
local hash="$1"
|
|
110
|
+
[ -n "$hash" ] || return 1
|
|
111
|
+
_block_list_validate_hash "$hash" || return 1
|
|
112
|
+
[ -f "$BLOCK_LIST_FILE" ] || return 1
|
|
113
|
+
# Use jq if available; fall back to grep-on-quoted-hash if not.
|
|
114
|
+
if command -v jq >/dev/null 2>&1; then
|
|
115
|
+
jq -e --arg h "$hash" 'index([$h]) != null' "$BLOCK_LIST_FILE" >/dev/null 2>&1
|
|
116
|
+
else
|
|
117
|
+
grep -Fq "\"$hash\"" "$BLOCK_LIST_FILE"
|
|
118
|
+
fi
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Add a hash to the block list. Idempotent (re-adding same hash is
|
|
122
|
+
# a no-op on the list file but DOES emit an audit entry — re-blocks
|
|
123
|
+
# are themselves audit events). Returns 0 on success / no-op,
|
|
124
|
+
# non-zero on input validation failure.
|
|
125
|
+
add_block() {
|
|
126
|
+
local hash="$1"
|
|
127
|
+
local evidence="$2"
|
|
128
|
+
local author="$3"
|
|
129
|
+
[ -n "$hash" ] || return 2
|
|
130
|
+
_block_list_validate_hash "$hash" || return 2
|
|
131
|
+
_block_list_ensure_file
|
|
132
|
+
if is_blocked "$hash"; then
|
|
133
|
+
# Idempotent — already present. Do not duplicate the list entry.
|
|
134
|
+
# Skip the audit entry too: per the "one entry per hash" idempotency
|
|
135
|
+
# contract the bats asserts, the audit log's add-record stays a
|
|
136
|
+
# single line for a single hash.
|
|
137
|
+
return 0
|
|
138
|
+
fi
|
|
139
|
+
if command -v jq >/dev/null 2>&1; then
|
|
140
|
+
local tmp
|
|
141
|
+
tmp=$(mktemp)
|
|
142
|
+
jq --arg h "$hash" '. + [$h]' "$BLOCK_LIST_FILE" > "$tmp" && mv "$tmp" "$BLOCK_LIST_FILE"
|
|
143
|
+
else
|
|
144
|
+
# Fallback: simple JSON-array textual edit. Strip trailing `]`,
|
|
145
|
+
# append `, "<hash>"]` or `"<hash>"]` for empty arrays.
|
|
146
|
+
local content
|
|
147
|
+
content=$(cat "$BLOCK_LIST_FILE")
|
|
148
|
+
if [[ "$content" =~ ^\[\ *\]\ *$ ]]; then
|
|
149
|
+
printf '["%s"]\n' "$hash" > "$BLOCK_LIST_FILE"
|
|
150
|
+
else
|
|
151
|
+
# Replace closing `]` with `,"<hash>"]`.
|
|
152
|
+
printf '%s' "$content" | sed "s/\]\s*\$/,\"$hash\"]/" > "$BLOCK_LIST_FILE"
|
|
153
|
+
printf '\n' >> "$BLOCK_LIST_FILE"
|
|
154
|
+
fi
|
|
155
|
+
fi
|
|
156
|
+
_block_list_audit_append "block" "$hash" "$evidence" "$author"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Remove a hash from the block list. Appends an `unblock` audit entry
|
|
160
|
+
# regardless of whether the hash was present (so attempted-unblock-of-
|
|
161
|
+
# never-blocked is itself audit-trail-recorded). Returns 0 on success,
|
|
162
|
+
# non-zero on input validation failure.
|
|
163
|
+
remove_block() {
|
|
164
|
+
local hash="$1"
|
|
165
|
+
local reason="$2"
|
|
166
|
+
[ -n "$hash" ] || return 2
|
|
167
|
+
_block_list_validate_hash "$hash" || return 2
|
|
168
|
+
_block_list_ensure_file
|
|
169
|
+
if command -v jq >/dev/null 2>&1; then
|
|
170
|
+
local tmp
|
|
171
|
+
tmp=$(mktemp)
|
|
172
|
+
jq --arg h "$hash" '. - [$h]' "$BLOCK_LIST_FILE" > "$tmp" && mv "$tmp" "$BLOCK_LIST_FILE"
|
|
173
|
+
else
|
|
174
|
+
# Fallback: textual remove of `"<hash>"` and any adjacent comma.
|
|
175
|
+
sed -i.bak -e "s/,\"$hash\"//g" -e "s/\"$hash\",//g" -e "s/\"$hash\"//g" "$BLOCK_LIST_FILE"
|
|
176
|
+
rm -f "${BLOCK_LIST_FILE}.bak"
|
|
177
|
+
fi
|
|
178
|
+
_block_list_audit_append "unblock" "$hash" "$reason" ""
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Print all currently-blocked hashes, one per line. Empty list prints
|
|
182
|
+
# nothing. Returns 0 always (empty is a valid state).
|
|
183
|
+
list_blocks() {
|
|
184
|
+
[ -f "$BLOCK_LIST_FILE" ] || return 0
|
|
185
|
+
if command -v jq >/dev/null 2>&1; then
|
|
186
|
+
jq -r '.[]' "$BLOCK_LIST_FILE" 2>/dev/null
|
|
187
|
+
else
|
|
188
|
+
# Fallback: extract `"<hash>"` substrings from the array.
|
|
189
|
+
grep -oE '"[0-9a-f]{64}"' "$BLOCK_LIST_FILE" | tr -d '"'
|
|
190
|
+
fi
|
|
191
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Shared gate logic for `.claude/` user-space write protection (P131).
|
|
3
|
+
#
|
|
4
|
+
# Sourced by itil-claude-space-protection.sh. Provides:
|
|
5
|
+
# is_protected_claude_path — returns 0 if path is project-scoped `.claude/`
|
|
6
|
+
# AND not in user-space allow-list
|
|
7
|
+
# has_approval_marker — returns 0 if user pre-authorized this path via
|
|
8
|
+
# `.claude/.agent-write-approved-<sha256>` marker
|
|
9
|
+
# claude_space_deny — emits PreToolUse deny JSON
|
|
10
|
+
#
|
|
11
|
+
# Why this is a separate gate semantic from review-gate.sh / create-gate.sh:
|
|
12
|
+
# review-gate.sh enforces per-session "policy was reviewed" markers with TTL.
|
|
13
|
+
# create-gate.sh enforces per-session "duplicate-check ran" markers.
|
|
14
|
+
# This gate enforces a PERSISTENT "user pre-approved this specific path"
|
|
15
|
+
# marker — different lifecycle (no TTL, no drift, never auto-cleared) and
|
|
16
|
+
# different scope (file-path-keyed, not session-keyed). Architect verdict
|
|
17
|
+
# (P131 Phase 2): keep semantically distinct from existing gate libraries.
|
|
18
|
+
#
|
|
19
|
+
# Marker convention: `.claude/.agent-write-approved-<sha256-of-rel-path>`
|
|
20
|
+
# where `<sha256-of-rel-path>` is the SHA-256 hex of the path relative to
|
|
21
|
+
# project root (PWD). Persistent, in-tree, file-existence test — same
|
|
22
|
+
# shape as ADR-030 / P120 `.claude/.install-updates-consent` precedent.
|
|
23
|
+
#
|
|
24
|
+
# Allow-list scope: paths under `.claude/` that are user-controlled by
|
|
25
|
+
# convention (Claude Code config, user-authored extensions, system state):
|
|
26
|
+
# - settings.json, settings.local.json, *.local.json (root-depth only)
|
|
27
|
+
# - MEMORY.md
|
|
28
|
+
# - .install-updates-consent, scheduled_tasks.lock
|
|
29
|
+
# - skills/, commands/, agents/, hooks/, projects/, worktrees/ subtrees
|
|
30
|
+
# - .agent-write-approved-* markers themselves (so user can create them)
|
|
31
|
+
#
|
|
32
|
+
# References:
|
|
33
|
+
# ADR-009 — gate marker lifecycle (this gate adds a NEW persistent
|
|
34
|
+
# marker class; the ADR's session-scoped /tmp markers are
|
|
35
|
+
# unchanged)
|
|
36
|
+
# ADR-013 Rule 6 — non-interactive fail-safe (parse-error => exit 0)
|
|
37
|
+
# ADR-030 — install-updates persistent in-tree consent marker precedent
|
|
38
|
+
# ADR-038 — progressive disclosure (deny message <500 bytes)
|
|
39
|
+
# ADR-045 — hook injection budget (Pattern 1 silent-on-pass; allow path
|
|
40
|
+
# emits zero bytes)
|
|
41
|
+
# P119 — manage-problem-enforce-create.sh precedent for itil
|
|
42
|
+
# PreToolUse:Write enforcement hook shape
|
|
43
|
+
# P120 — install-updates consent-marker persistence precedent
|
|
44
|
+
# P131 — this gate's driver
|
|
45
|
+
|
|
46
|
+
# Returns 0 (true) if FILE_PATH is a project-scoped .claude/ path AND is
|
|
47
|
+
# NOT in the user-space allow-list. Returns 1 (false) otherwise.
|
|
48
|
+
#
|
|
49
|
+
# Project scope: FILE_PATH must be under PWD (the project root). Paths
|
|
50
|
+
# under ~/.claude/, /Users/.../.claude/projects/, or any .claude/
|
|
51
|
+
# directory outside PWD are NOT project-scoped — those are user-home
|
|
52
|
+
# config or other repos and out of scope for this gate.
|
|
53
|
+
#
|
|
54
|
+
# Usage: if is_protected_claude_path "$FILE_PATH" "$PWD"; then ...; fi
|
|
55
|
+
is_protected_claude_path() {
|
|
56
|
+
local file_path="$1"
|
|
57
|
+
local pwd_root="$2"
|
|
58
|
+
|
|
59
|
+
[ -n "$file_path" ] || return 1
|
|
60
|
+
[ -n "$pwd_root" ] || return 1
|
|
61
|
+
|
|
62
|
+
# Resolve to a path relative to project root if absolute. Hook callers
|
|
63
|
+
# pass absolute paths in tool_input.file_path; tests may pass relative.
|
|
64
|
+
local rel_path
|
|
65
|
+
case "$file_path" in
|
|
66
|
+
"$pwd_root"/*)
|
|
67
|
+
rel_path="${file_path#"$pwd_root"/}"
|
|
68
|
+
;;
|
|
69
|
+
/*)
|
|
70
|
+
# Absolute path outside project root — not project-scoped.
|
|
71
|
+
return 1
|
|
72
|
+
;;
|
|
73
|
+
*)
|
|
74
|
+
# Relative path — assume relative to PWD.
|
|
75
|
+
rel_path="$file_path"
|
|
76
|
+
;;
|
|
77
|
+
esac
|
|
78
|
+
|
|
79
|
+
# Strip leading ./ if present
|
|
80
|
+
rel_path="${rel_path#./}"
|
|
81
|
+
|
|
82
|
+
# Must be under .claude/ at project-relative root depth.
|
|
83
|
+
case "$rel_path" in
|
|
84
|
+
.claude/*) ;;
|
|
85
|
+
*) return 1 ;;
|
|
86
|
+
esac
|
|
87
|
+
|
|
88
|
+
# User-space allow-list. Match against rel_path only (project-relative).
|
|
89
|
+
# Anchor patterns to avoid accidental allows at deeper paths.
|
|
90
|
+
case "$rel_path" in
|
|
91
|
+
# Root-level config files
|
|
92
|
+
.claude/settings.json) return 1 ;;
|
|
93
|
+
.claude/settings.local.json) return 1 ;;
|
|
94
|
+
.claude/MEMORY.md) return 1 ;;
|
|
95
|
+
.claude/.install-updates-consent) return 1 ;;
|
|
96
|
+
.claude/scheduled_tasks.lock) return 1 ;;
|
|
97
|
+
# Approval markers themselves (so user can create them via Write)
|
|
98
|
+
.claude/.agent-write-approved-*) return 1 ;;
|
|
99
|
+
# Root-depth *.local.json (Claude Code convention for local overrides).
|
|
100
|
+
# Must NOT extend to .claude/<subdir>/foo.local.json — anchor to one
|
|
101
|
+
# path segment after .claude/.
|
|
102
|
+
.claude/*.local.json)
|
|
103
|
+
case "$rel_path" in
|
|
104
|
+
.claude/*/*.local.json) ;;
|
|
105
|
+
*) return 1 ;;
|
|
106
|
+
esac
|
|
107
|
+
;;
|
|
108
|
+
# User-extension subtrees: skills, commands, agents, hooks
|
|
109
|
+
# (Claude Code conventions). Symlinks under skills/ are common.
|
|
110
|
+
.claude/skills/*) return 1 ;;
|
|
111
|
+
.claude/commands/*) return 1 ;;
|
|
112
|
+
.claude/agents/*) return 1 ;;
|
|
113
|
+
.claude/hooks/*) return 1 ;;
|
|
114
|
+
# Claude Code's own state: projects/<id>/memory/, worktrees/
|
|
115
|
+
.claude/projects/*) return 1 ;;
|
|
116
|
+
.claude/worktrees/*) return 1 ;;
|
|
117
|
+
esac
|
|
118
|
+
|
|
119
|
+
# Project-scoped .claude/ path NOT on the allow-list — protected.
|
|
120
|
+
return 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Returns 0 (true) if user has pre-authorized writes to FILE_PATH via an
|
|
124
|
+
# `.claude/.agent-write-approved-<sha256-of-rel-path>` marker file. The
|
|
125
|
+
# hash keys on the path relative to project root so the marker is portable
|
|
126
|
+
# across machines using the same project tree.
|
|
127
|
+
#
|
|
128
|
+
# Usage: if has_approval_marker "$FILE_PATH" "$PWD"; then ...; fi
|
|
129
|
+
has_approval_marker() {
|
|
130
|
+
local file_path="$1"
|
|
131
|
+
local pwd_root="$2"
|
|
132
|
+
|
|
133
|
+
[ -n "$file_path" ] || return 1
|
|
134
|
+
[ -n "$pwd_root" ] || return 1
|
|
135
|
+
|
|
136
|
+
local rel_path
|
|
137
|
+
case "$file_path" in
|
|
138
|
+
"$pwd_root"/*) rel_path="${file_path#"$pwd_root"/}" ;;
|
|
139
|
+
/*) return 1 ;;
|
|
140
|
+
*) rel_path="$file_path" ;;
|
|
141
|
+
esac
|
|
142
|
+
rel_path="${rel_path#./}"
|
|
143
|
+
|
|
144
|
+
# SHA-256 of the project-relative path. Use shasum (BSD) or sha256sum
|
|
145
|
+
# (GNU) — fall back gracefully.
|
|
146
|
+
local hash
|
|
147
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
148
|
+
hash=$(printf '%s' "$rel_path" | shasum -a 256 | awk '{print $1}')
|
|
149
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
150
|
+
hash=$(printf '%s' "$rel_path" | sha256sum | awk '{print $1}')
|
|
151
|
+
else
|
|
152
|
+
# No hash tool available — treat as no marker (fail-closed for the
|
|
153
|
+
# bypass; hook will emit deny). Better to require explicit user
|
|
154
|
+
# action than to silently allow.
|
|
155
|
+
return 1
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
[ -n "$hash" ] || return 1
|
|
159
|
+
[ -f "${pwd_root}/.claude/.agent-write-approved-${hash}" ]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Emit fail-closed PreToolUse deny JSON. Reason should be terse (<500
|
|
163
|
+
# bytes per ADR-038). Hook callers compose the basename + reason inline.
|
|
164
|
+
#
|
|
165
|
+
# Usage: claude_space_deny "BLOCKED: <reason>"
|
|
166
|
+
claude_space_deny() {
|
|
167
|
+
local reason="$1"
|
|
168
|
+
cat <<EOF
|
|
169
|
+
{
|
|
170
|
+
"hookSpecificOutput": {
|
|
171
|
+
"hookEventName": "PreToolUse",
|
|
172
|
+
"permissionDecision": "deny",
|
|
173
|
+
"permissionDecisionReason": "$reason"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
EOF
|
|
177
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P123: packages/itil/hooks/lib/block-list.sh shared helper for the
|
|
4
|
+
# inbound-report block-list mechanism. Per ADR-046 §v1 implementation
|
|
5
|
+
# contract — audit-log-only — the helper exposes four functions:
|
|
6
|
+
# is_blocked(<reporter-id-hash>)
|
|
7
|
+
# add_block(<reporter-id-hash> <evidence-ticket-P###> <provenance>)
|
|
8
|
+
# remove_block(<reporter-id-hash> <reason>)
|
|
9
|
+
# list_blocks()
|
|
10
|
+
#
|
|
11
|
+
# Per ADR-046 §Decision Outcome §Identifier, the entry shape is the
|
|
12
|
+
# SHA-256 hash of the GitHub numeric user ID. Per architect verdict
|
|
13
|
+
# (this iter), the helper does NOT compute the hash — caller supplies
|
|
14
|
+
# an opaque hex string; helper validates hex shape only. This keeps
|
|
15
|
+
# the helper GitHub-agnostic so non-GitHub channel adoption (out-of-
|
|
16
|
+
# scope per ADR-046 §Reassessment) wouldn't require helper changes.
|
|
17
|
+
#
|
|
18
|
+
# Audit log: sibling JSONL file `docs/blocked-reporters.audit.jsonl`.
|
|
19
|
+
# Append-only; one JSON object per line per the five-field shape
|
|
20
|
+
# adopted in ADR-046 Q2: {type, reporter_id_hash, evidence_ticket,
|
|
21
|
+
# timestamp, author}.
|
|
22
|
+
#
|
|
23
|
+
# Per feedback_behavioural_tests.md (P081) — behavioural assertions on
|
|
24
|
+
# observable outcomes (file state, exit codes, helper-emitted output).
|
|
25
|
+
# No source-grep on helper text. Per ADR-005 (plugin testing strategy)
|
|
26
|
+
# + ADR-037 (skill testing strategy) hook bats live under
|
|
27
|
+
# packages/itil/hooks/test/ and assert behaviour, not implementation.
|
|
28
|
+
|
|
29
|
+
setup() {
|
|
30
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
31
|
+
HELPER="$SCRIPT_DIR/lib/block-list.sh"
|
|
32
|
+
ORIG_DIR="$PWD"
|
|
33
|
+
TEST_DIR=$(mktemp -d)
|
|
34
|
+
cd "$TEST_DIR"
|
|
35
|
+
mkdir -p docs
|
|
36
|
+
# Mirror the per-repo on-disk shape ADR-046 names.
|
|
37
|
+
echo "[]" > docs/blocked-reporters.json
|
|
38
|
+
# Source the helper. Functions become callable in this shell.
|
|
39
|
+
# shellcheck source=/dev/null
|
|
40
|
+
source "$HELPER"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
teardown() {
|
|
44
|
+
cd "$ORIG_DIR"
|
|
45
|
+
rm -rf "$TEST_DIR"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Canonical fixture hash — 64 hex chars (SHA-256 width). Real callers
|
|
49
|
+
# would compute this from a GitHub numeric user ID; the helper does
|
|
50
|
+
# not care about provenance, only that the input is hex-shaped.
|
|
51
|
+
HASH_A="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
52
|
+
HASH_B="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
53
|
+
|
|
54
|
+
# --- Round-trip: add_block then is_blocked ---
|
|
55
|
+
|
|
56
|
+
@test "add_block + is_blocked round-trip: hash present after add" {
|
|
57
|
+
add_block "$HASH_A" "P123" "test@example.com"
|
|
58
|
+
run is_blocked "$HASH_A"
|
|
59
|
+
[ "$status" -eq 0 ]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@test "is_blocked: returns non-zero for hash never added" {
|
|
63
|
+
run is_blocked "$HASH_B"
|
|
64
|
+
[ "$status" -ne 0 ]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# --- list_blocks output shape ---
|
|
68
|
+
|
|
69
|
+
@test "list_blocks: prints each blocked hash on its own line" {
|
|
70
|
+
add_block "$HASH_A" "P123" "test@example.com"
|
|
71
|
+
add_block "$HASH_B" "P124" "test@example.com"
|
|
72
|
+
run list_blocks
|
|
73
|
+
[ "$status" -eq 0 ]
|
|
74
|
+
[[ "$output" == *"$HASH_A"* ]]
|
|
75
|
+
[[ "$output" == *"$HASH_B"* ]]
|
|
76
|
+
# Two distinct hashes — output should have at least two non-empty lines.
|
|
77
|
+
line_count=$(printf '%s\n' "$output" | grep -c .)
|
|
78
|
+
[ "$line_count" -ge 2 ]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@test "list_blocks: empty list on empty docs/blocked-reporters.json" {
|
|
82
|
+
run list_blocks
|
|
83
|
+
[ "$status" -eq 0 ]
|
|
84
|
+
# No hash strings; either empty output or whitespace only.
|
|
85
|
+
[[ "$output" != *"$HASH_A"* ]]
|
|
86
|
+
[[ "$output" != *"$HASH_B"* ]]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# --- Idempotent add ---
|
|
90
|
+
|
|
91
|
+
@test "add_block: adding the same hash twice is idempotent (one entry)" {
|
|
92
|
+
add_block "$HASH_A" "P123" "test@example.com"
|
|
93
|
+
add_block "$HASH_A" "P123" "test@example.com"
|
|
94
|
+
run list_blocks
|
|
95
|
+
[ "$status" -eq 0 ]
|
|
96
|
+
occurrences=$(printf '%s\n' "$output" | grep -c "$HASH_A")
|
|
97
|
+
[ "$occurrences" -eq 1 ]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# --- remove_block path ---
|
|
101
|
+
|
|
102
|
+
@test "remove_block: hash absent from is_blocked after remove" {
|
|
103
|
+
add_block "$HASH_A" "P123" "test@example.com"
|
|
104
|
+
remove_block "$HASH_A" "wrongly-classified"
|
|
105
|
+
run is_blocked "$HASH_A"
|
|
106
|
+
[ "$status" -ne 0 ]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# --- Audit log presence (ADR-046 Q2 five-field shape) ---
|
|
110
|
+
|
|
111
|
+
@test "add_block: appends entry to docs/blocked-reporters.audit.jsonl" {
|
|
112
|
+
add_block "$HASH_A" "P123" "test@example.com"
|
|
113
|
+
[ -f docs/blocked-reporters.audit.jsonl ]
|
|
114
|
+
run cat docs/blocked-reporters.audit.jsonl
|
|
115
|
+
[[ "$output" == *"\"type\""* ]]
|
|
116
|
+
[[ "$output" == *"\"block\""* ]]
|
|
117
|
+
[[ "$output" == *"$HASH_A"* ]]
|
|
118
|
+
[[ "$output" == *"P123"* ]]
|
|
119
|
+
[[ "$output" == *"test@example.com"* ]]
|
|
120
|
+
# Five-field shape names: type, reporter_id_hash, evidence_ticket,
|
|
121
|
+
# timestamp, author. Assert each label is present.
|
|
122
|
+
[[ "$output" == *"reporter_id_hash"* ]]
|
|
123
|
+
[[ "$output" == *"evidence_ticket"* ]]
|
|
124
|
+
[[ "$output" == *"timestamp"* ]]
|
|
125
|
+
[[ "$output" == *"author"* ]]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@test "remove_block: appends an unblock entry to the audit log" {
|
|
129
|
+
add_block "$HASH_A" "P123" "test@example.com"
|
|
130
|
+
remove_block "$HASH_A" "wrongly-classified"
|
|
131
|
+
run cat docs/blocked-reporters.audit.jsonl
|
|
132
|
+
[[ "$output" == *"\"unblock\""* ]]
|
|
133
|
+
# The reason field rides under evidence_ticket per the five-field shape
|
|
134
|
+
# (audit log is type-tagged; the reason slot reuses evidence_ticket
|
|
135
|
+
# for unblock provenance per the helper's contract).
|
|
136
|
+
[[ "$output" == *"wrongly-classified"* ]]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# --- Hashed-ID handling: helper validates hex shape ---
|
|
140
|
+
|
|
141
|
+
@test "add_block: rejects non-hex input (non-zero exit, no entry written)" {
|
|
142
|
+
run add_block "not-a-hash" "P123" "test@example.com"
|
|
143
|
+
[ "$status" -ne 0 ]
|
|
144
|
+
# State unchanged.
|
|
145
|
+
run is_blocked "not-a-hash"
|
|
146
|
+
[ "$status" -ne 0 ]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@test "add_block: rejects wrong-length hex (non-SHA-256-width input)" {
|
|
150
|
+
# 32 hex chars (half of SHA-256 width) — wrong length.
|
|
151
|
+
run add_block "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "P123" "test@example.com"
|
|
152
|
+
[ "$status" -ne 0 ]
|
|
153
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P131: itil-claude-space-protection.sh PreToolUse:Write|Edit hook must
|
|
4
|
+
# block agent writes to project-scoped `.claude/` paths NOT in the
|
|
5
|
+
# user-space allow-list, unless an approval marker is present.
|
|
6
|
+
#
|
|
7
|
+
# Per ADR-037 + P081 (feedback_behavioural_tests.md): behavioural
|
|
8
|
+
# assertions — simulate the hook's payload on stdin and assert on
|
|
9
|
+
# emitted JSON permissionDecision and exit status. No source-grep on
|
|
10
|
+
# hook content.
|
|
11
|
+
#
|
|
12
|
+
# References:
|
|
13
|
+
# ADR-009 — marker lifecycle (this hook adds a new persistent class)
|
|
14
|
+
# ADR-013 Rule 6 — non-interactive fail-safe
|
|
15
|
+
# ADR-038 — progressive disclosure (deny message <500 bytes)
|
|
16
|
+
# ADR-045 — hook injection budget (silent on allow path)
|
|
17
|
+
# P131 — user-space write protection driver
|
|
18
|
+
|
|
19
|
+
setup() {
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
21
|
+
HOOK="$SCRIPT_DIR/itil-claude-space-protection.sh"
|
|
22
|
+
ORIG_DIR="$PWD"
|
|
23
|
+
TEST_DIR=$(mktemp -d)
|
|
24
|
+
cd "$TEST_DIR"
|
|
25
|
+
mkdir -p .claude/skills/myskill .claude/commands .claude/agents \
|
|
26
|
+
.claude/hooks .claude/projects/abc/memory .claude/worktrees \
|
|
27
|
+
docs/plans
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
teardown() {
|
|
31
|
+
cd "$ORIG_DIR"
|
|
32
|
+
rm -rf "$TEST_DIR"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
run_hook() {
|
|
36
|
+
local tool="$1"
|
|
37
|
+
local file_path="$2"
|
|
38
|
+
local json
|
|
39
|
+
json=$(printf '{"tool_name":"%s","tool_input":{"file_path":"%s"},"session_id":"test-sid"}' \
|
|
40
|
+
"$tool" "$file_path")
|
|
41
|
+
echo "$json" | bash "$HOOK"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Helper: compute approval-marker filename for a project-relative path
|
|
45
|
+
marker_path_for() {
|
|
46
|
+
local rel_path="$1"
|
|
47
|
+
local hash
|
|
48
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
49
|
+
hash=$(printf '%s' "$rel_path" | shasum -a 256 | awk '{print $1}')
|
|
50
|
+
else
|
|
51
|
+
hash=$(printf '%s' "$rel_path" | sha256sum | awk '{print $1}')
|
|
52
|
+
fi
|
|
53
|
+
echo ".claude/.agent-write-approved-${hash}"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# --- Core deny path: protected .claude/ writes without marker ---
|
|
57
|
+
|
|
58
|
+
@test "deny: Write to .claude/plans/foo.md without marker" {
|
|
59
|
+
run run_hook "Write" "$PWD/.claude/plans/foo.md"
|
|
60
|
+
[ "$status" -eq 0 ]
|
|
61
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
62
|
+
[[ "$output" == *"BLOCKED"* ]]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@test "deny: Write to .claude/audits/2026-04-28.md without marker" {
|
|
66
|
+
run run_hook "Write" "$PWD/.claude/audits/2026-04-28.md"
|
|
67
|
+
[ "$status" -eq 0 ]
|
|
68
|
+
[[ "$output" == *"BLOCKED"* ]]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@test "deny: Edit to existing agent-introduced .claude/plans file" {
|
|
72
|
+
mkdir -p .claude/plans
|
|
73
|
+
echo "stub" > .claude/plans/foo.md
|
|
74
|
+
run run_hook "Edit" "$PWD/.claude/plans/foo.md"
|
|
75
|
+
[ "$status" -eq 0 ]
|
|
76
|
+
[[ "$output" == *"BLOCKED"* ]]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@test "deny: Write to .claude/scratch/state.json without marker" {
|
|
80
|
+
run run_hook "Write" "$PWD/.claude/scratch/state.json"
|
|
81
|
+
[ "$status" -eq 0 ]
|
|
82
|
+
[[ "$output" == *"BLOCKED"* ]]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# --- Allow paths: user-space allow-list ---
|
|
86
|
+
|
|
87
|
+
@test "allow: Write to .claude/settings.json" {
|
|
88
|
+
run run_hook "Write" "$PWD/.claude/settings.json"
|
|
89
|
+
[ "$status" -eq 0 ]
|
|
90
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
91
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@test "allow: Write to .claude/settings.local.json" {
|
|
95
|
+
run run_hook "Write" "$PWD/.claude/settings.local.json"
|
|
96
|
+
[ "$status" -eq 0 ]
|
|
97
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@test "allow: Write to .claude/MEMORY.md" {
|
|
101
|
+
run run_hook "Write" "$PWD/.claude/MEMORY.md"
|
|
102
|
+
[ "$status" -eq 0 ]
|
|
103
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@test "allow: Write to .claude/.install-updates-consent" {
|
|
107
|
+
run run_hook "Write" "$PWD/.claude/.install-updates-consent"
|
|
108
|
+
[ "$status" -eq 0 ]
|
|
109
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@test "allow: Write to .claude/scheduled_tasks.lock" {
|
|
113
|
+
run run_hook "Write" "$PWD/.claude/scheduled_tasks.lock"
|
|
114
|
+
[ "$status" -eq 0 ]
|
|
115
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@test "allow: Write to .claude/skills/install-updates/SKILL.md (skills subtree)" {
|
|
119
|
+
run run_hook "Write" "$PWD/.claude/skills/install-updates/SKILL.md"
|
|
120
|
+
[ "$status" -eq 0 ]
|
|
121
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@test "allow: Write to .claude/commands/foo.md (commands subtree)" {
|
|
125
|
+
run run_hook "Write" "$PWD/.claude/commands/foo.md"
|
|
126
|
+
[ "$status" -eq 0 ]
|
|
127
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@test "allow: Write to .claude/agents/foo.md (agents subtree)" {
|
|
131
|
+
run run_hook "Write" "$PWD/.claude/agents/foo.md"
|
|
132
|
+
[ "$status" -eq 0 ]
|
|
133
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@test "allow: Write to .claude/hooks/foo.sh (hooks subtree)" {
|
|
137
|
+
run run_hook "Write" "$PWD/.claude/hooks/foo.sh"
|
|
138
|
+
[ "$status" -eq 0 ]
|
|
139
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@test "allow: Write to .claude/projects/abc/memory/MEMORY.md (Claude Code state)" {
|
|
143
|
+
run run_hook "Write" "$PWD/.claude/projects/abc/memory/MEMORY.md"
|
|
144
|
+
[ "$status" -eq 0 ]
|
|
145
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@test "allow: Write to .claude/worktrees/something (worktrees subtree)" {
|
|
149
|
+
run run_hook "Write" "$PWD/.claude/worktrees/something"
|
|
150
|
+
[ "$status" -eq 0 ]
|
|
151
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# --- Allow paths: outside .claude/ ---
|
|
155
|
+
|
|
156
|
+
@test "allow: Write to docs/plans/foo.md (not under .claude/)" {
|
|
157
|
+
run run_hook "Write" "$PWD/docs/plans/foo.md"
|
|
158
|
+
[ "$status" -eq 0 ]
|
|
159
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@test "allow: Write to packages/itil/hooks/foo.sh" {
|
|
163
|
+
mkdir -p packages/itil/hooks
|
|
164
|
+
run run_hook "Write" "$PWD/packages/itil/hooks/foo.sh"
|
|
165
|
+
[ "$status" -eq 0 ]
|
|
166
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@test "allow: Write to absolute path outside PWD project root" {
|
|
170
|
+
run run_hook "Write" "/Users/someone/.claude/plans/foo.md"
|
|
171
|
+
[ "$status" -eq 0 ]
|
|
172
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@test "allow: Write to ~/.claude path (user home, outside project)" {
|
|
176
|
+
run run_hook "Write" "$HOME/.claude/projects/xyz/foo.md"
|
|
177
|
+
[ "$status" -eq 0 ]
|
|
178
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# --- Approval-marker bypass ---
|
|
182
|
+
|
|
183
|
+
@test "allow: Write to .claude/plans/foo.md WHEN approval marker exists" {
|
|
184
|
+
marker=$(marker_path_for ".claude/plans/foo.md")
|
|
185
|
+
: > "$marker"
|
|
186
|
+
run run_hook "Write" "$PWD/.claude/plans/foo.md"
|
|
187
|
+
[ "$status" -eq 0 ]
|
|
188
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@test "deny: marker for one path does not authorize a different path" {
|
|
192
|
+
marker=$(marker_path_for ".claude/plans/foo.md")
|
|
193
|
+
: > "$marker"
|
|
194
|
+
run run_hook "Write" "$PWD/.claude/plans/bar.md"
|
|
195
|
+
[ "$status" -eq 0 ]
|
|
196
|
+
[[ "$output" == *"BLOCKED"* ]]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@test "allow: user can Write the approval marker itself" {
|
|
200
|
+
marker=$(marker_path_for ".claude/plans/foo.md")
|
|
201
|
+
run run_hook "Write" "$PWD/$marker"
|
|
202
|
+
[ "$status" -eq 0 ]
|
|
203
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# --- Tool-name and edge cases ---
|
|
207
|
+
|
|
208
|
+
@test "allow: Read tool on protected .claude/ path is unaffected" {
|
|
209
|
+
mkdir -p .claude/plans
|
|
210
|
+
echo "stub" > .claude/plans/foo.md
|
|
211
|
+
run bash -c "echo '{\"tool_name\":\"Read\",\"tool_input\":{\"file_path\":\"$PWD/.claude/plans/foo.md\"}}' | bash $HOOK"
|
|
212
|
+
[ "$status" -eq 0 ]
|
|
213
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
@test "allow: Glob tool on .claude/ is unaffected (pattern, not file_path)" {
|
|
217
|
+
run bash -c "echo '{\"tool_name\":\"Glob\",\"tool_input\":{\"pattern\":\".claude/**/*.md\"}}' | bash $HOOK"
|
|
218
|
+
[ "$status" -eq 0 ]
|
|
219
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@test "allow: empty file_path exits 0 without action" {
|
|
223
|
+
run bash -c "echo '{\"tool_name\":\"Write\",\"tool_input\":{}}' | bash $HOOK"
|
|
224
|
+
[ "$status" -eq 0 ]
|
|
225
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# --- Allow-list anchor depth (architect note) ---
|
|
229
|
+
|
|
230
|
+
@test "deny: .claude/plans/foo.local.json (deeper than root) — *.local.json must not pass at arbitrary depth" {
|
|
231
|
+
run run_hook "Write" "$PWD/.claude/plans/foo.local.json"
|
|
232
|
+
[ "$status" -eq 0 ]
|
|
233
|
+
[[ "$output" == *"BLOCKED"* ]]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@test "allow: .claude/foo.local.json (root depth) — *.local.json convention" {
|
|
237
|
+
run run_hook "Write" "$PWD/.claude/foo.local.json"
|
|
238
|
+
[ "$status" -eq 0 ]
|
|
239
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# --- Deny message contract (ADR-038 progressive disclosure) ---
|
|
243
|
+
|
|
244
|
+
@test "deny message names P131" {
|
|
245
|
+
run run_hook "Write" "$PWD/.claude/plans/foo.md"
|
|
246
|
+
[[ "$output" == *"P131"* ]]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@test "deny message suggests docs/ alternative" {
|
|
250
|
+
run run_hook "Write" "$PWD/.claude/plans/foo.md"
|
|
251
|
+
[[ "$output" == *"docs/"* ]]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@test "deny message names the approval-marker bypass" {
|
|
255
|
+
run run_hook "Write" "$PWD/.claude/plans/foo.md"
|
|
256
|
+
[[ "$output" == *".agent-write-approved-"* ]]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@test "deny message references project CLAUDE.md MANDATORY rule" {
|
|
260
|
+
run run_hook "Write" "$PWD/.claude/plans/foo.md"
|
|
261
|
+
[[ "$output" == *"CLAUDE.md"* ]]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
@test "deny message stays under ADR-038 progressive-disclosure 500-byte cap" {
|
|
265
|
+
run run_hook "Write" "$PWD/.claude/plans/foo.md"
|
|
266
|
+
# Extract the permissionDecisionReason value and check its byte length.
|
|
267
|
+
reason=$(echo "$output" | python3 -c "
|
|
268
|
+
import sys, json
|
|
269
|
+
try:
|
|
270
|
+
data = json.load(sys.stdin)
|
|
271
|
+
print(data['hookSpecificOutput']['permissionDecisionReason'])
|
|
272
|
+
except Exception as e:
|
|
273
|
+
print('')
|
|
274
|
+
")
|
|
275
|
+
byte_len=$(printf '%s' "$reason" | wc -c)
|
|
276
|
+
# Bound: the deny message must be discoverable + actionable but
|
|
277
|
+
# progressive — < 500 bytes per ADR-038.
|
|
278
|
+
[ "$byte_len" -lt 500 ]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# --- ADR-045 silent-on-pass: allow path emits zero bytes ---
|
|
282
|
+
|
|
283
|
+
@test "allow path emits no output (ADR-045 Pattern 1 silent-on-pass)" {
|
|
284
|
+
run run_hook "Write" "$PWD/.claude/settings.json"
|
|
285
|
+
[ "$status" -eq 0 ]
|
|
286
|
+
# Empty stdout — silent on allow.
|
|
287
|
+
[ -z "$output" ]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@test "non-Write|Edit tool emits no output (silent-on-pass)" {
|
|
291
|
+
run bash -c "echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"ls\"}}' | bash $HOOK"
|
|
292
|
+
[ "$status" -eq 0 ]
|
|
293
|
+
[ -z "$output" ]
|
|
294
|
+
}
|
package/package.json
CHANGED
|
@@ -407,11 +407,34 @@ After writing the new `.open.md` file, regenerate `docs/problems/README.md` to i
|
|
|
407
407
|
**WSJF Rankings tie-break sort (P138)**: rows in the WSJF Rankings table are sorted by the multi-key `(WSJF desc, Known-Error-first, Effort-divisor asc, Reported-date asc, ID asc)` so the rendered top-to-bottom row order matches `/wr-itil:work-problems` SKILL.md Step 3's tie-break selection 1:1. The first key (WSJF desc) sets the tier; within a tier the next three keys are the canonical tie-break ladder (Known Error before Open; smaller effort before larger; older Reported date before newer); ID asc is the deterministic final tiebreaker for full-tie cases. The table MUST include a `Reported` column so the third tie-break input is visible to README readers — without it, users cannot reconcile the rendered order against the orchestrator's selection. <!-- TIE-BREAK-LADDER-SOURCE: /wr-itil:work-problems SKILL.md Step 3 --> Any future change to the tie-break ladder MUST update this render block, the Step 7 P062 block, the Step 9e template, AND `/wr-itil:review-problems` SKILL.md Step 3 / Step 5 — drift here re-opens P138.
|
|
408
408
|
|
|
409
409
|
1. After `Write`-ing the new `.open.md` file (and, for multi-concern splits per step 4b, after all split files are written), regenerate `docs/problems/README.md` in-place reflecting the new filename set.
|
|
410
|
-
2. Update the "Last reviewed" line
|
|
411
|
-
3. `git add docs/problems/README.md` — the stage list at Step 11 must include it alongside the new `.open.md` file (Step 11's `git add -u` catch-all handles tracked-file modifications; the new README render lands via this path when README.md already exists in git, and via an explicit `git add docs/problems/README.md` when it is newly created).
|
|
410
|
+
2. Update the "Last reviewed" line per the **Last-reviewed line discipline (P134)** subsection below — name the new ticket as the most-recent fragment (e.g. `P<NNN> opened — <one-line title>`); displaced prior fragments rotate to `docs/problems/README-history.md`.
|
|
411
|
+
3. `git add docs/problems/README.md` — the stage list at Step 11 must include it alongside the new `.open.md` file (Step 11's `git add -u` catch-all handles tracked-file modifications; the new README render lands via this path when README.md already exists in git, and via an explicit `git add docs/problems/README.md` when it is newly created). When line-3 truncation displaces prior content, also `git add docs/problems/README-history.md`.
|
|
412
412
|
|
|
413
413
|
For the multi-concern split path (step 4b), the refresh fires **once** after all split tickets are written, not per-split — a single render captures the full new set in one pass.
|
|
414
414
|
|
|
415
|
+
#### Last-reviewed line discipline (P134)
|
|
416
|
+
|
|
417
|
+
The "Last reviewed" line (line 3 of `docs/problems/README.md`) was designed as a short audit marker — one ticket name + one transition reason — but historically accumulated multi-paragraph session-summary fragments unbounded ("Prior:" stacking on every refresh). At ~62 KB / 76 KB it crossed the Read-tool 25K-token whole-file limit and could no longer be window-read at any offset/limit. P134 closes the accumulator on this surface; sibling to P099 on `docs/briefing/<topic>.md`.
|
|
418
|
+
|
|
419
|
+
**Contract** — applies to every refresh that touches line 3 (Step 5 P094 creation, Step 6 P094 conditional update, Step 7 P062 transition; mirrored in `transition-problem`, `transition-problems`, `review-problems`, `reconcile-readme`):
|
|
420
|
+
|
|
421
|
+
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.
|
|
422
|
+
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.
|
|
423
|
+
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).
|
|
424
|
+
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).
|
|
425
|
+
|
|
426
|
+
**Mechanism** (when authoring a refresh):
|
|
427
|
+
|
|
428
|
+
1. Read the current line 3 of the README (e.g. `awk 'NR==3' docs/problems/README.md`).
|
|
429
|
+
2. If the current line 3 is non-empty AND the new fragment is not a near-duplicate (same ticket + same verb in the same session): append the current line 3 verbatim to `docs/problems/README-history.md` under a `## YYYY-MM-DD` heading (creating the heading on first append for that date; subsequent same-day appends nest under the existing heading).
|
|
430
|
+
3. Compose the new line 3 as a single paragraph naming the operation only. Keep ≤ 1024 bytes.
|
|
431
|
+
4. Replace line 3 of README.md with the new paragraph.
|
|
432
|
+
5. Stage both files in the same commit as the ticket change per ADR-014: `git add docs/problems/README.md docs/problems/README-history.md`.
|
|
433
|
+
|
|
434
|
+
**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.
|
|
435
|
+
|
|
436
|
+
**Cross-references**: ADR-040 line 92 (reusable accumulator-doc pattern — explicitly names "problems index"), ADR-038 (progressive disclosure), ADR-014 (single-commit governance), `packages/itil/scripts/check-problems-readme-budget.sh`, `packages/itil/scripts/test/check-problems-readme-budget.bats`.
|
|
437
|
+
|
|
415
438
|
### 6. For updates: Edit the existing file
|
|
416
439
|
|
|
417
440
|
Find the file matching the problem ID:
|
|
@@ -441,8 +464,8 @@ If the edit touched only `## Root Cause Analysis`, `## Symptoms`, `## Workaround
|
|
|
441
464
|
**Mechanism** (when the trigger fires):
|
|
442
465
|
|
|
443
466
|
1. Regenerate `docs/problems/README.md` using the same render rules as Step 7's P062 block — render, not re-rank. Trust every other ticket's stored WSJF; consume only this ticket's updated WSJF from the post-edit file.
|
|
444
|
-
2. Update the "Last reviewed" line
|
|
445
|
-
3. `git add docs/problems/README.md` so the refresh rides the same commit as the ticket update per ADR-014.
|
|
467
|
+
2. Update the "Last reviewed" line per the **Last-reviewed line discipline (P134)** subsection in Step 5 above — name the re-rated ticket as the most-recent fragment (e.g. `P<NNN> re-rated — <old-WSJF> → <new-WSJF>`); displaced prior fragments rotate to `docs/problems/README-history.md`.
|
|
468
|
+
3. `git add docs/problems/README.md` so the refresh rides the same commit as the ticket update per ADR-014. When line-3 truncation displaces prior content, also `git add docs/problems/README-history.md`.
|
|
446
469
|
|
|
447
470
|
**Dependency ripple**: if this update changed the ticket's Effort, and the ticket is an upstream of other tickets (any ticket's `## Dependencies` → `**Blocked by**` list references this ID), the transitive-effort rule (P076) says dependents may need to re-rate too. The surgical render in this step does NOT re-walk the graph — that is Step 9b.1's job. If the dependency graph is known to be non-trivial, prefer `/wr-itil:review-problems` instead of a bare update; the review path handles the re-walk deterministically. The conditional refresh here is sufficient for the common case of a self-only re-rate.
|
|
448
471
|
|
|
@@ -552,7 +575,7 @@ The refresh uses the same rendering rules as Step 9e (glob `docs/problems/*.open
|
|
|
552
575
|
|
|
553
576
|
1. After renaming + Editing + `git add`-ing the transitioned ticket file (per the staging-trap rule above), regenerate `docs/problems/README.md` in-place reflecting the new filename set and the transitioned ticket's new Status.
|
|
554
577
|
2. `git add docs/problems/README.md` — stage the refreshed README with the same commit as the transition.
|
|
555
|
-
3. Update the "Last reviewed" line
|
|
578
|
+
3. Update the "Last reviewed" line per the **Last-reviewed line discipline (P134)** subsection in Step 5 above — name the transition as the most-recent fragment (e.g. `P<NNN> <status> — <one-line fix summary>`); displaced prior fragments rotate to `docs/problems/README-history.md`. When the rotation displaces prior content, the staged file set MUST include both `docs/problems/README.md` AND `docs/problems/README-history.md` per ADR-014 single-commit grain.
|
|
556
579
|
|
|
557
580
|
**Scope**: fires for every Step 7 rename. Applies equally to:
|
|
558
581
|
- Standalone transition commits (e.g. `docs(problems): P<NNN> known error — <summary>`).
|
|
@@ -91,7 +91,7 @@ Render the Verification Queue row in the existing format:
|
|
|
91
91
|
|
|
92
92
|
### Step 4. Apply edits via Edit tool — preserve narrative
|
|
93
93
|
|
|
94
|
-
This is the load-bearing step. Use the `Edit` tool to apply each row-level change. DO NOT regenerate the entire README from scratch — the
|
|
94
|
+
This is the load-bearing step. Use the `Edit` tool to apply each row-level change. DO NOT regenerate the entire README from scratch — the per-Closed-row free-text closure-via column is human-curated narrative that a full regeneration would destroy. (The "Last reviewed:" line is now subject to the **Last-reviewed line discipline (P134)** described in Step 5 below — it carries only the most-recent fragment, not an ever-growing prose paragraph; the displaced history lives in `docs/problems/README-history.md`. Step 5 owns the line-3 update; Step 4 leaves it untouched.)
|
|
95
95
|
|
|
96
96
|
For each REMOVE: `Edit` with the existing row as `old_string`, and remove it (replace with empty string) or replace with a re-positioned row in another section (REMOVE-from-WSJF-Rankings + ADD-to-Verification-Queue is two Edit operations: one to delete the WSJF row, one to insert the VQ row).
|
|
97
97
|
|
|
@@ -101,13 +101,21 @@ For each ADD to Verification Queue: append at the bottom of the VQ table (the ta
|
|
|
101
101
|
|
|
102
102
|
After all edits, re-run `packages/itil/scripts/reconcile-readme.sh docs/problems` to confirm exit 0. If the second run still reports drift, investigate the residual edits — do NOT re-run reconciliation in a loop, as that hides systematic edit failures.
|
|
103
103
|
|
|
104
|
-
### Step 5. Update the "Last reviewed" annotation
|
|
104
|
+
### Step 5. Update the "Last reviewed" annotation per P134 truncation discipline
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
Apply the **Last-reviewed line discipline (P134)** contract documented in `manage-problem` SKILL.md Step 5 — line 3 carries ONE most-recent fragment naming this reconciliation; the prior content rotates to `docs/problems/README-history.md` (forward-chronology archive, soft cap ≤ 1024 bytes per fragment, hard ceiling 5120 bytes per ADR-040 Tier 3 envelope, surfaced advisory-only by `packages/itil/scripts/check-problems-readme-budget.sh`).
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
**Mechanism**:
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
1. Read the current line 3 of `docs/problems/README.md` (e.g. `awk 'NR==3' docs/problems/README.md`).
|
|
111
|
+
2. If the current line 3 is non-empty and not a same-day reconciliation duplicate, append it to `docs/problems/README-history.md` under a `## YYYY-MM-DD` heading (creating the heading on first append for that date).
|
|
112
|
+
3. Replace line 3 of README.md with the new fragment of the form:
|
|
113
|
+
|
|
114
|
+
> Last reviewed: 2026-MM-DD **README reconciled** — (N) drift entries corrected: <comma-separated ID list>. Reconciliation contract per P118 + ADR-014 amended ("Reconciliation as preflight robustness layer").
|
|
115
|
+
|
|
116
|
+
Keep the new fragment ≤ 1024 bytes (soft cap) and certainly ≤ 5120 bytes (hard ceiling). Do NOT prepend `Prior:` segments. Do NOT re-write the existing prose inline — the displaced content lives in `README-history.md` going forward; truncation is the contract, not a side effect.
|
|
117
|
+
|
|
118
|
+
**Rationale (P134)**: this skill previously documented the line as "an ever-growing prose paragraph". That convention is what produced the 76-KB line-3 that broke the Read tool entirely. The reconcile path was a load-bearing site of the bloat — every reconcile that happened under the old convention re-wrote line 3 unbounded. The new discipline closes the surface for reconcile parity with `manage-problem` Step 5 P094, Step 6 P094, Step 7 P062, and the sibling `transition-problem`, `transition-problems`, `review-problems` skills.
|
|
111
119
|
|
|
112
120
|
### Step 6. Commit (when invoked from an AFK orchestrator subprocess)
|
|
113
121
|
|
|
@@ -143,7 +143,7 @@ Fix released, awaiting user verification (driven off `docs/problems/*.verifying.
|
|
|
143
143
|
...
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
Apply the **Last-reviewed line discipline (P134)** contract documented in `manage-problem` SKILL.md Step 5 — line 3 carries ONE most-recent fragment naming the meaningful state change in this refresh (auto-transitions fired, priority flips, newly-stale tickets); displaced prior fragments rotate to `docs/problems/README-history.md` (forward-chronology archive, soft cap ≤ 1024 bytes per fragment, hard ceiling 5120 bytes per ADR-040 Tier 3 envelope, surfaced advisory-only by `packages/itil/scripts/check-problems-readme-budget.sh`). When the rotation displaces prior content, the staged file set MUST include both `docs/problems/README.md` AND `docs/problems/README-history.md` per ADR-014 single-commit grain.
|
|
147
147
|
|
|
148
148
|
### 6. Commit the refresh
|
|
149
149
|
|
|
@@ -175,7 +175,7 @@ The refresh uses the same rendering rules as `/wr-itil:review-problems` Step 9e
|
|
|
175
175
|
|
|
176
176
|
1. After renaming + Editing + `git add`-ing the transitioned ticket file (per the staging-trap rule above), regenerate `docs/problems/README.md` in-place reflecting the new filename set and the transitioned ticket's new Status.
|
|
177
177
|
2. `git add docs/problems/README.md` — stage the refreshed README with the same commit as the transition.
|
|
178
|
-
3. Update the "Last reviewed" line
|
|
178
|
+
3. Update the "Last reviewed" line per the **Last-reviewed line discipline (P134)** contract documented in `manage-problem` SKILL.md Step 5 — name the transition as the most-recent fragment (e.g. `P<NNN> <status> — <one-line fix summary>`); displaced prior fragments rotate to `docs/problems/README-history.md` (forward-chronology archive, soft cap ≤ 1024 bytes per fragment, hard ceiling 5120 bytes per ADR-040 Tier 3 envelope, surfaced advisory-only by `packages/itil/scripts/check-problems-readme-budget.sh`). When the rotation displaces prior content, the staged file set MUST include both `docs/problems/README.md` AND `docs/problems/README-history.md` per ADR-014 single-commit grain.
|
|
179
179
|
|
|
180
180
|
### 8. Commit per ADR-014
|
|
181
181
|
|
|
@@ -184,7 +184,7 @@ The refresh follows the same render rules as `/wr-itil:review-problems` Step 9e
|
|
|
184
184
|
git add docs/problems/README.md
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
-
Update the "Last reviewed"
|
|
187
|
+
Update the "Last reviewed" line per the **Last-reviewed line discipline (P134)** contract documented in `manage-problem` SKILL.md Step 5 — name the batch as a SINGLE most-recent fragment summarising the cohort (e.g. `batch transition: P063 close, P067 close, P092 close, P094 close`); displaced prior fragments rotate to `docs/problems/README-history.md` ONCE for the entire batch, not per-pair. Soft cap ≤ 1024 bytes for the batch fragment — if the cohort would exceed it, abbreviate to ID + verb only and let the per-ticket bodies carry the rationale. When the rotation displaces prior content, also `git add docs/problems/README-history.md` so the same single batch commit per ADR-014 captures both files.
|
|
188
188
|
|
|
189
189
|
**4b. Commit gate (per ADR-014).**
|
|
190
190
|
|