@windyroad/itil 0.22.1 → 0.23.0-preview.249

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-itil",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.22.1",
3
+ "version": "0.23.0-preview.249",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -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's parenthetical to name the new ticket (e.g. `P<NNN> opened — <one-line title>`) so the next session's fast-path check has a human-readable audit marker.
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's parenthetical to name the re-rated ticket (e.g. `P<NNN> re-rated — <old-WSJF> → <new-WSJF>`).
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's parenthetical to name the transition (e.g. `P<NNN> <status> — <one-line fix summary>`) so the next session's fast-path check has a human-readable audit marker alongside the git-history staleness test.
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 long "Last reviewed: ..." prose paragraph at the top, and the per-Closed-row free-text closure-via column, are human-curated narrative that a full regeneration would destroy.
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
- Prepend (or append, per existing convention — the README uses a single ever-growing prose paragraph) a short note to the "Last reviewed:" paragraph of the form:
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
- > 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").
108
+ **Mechanism**:
109
109
 
110
- Keep the note ≤ 200 bytes; the long narrative form belongs in retros and ADR amendments, not in this annotation. Do not re-write the existing prose.
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
- The "Last reviewed" parenthetical should name any meaningful state change in this refresh (auto-transitions fired, priority flips, newly-stale tickets) so the next session's fast-path has a human-readable audit marker alongside the git-history staleness test.
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's parenthetical to name the transition (e.g. `P<NNN> <status> — <one-line fix summary>`) so the next session's fast-path check has a human-readable audit marker alongside the git-history staleness test.
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" parenthetical to name the batch (e.g. `batch transition: P063 close, P067 close, P092 close, P094 close`).
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