@windyroad/itil 0.19.6 → 0.19.7-preview.208
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/lib/session-id.sh +108 -0
- package/hooks/lib/staging-detect.sh +107 -0
- package/hooks/p057-staging-trap-detect.sh +89 -0
- package/hooks/test/p057-staging-trap-detect.bats +141 -0
- package/hooks/test/session-id.bats +108 -0
- package/package.json +1 -1
- package/skills/manage-problem/SKILL.md +7 -3
package/hooks/hooks.json
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
{
|
|
12
12
|
"matcher": "Write",
|
|
13
13
|
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/manage-problem-enforce-create.sh" }]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"matcher": "Bash",
|
|
17
|
+
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/p057-staging-trap-detect.sh" }]
|
|
14
18
|
}
|
|
15
19
|
],
|
|
16
20
|
"Stop": [
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# P124: agent-side session-ID discovery helper.
|
|
3
|
+
#
|
|
4
|
+
# `get_current_session_id` returns the canonical Claude Code session UUID
|
|
5
|
+
# for the current invocation, used by /wr-itil:manage-problem Step 2
|
|
6
|
+
# substep 7 to write the create-gate marker (`/tmp/manage-problem-grep-${SID}`)
|
|
7
|
+
# under the same SID the manage-problem-enforce-create.sh hook reads from
|
|
8
|
+
# its stdin JSON payload.
|
|
9
|
+
#
|
|
10
|
+
# Why this helper exists:
|
|
11
|
+
# The agent's process does NOT export CLAUDE_SESSION_ID. The hook side
|
|
12
|
+
# reads session_id from its stdin JSON payload (per the Claude Code
|
|
13
|
+
# PreToolUse contract); the agent side has no equivalent surface, so
|
|
14
|
+
# /wr-itil:manage-problem Step 2's prior fallback `${CLAUDE_SESSION_ID:-default}`
|
|
15
|
+
# wrote the marker under "default" while the hook checked the real UUID.
|
|
16
|
+
# Marker mismatch -> Write deny -> agent had to scrape an existing
|
|
17
|
+
# announce marker filename ad-hoc to recover. P124.
|
|
18
|
+
#
|
|
19
|
+
# Discovery strategy (announce markers preferred over reviewed markers):
|
|
20
|
+
# /tmp/${SYSTEM}-announced-${SESSION_ID} markers are write-once-per-session
|
|
21
|
+
# per ADR-038 and are emitted on the FIRST UserPromptSubmit of every
|
|
22
|
+
# session by every active plugin (architect, jtbd, tdd, style-guide,
|
|
23
|
+
# voice-tone, itil-assistant-gate, itil-correction-detect). They have
|
|
24
|
+
# no mtime sliding (unlike `-reviewed-` gate markers, which `touch`-refresh
|
|
25
|
+
# on every gate check per ADR-009 sliding TTL + P111 subprocess refresh),
|
|
26
|
+
# so the announce-marker UUID is the most reliable per-session signal
|
|
27
|
+
# reachable from agent-side code without an env var.
|
|
28
|
+
#
|
|
29
|
+
# Why itil-local instead of packages/shared (cf. ADR-017):
|
|
30
|
+
# The discovery direction is the OPPOSITE of ADR-038's announce helper —
|
|
31
|
+
# ADR-038's session-marker.sh WRITES announce markers from hook side;
|
|
32
|
+
# this helper READS them from agent side. Only manage-problem SKILL.md
|
|
33
|
+
# needs agent-side discovery today (Step 2 substep 7), so the helper
|
|
34
|
+
# is itil-local with read-only fallbacks across other plugins' marker
|
|
35
|
+
# filenames (no write coupling, no sync obligation). If a second skill
|
|
36
|
+
# adopts agent-side SID discovery, promote to packages/shared/ at that
|
|
37
|
+
# point per ADR-017 shared-code-sync. Mirrors create-gate.sh's "Why a
|
|
38
|
+
# separate helper from lib/review-gate.sh" precedent.
|
|
39
|
+
#
|
|
40
|
+
# Empty SESSION_ID fallback:
|
|
41
|
+
# No env-var + no markers -> echo nothing, return 1. Callers MUST check
|
|
42
|
+
# the return code; a marker-write under an empty SID would land at
|
|
43
|
+
# /tmp/manage-problem-grep- which the hook never matches. Fail-closed.
|
|
44
|
+
#
|
|
45
|
+
# References:
|
|
46
|
+
# ADR-038 — progressive disclosure / session-marker pattern (announce
|
|
47
|
+
# markers, /tmp/${SYSTEM}-announced-${SESSION_ID} convention).
|
|
48
|
+
# ADR-009 — gate marker lifecycle (covers /tmp marker conventions).
|
|
49
|
+
# ADR-017 — shared-code-sync (consulted; itil-local is the right home today).
|
|
50
|
+
# P119 — create-gate hook this discovery helper feeds.
|
|
51
|
+
# P124 — this helper.
|
|
52
|
+
#
|
|
53
|
+
# Test override: SESSION_MARKER_DIR (defaults to /tmp) lets bats run
|
|
54
|
+
# under a sandboxed marker directory without polluting real session
|
|
55
|
+
# state in /tmp.
|
|
56
|
+
|
|
57
|
+
# Returns the canonical session UUID for the current invocation.
|
|
58
|
+
# Echoes the UUID on stdout. Exit 0 if discovered, 1 if not.
|
|
59
|
+
#
|
|
60
|
+
# Usage:
|
|
61
|
+
# source packages/itil/hooks/lib/session-id.sh
|
|
62
|
+
# sid=$(get_current_session_id) || { echo "no SID available" >&2; exit 1; }
|
|
63
|
+
get_current_session_id() {
|
|
64
|
+
# Env-var fast path. CLAUDE_SESSION_ID is not exported in agent
|
|
65
|
+
# contexts today, but if a future Claude Code release adds it,
|
|
66
|
+
# this branch picks it up for free.
|
|
67
|
+
if [ -n "${CLAUDE_SESSION_ID:-}" ]; then
|
|
68
|
+
echo "$CLAUDE_SESSION_ID"
|
|
69
|
+
return 0
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
local marker_dir="${SESSION_MARKER_DIR:-/tmp}"
|
|
73
|
+
|
|
74
|
+
# Marker-system priority order. Architect first because architect-
|
|
75
|
+
# enforce-edit.sh fires on virtually every project edit and so its
|
|
76
|
+
# announce marker is the most reliably present early in any session
|
|
77
|
+
# touching this repo. JTBD second for the same reason on this project.
|
|
78
|
+
# The remaining systems give graceful degradation if the higher-
|
|
79
|
+
# priority hooks haven't yet announced (rare — UserPromptSubmit
|
|
80
|
+
# announces fire on prompt 1).
|
|
81
|
+
local systems=(
|
|
82
|
+
architect
|
|
83
|
+
jtbd
|
|
84
|
+
tdd
|
|
85
|
+
itil-assistant-gate
|
|
86
|
+
itil-correction-detect
|
|
87
|
+
style-guide
|
|
88
|
+
voice-tone
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
local system marker
|
|
92
|
+
for system in "${systems[@]}"; do
|
|
93
|
+
# Glob expansion: nullglob avoids the literal-pattern-on-no-match
|
|
94
|
+
# pitfall. Subshell isolates the shopt change.
|
|
95
|
+
marker=$(
|
|
96
|
+
shopt -s nullglob
|
|
97
|
+
set -- "${marker_dir}/${system}-announced-"*
|
|
98
|
+
[ "$#" -gt 0 ] && printf '%s\n' "$1"
|
|
99
|
+
)
|
|
100
|
+
if [ -n "$marker" ]; then
|
|
101
|
+
# Strip the prefix to recover the trailing UUID.
|
|
102
|
+
basename "$marker" | sed "s/^${system}-announced-//"
|
|
103
|
+
return 0
|
|
104
|
+
fi
|
|
105
|
+
done
|
|
106
|
+
|
|
107
|
+
return 1
|
|
108
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# P125: shared staging-trap detection helper.
|
|
3
|
+
#
|
|
4
|
+
# `detect_p057_trap` returns 0 (no trap detected — allow) / 1 (trap
|
|
5
|
+
# detected — caller should deny). On 1, the trap'd path is echoed on
|
|
6
|
+
# stdout and a one-line recovery hint is emitted on stderr so callers
|
|
7
|
+
# can surface both in deny messages without re-parsing diff output.
|
|
8
|
+
#
|
|
9
|
+
# Trap shape (P057):
|
|
10
|
+
# `git mv A B` stages the rename atomically. If the agent then
|
|
11
|
+
# modifies B via the Edit tool (or any other working-tree edit),
|
|
12
|
+
# git's index still carries only the rename — the post-rename
|
|
13
|
+
# content edit is a working-tree modification needing a separate
|
|
14
|
+
# `git add B`. Without that re-stage, only the rename lands at
|
|
15
|
+
# commit time and the edit leaks into the next commit (audit-trail
|
|
16
|
+
# corruption — the original P057 concern).
|
|
17
|
+
#
|
|
18
|
+
# Detection logic:
|
|
19
|
+
# - `git diff --staged --name-status` enumerates staged changes;
|
|
20
|
+
# rename rows start with `R<num>\t<old>\t<new>`.
|
|
21
|
+
# - `git diff --name-only` enumerates working-tree modifications.
|
|
22
|
+
# - If any rename's `<new>` path also appears in the working-tree
|
|
23
|
+
# modification list, the trap shape is present.
|
|
24
|
+
#
|
|
25
|
+
# Cost: two `git diff` invocations per check (~10-50ms on this repo's
|
|
26
|
+
# working tree; bounded by repo size, not commit batch size). Per
|
|
27
|
+
# architect verdict on P125: cheaper than a session-marker variant
|
|
28
|
+
# and deterministic — runs on every `git commit` invocation rather
|
|
29
|
+
# than relying on per-tool-call session state tracking.
|
|
30
|
+
#
|
|
31
|
+
# Fail-open contract:
|
|
32
|
+
# - Outside a git working tree, or when `git diff` fails for any
|
|
33
|
+
# reason (parse error, broken index, permissions), return 0
|
|
34
|
+
# (allow). Mirrors `lib/create-gate.sh`'s exit-0 fallback on
|
|
35
|
+
# parse-incomplete input — a hook that fails-closed on hostile
|
|
36
|
+
# environments would block legitimate commits in non-git contexts
|
|
37
|
+
# (e.g. agent-driven scripts that happen to mention `git commit`
|
|
38
|
+
# in unrelated contexts).
|
|
39
|
+
#
|
|
40
|
+
# References:
|
|
41
|
+
# ADR-005 — plugin testing strategy (hook bats live under hooks/test/).
|
|
42
|
+
# ADR-009 — gate marker lifecycle (this helper deliberately does NOT
|
|
43
|
+
# use markers; detection is per-invocation deterministic,
|
|
44
|
+
# not per-session trust window).
|
|
45
|
+
# ADR-013 Rule 1 — deny redirects with mechanical recovery (the deny
|
|
46
|
+
# text names the file + the literal `git add <new>`
|
|
47
|
+
# recovery; no skill round-trip required).
|
|
48
|
+
# ADR-038 — progressive disclosure / deny-message terseness.
|
|
49
|
+
# P057 — original staging-trap ticket (closed; documentation fix).
|
|
50
|
+
# P119 — sibling create-gate hook + lib/create-gate.sh helper
|
|
51
|
+
# (precedent for the deny + helper-pair shape).
|
|
52
|
+
# P125 — this helper.
|
|
53
|
+
|
|
54
|
+
# Detect the P057 staging trap in the current git working tree.
|
|
55
|
+
#
|
|
56
|
+
# Echoes the trap'd path on stdout when detected; emits a one-line
|
|
57
|
+
# recovery hint on stderr.
|
|
58
|
+
#
|
|
59
|
+
# Returns:
|
|
60
|
+
# 0 — no trap (allow / fail-open)
|
|
61
|
+
# 1 — trap detected (caller should deny)
|
|
62
|
+
#
|
|
63
|
+
# Usage:
|
|
64
|
+
# if trapped=$(detect_p057_trap 2>/dev/null); then
|
|
65
|
+
# echo "no trap"
|
|
66
|
+
# else
|
|
67
|
+
# echo "trap on $trapped"
|
|
68
|
+
# fi
|
|
69
|
+
detect_p057_trap() {
|
|
70
|
+
# Fail-open if not inside a git working tree.
|
|
71
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
|
|
72
|
+
|
|
73
|
+
# Staged renames: rows shaped `R<num>\t<old>\t<new>`.
|
|
74
|
+
local staged_renames
|
|
75
|
+
staged_renames=$(git diff --staged --name-status 2>/dev/null) || return 0
|
|
76
|
+
|
|
77
|
+
# Working-tree modifications: one path per line.
|
|
78
|
+
local wt_mods
|
|
79
|
+
wt_mods=$(git diff --name-only 2>/dev/null) || return 0
|
|
80
|
+
|
|
81
|
+
# No working-tree mods at all => nothing can be the post-rename
|
|
82
|
+
# content edit. Fast bail.
|
|
83
|
+
[ -n "$wt_mods" ] || return 0
|
|
84
|
+
|
|
85
|
+
# Walk staged renames; if any `<new>` path also appears in the
|
|
86
|
+
# working-tree mod list, the trap is present.
|
|
87
|
+
local line new_path
|
|
88
|
+
while IFS= read -r line; do
|
|
89
|
+
case "$line" in
|
|
90
|
+
R*)
|
|
91
|
+
# Tab-delimited: status \t old \t new — take the third field.
|
|
92
|
+
new_path=$(printf '%s' "$line" | awk -F'\t' '{print $3}')
|
|
93
|
+
[ -n "$new_path" ] || continue
|
|
94
|
+
# Match against working-tree mod list (full-line match).
|
|
95
|
+
if printf '%s\n' "$wt_mods" | grep -Fxq "$new_path"; then
|
|
96
|
+
printf '%s\n' "$new_path"
|
|
97
|
+
echo "P057 staging-trap: re-stage with: git add $new_path" >&2
|
|
98
|
+
return 1
|
|
99
|
+
fi
|
|
100
|
+
;;
|
|
101
|
+
esac
|
|
102
|
+
done <<EOF
|
|
103
|
+
$staged_renames
|
|
104
|
+
EOF
|
|
105
|
+
|
|
106
|
+
return 0
|
|
107
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# P125: PreToolUse:Bash hook — denies `git commit` invocations that
|
|
3
|
+
# would land in the P057 staging-trap shape (rename + post-rename
|
|
4
|
+
# Edit without re-stage).
|
|
5
|
+
#
|
|
6
|
+
# Detection delegates to `lib/staging-detect.sh::detect_p057_trap`.
|
|
7
|
+
# When the helper returns 1, this hook emits PreToolUse deny JSON
|
|
8
|
+
# with the trap'd path inline and the literal `git add <path>`
|
|
9
|
+
# recovery command, satisfying ADR-013 Rule 1's "deny redirects to
|
|
10
|
+
# a recovery path" contract via the mechanical-recovery shape (no
|
|
11
|
+
# skill wrapper required — re-staging a file is a single command).
|
|
12
|
+
#
|
|
13
|
+
# Allow paths (exit 0 without deny):
|
|
14
|
+
# - tool_name != "Bash" (only Bash invocations are gated)
|
|
15
|
+
# - command does not contain `git commit` substring (non-commit
|
|
16
|
+
# Bash bypasses entirely)
|
|
17
|
+
# - working tree clean of trap (helper returns 0)
|
|
18
|
+
# - outside a git work tree (helper fails-open)
|
|
19
|
+
# - parse failure on stdin (mirrors create-gate.sh fail-open)
|
|
20
|
+
#
|
|
21
|
+
# References:
|
|
22
|
+
# ADR-005 — plugin testing strategy (hook bats live under hooks/test/).
|
|
23
|
+
# ADR-009 — gate marker lifecycle (this hook deliberately does NOT
|
|
24
|
+
# use markers; detection is per-invocation deterministic).
|
|
25
|
+
# ADR-013 Rule 1 — deny redirects with mechanical recovery.
|
|
26
|
+
# ADR-038 — progressive disclosure / deny-message terseness budget.
|
|
27
|
+
# P057 — original staging-trap ticket; this hook is the
|
|
28
|
+
# enforcement layer the documentation alone didn't provide.
|
|
29
|
+
# P119 — sibling create-gate hook (PreToolUse:Write + lib/create-gate.sh).
|
|
30
|
+
# P125 — this hook.
|
|
31
|
+
|
|
32
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
33
|
+
# shellcheck source=lib/staging-detect.sh
|
|
34
|
+
source "$SCRIPT_DIR/lib/staging-detect.sh"
|
|
35
|
+
|
|
36
|
+
INPUT=$(cat)
|
|
37
|
+
|
|
38
|
+
TOOL_NAME=$(echo "$INPUT" | python3 -c "
|
|
39
|
+
import sys, json
|
|
40
|
+
try:
|
|
41
|
+
data = json.load(sys.stdin)
|
|
42
|
+
print(data.get('tool_name', ''))
|
|
43
|
+
except:
|
|
44
|
+
print('')
|
|
45
|
+
" 2>/dev/null || echo "")
|
|
46
|
+
|
|
47
|
+
# Only gate Bash. Non-Bash tools bypass entirely.
|
|
48
|
+
if [ "$TOOL_NAME" != "Bash" ]; then
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
COMMAND=$(echo "$INPUT" | python3 -c "
|
|
53
|
+
import sys, json
|
|
54
|
+
try:
|
|
55
|
+
data = json.load(sys.stdin)
|
|
56
|
+
print(data.get('tool_input', {}).get('command', ''))
|
|
57
|
+
except:
|
|
58
|
+
print('')
|
|
59
|
+
" 2>/dev/null || echo "")
|
|
60
|
+
|
|
61
|
+
# Only fire on `git commit` invocations. Substring match catches
|
|
62
|
+
# common shapes (`git commit -m`, `git commit --amend`, leading
|
|
63
|
+
# `cd && git commit`, etc.) without over-matching unrelated bash.
|
|
64
|
+
case "$COMMAND" in
|
|
65
|
+
*"git commit"*) ;;
|
|
66
|
+
*) exit 0 ;;
|
|
67
|
+
esac
|
|
68
|
+
|
|
69
|
+
# Run detection. Helper echoes trap'd path on stdout when detected;
|
|
70
|
+
# returns 1 in that case. Returns 0 (allow) on no-trap or fail-open
|
|
71
|
+
# (non-git tree, parse error).
|
|
72
|
+
TRAPPED_PATH=$(detect_p057_trap 2>/dev/null) && exit 0
|
|
73
|
+
|
|
74
|
+
# Trap detected — emit deny with terse recovery.
|
|
75
|
+
# Voice-tone draft target ~245 bytes; ADR-038 progressive-disclosure
|
|
76
|
+
# budget. Keeps the rule cite (P057), the trap'd file path, and the
|
|
77
|
+
# literal recovery command inline.
|
|
78
|
+
REASON="BLOCKED: P057 staging-trap. Renamed file has unstaged post-rename edits: ${TRAPPED_PATH}. Run \`git add ${TRAPPED_PATH}\` then retry commit. Otherwise rename lands without the edit; edit drifts into next commit (audit-trail break)."
|
|
79
|
+
|
|
80
|
+
cat <<EOF
|
|
81
|
+
{
|
|
82
|
+
"hookSpecificOutput": {
|
|
83
|
+
"hookEventName": "PreToolUse",
|
|
84
|
+
"permissionDecision": "deny",
|
|
85
|
+
"permissionDecisionReason": "${REASON}"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
EOF
|
|
89
|
+
exit 0
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P125: p057-staging-trap-detect.sh PreToolUse:Bash hook must detect the
|
|
4
|
+
# rename-then-edit-without-re-stage pattern that drops post-rename edits
|
|
5
|
+
# into the next commit (P057 staging trap).
|
|
6
|
+
#
|
|
7
|
+
# Detection logic (per ticket Fix Strategy):
|
|
8
|
+
# On `git commit` invocations, run `git diff --staged --name-status`
|
|
9
|
+
# and `git diff --name-only`. If a file appears in --staged with an
|
|
10
|
+
# `R<num>` (rename) status AND in working-tree `git diff --name-only`
|
|
11
|
+
# as modified, the trap shape is present — emit a deny with recovery
|
|
12
|
+
# command `git add <new-path>` and the P057 cite.
|
|
13
|
+
#
|
|
14
|
+
# Per ADR-005 (plugin testing strategy) — hook bats live under
|
|
15
|
+
# packages/<plugin>/hooks/test/ and assert behaviour on emitted JSON,
|
|
16
|
+
# not source-content. ADR-037 partitions skill tests separately.
|
|
17
|
+
#
|
|
18
|
+
# Per feedback_behavioural_tests.md (P081) — no source-grep on hook
|
|
19
|
+
# text. Simulate the PreToolUse:Bash payload on stdin and assert on
|
|
20
|
+
# the emitted permissionDecision.
|
|
21
|
+
|
|
22
|
+
setup() {
|
|
23
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
24
|
+
HOOK="$SCRIPT_DIR/p057-staging-trap-detect.sh"
|
|
25
|
+
ORIG_DIR="$PWD"
|
|
26
|
+
TEST_DIR=$(mktemp -d)
|
|
27
|
+
cd "$TEST_DIR"
|
|
28
|
+
git init --quiet -b main
|
|
29
|
+
git config user.email "test@example.com"
|
|
30
|
+
git config user.name "Test"
|
|
31
|
+
echo "original" > foo.md
|
|
32
|
+
git add foo.md
|
|
33
|
+
git -c commit.gpgsign=false commit --quiet -m "initial"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
teardown() {
|
|
37
|
+
cd "$ORIG_DIR"
|
|
38
|
+
rm -rf "$TEST_DIR"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Helper: simulate the PreToolUse:Bash payload on stdin.
|
|
42
|
+
run_bash_hook() {
|
|
43
|
+
local cmd="$1"
|
|
44
|
+
local json
|
|
45
|
+
json=$(printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$cmd")
|
|
46
|
+
echo "$json" | bash "$HOOK"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# --- Trap detection: the canonical P057 shape ---
|
|
50
|
+
|
|
51
|
+
@test "deny: rename + post-rename edit without re-stage triggers deny on git commit" {
|
|
52
|
+
git mv foo.md bar.md
|
|
53
|
+
echo "modified content" > bar.md
|
|
54
|
+
run run_bash_hook "git commit -m 'test'"
|
|
55
|
+
[ "$status" -eq 0 ]
|
|
56
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
57
|
+
[[ "$output" == *"P057"* ]]
|
|
58
|
+
[[ "$output" == *"bar.md"* ]]
|
|
59
|
+
[[ "$output" == *"git add bar.md"* ]]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# --- Allow paths: each non-trap shape must NOT deny ---
|
|
63
|
+
|
|
64
|
+
@test "allow: rename + post-rename edit + re-stage allows the commit" {
|
|
65
|
+
git mv foo.md bar.md
|
|
66
|
+
echo "modified content" > bar.md
|
|
67
|
+
git add bar.md
|
|
68
|
+
run run_bash_hook "git commit -m 'test'"
|
|
69
|
+
[ "$status" -eq 0 ]
|
|
70
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@test "allow: pure rename without subsequent edit allows the commit" {
|
|
74
|
+
git mv foo.md bar.md
|
|
75
|
+
run run_bash_hook "git commit -m 'test'"
|
|
76
|
+
[ "$status" -eq 0 ]
|
|
77
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@test "allow: modify-only batch (no rename) allows the commit" {
|
|
81
|
+
echo "modified" > foo.md
|
|
82
|
+
git add foo.md
|
|
83
|
+
run run_bash_hook "git commit -m 'test'"
|
|
84
|
+
[ "$status" -eq 0 ]
|
|
85
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@test "allow: empty batch (nothing staged, nothing modified) allows the commit" {
|
|
89
|
+
run run_bash_hook "git commit -m 'test'"
|
|
90
|
+
[ "$status" -eq 0 ]
|
|
91
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# --- Tool-name and command-shape filters ---
|
|
95
|
+
|
|
96
|
+
@test "allow: non-Bash tool exits 0 without deny" {
|
|
97
|
+
run bash -c "echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"foo.md\"}}' | bash $HOOK"
|
|
98
|
+
[ "$status" -eq 0 ]
|
|
99
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@test "allow: Bash command that is NOT git commit (e.g., git status) bypasses detection" {
|
|
103
|
+
# Trap shape IS present in the working tree, but the command isn't
|
|
104
|
+
# `git commit` — the hook only fires on commit invocations.
|
|
105
|
+
git mv foo.md bar.md
|
|
106
|
+
echo "modified" > bar.md
|
|
107
|
+
run run_bash_hook "git status"
|
|
108
|
+
[ "$status" -eq 0 ]
|
|
109
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# --- Parse / fail-open (mirror create-gate.sh's exit-0 on parse failure) ---
|
|
113
|
+
|
|
114
|
+
@test "allow: empty JSON exits 0 without deny (fail-open on parse-incomplete)" {
|
|
115
|
+
run bash -c "echo '{}' | bash $HOOK"
|
|
116
|
+
[ "$status" -eq 0 ]
|
|
117
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# --- Deny message contract (ADR-038 progressive disclosure budget) ---
|
|
121
|
+
|
|
122
|
+
@test "deny message names the file, recovery command, and P057 cite" {
|
|
123
|
+
git mv foo.md bar.md
|
|
124
|
+
echo "modified" > bar.md
|
|
125
|
+
run run_bash_hook "git commit -m 'test'"
|
|
126
|
+
[ "$status" -eq 0 ]
|
|
127
|
+
[[ "$output" == *"P057"* ]]
|
|
128
|
+
[[ "$output" == *"git add bar.md"* ]]
|
|
129
|
+
[[ "$output" == *"bar.md"* ]]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@test "deny message stays under ADR-038 progressive-disclosure budget (<400 bytes)" {
|
|
133
|
+
# Voice-tone draft target is ~245 bytes; allow generous headroom for
|
|
134
|
+
# JSON envelope. Hard cap at 400 keeps the message terse per
|
|
135
|
+
# ADR-038 — fail loudly if the message bloats over time.
|
|
136
|
+
git mv foo.md bar.md
|
|
137
|
+
echo "modified" > bar.md
|
|
138
|
+
run run_bash_hook "git commit -m 'test'"
|
|
139
|
+
[ "$status" -eq 0 ]
|
|
140
|
+
[ "${#output}" -lt 400 ]
|
|
141
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P124: session-id.sh helper must canonicalise per-session UUID discovery
|
|
4
|
+
# for agent-side code paths (e.g. /wr-itil:manage-problem Step 2 substep 7
|
|
5
|
+
# marker write) that today fall back to the brittle ${CLAUDE_SESSION_ID:-default}
|
|
6
|
+
# pattern when the env var is not exported in the agent's process.
|
|
7
|
+
#
|
|
8
|
+
# Behavioural contract:
|
|
9
|
+
# 1. CLAUDE_SESSION_ID exported -> echo it (env-var fast path).
|
|
10
|
+
# 2. CLAUDE_SESSION_ID absent + an /tmp/<system>-announced-<UUID> marker
|
|
11
|
+
# present -> echo the UUID parsed from the marker filename.
|
|
12
|
+
# 3. CLAUDE_SESSION_ID absent + no markers anywhere -> echo nothing,
|
|
13
|
+
# exit non-zero (so callers can detect "could not discover").
|
|
14
|
+
# 4. Multiple announce markers across systems -> deterministic selection
|
|
15
|
+
# (architect first, then jtbd, then tdd, then itil-assistant-gate,
|
|
16
|
+
# then itil-correction-detect, then style-guide, then voice-tone)
|
|
17
|
+
# so the discovery is reproducible across invocations.
|
|
18
|
+
#
|
|
19
|
+
# Per feedback_behavioural_tests.md (P081): tests assert the helper's
|
|
20
|
+
# emitted output and exit code, not the source content of the helper.
|
|
21
|
+
# The marker-system selection order is asserted by constructing only the
|
|
22
|
+
# higher-priority marker and checking the helper picks it.
|
|
23
|
+
|
|
24
|
+
setup() {
|
|
25
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
26
|
+
HELPER="$SCRIPT_DIR/lib/session-id.sh"
|
|
27
|
+
# Use a sandbox /tmp so we never leak across real session markers.
|
|
28
|
+
SANDBOX_TMP=$(mktemp -d)
|
|
29
|
+
# session-id.sh reads from /tmp by default; SESSION_MARKER_DIR
|
|
30
|
+
# overrides that for sandboxed bats runs without touching real
|
|
31
|
+
# session state in /tmp.
|
|
32
|
+
export SESSION_MARKER_DIR="$SANDBOX_TMP"
|
|
33
|
+
unset CLAUDE_SESSION_ID
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
teardown() {
|
|
37
|
+
rm -rf "$SANDBOX_TMP"
|
|
38
|
+
unset SESSION_MARKER_DIR
|
|
39
|
+
unset CLAUDE_SESSION_ID
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Helper: source the helper and emit the discovered SID + exit code.
|
|
43
|
+
discover() {
|
|
44
|
+
bash -c "source '$HELPER'; get_current_session_id; echo \"EXIT:\$?\""
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Helper: write an announce marker with a known UUID under SESSION_MARKER_DIR.
|
|
48
|
+
mark_announced() {
|
|
49
|
+
local system="$1"
|
|
50
|
+
local uuid="$2"
|
|
51
|
+
: > "$SESSION_MARKER_DIR/${system}-announced-${uuid}"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# --- Behavioural contract: env-var fast path ---
|
|
55
|
+
|
|
56
|
+
@test "env-var present -> returns the env-var value verbatim" {
|
|
57
|
+
expected_uuid="11111111-1111-1111-1111-111111111111"
|
|
58
|
+
output=$(CLAUDE_SESSION_ID="$expected_uuid" bash -c "source '$HELPER'; get_current_session_id; echo \"EXIT:\$?\"")
|
|
59
|
+
[[ "$output" == *"$expected_uuid"* ]]
|
|
60
|
+
[[ "$output" == *"EXIT:0"* ]]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@test "env-var present -> ignores any markers (no scrape)" {
|
|
64
|
+
expected_uuid="22222222-2222-2222-2222-222222222222"
|
|
65
|
+
decoy_uuid="33333333-3333-3333-3333-333333333333"
|
|
66
|
+
mark_announced "architect" "$decoy_uuid"
|
|
67
|
+
output=$(CLAUDE_SESSION_ID="$expected_uuid" bash -c "source '$HELPER'; get_current_session_id; echo \"EXIT:\$?\"")
|
|
68
|
+
[[ "$output" == *"$expected_uuid"* ]]
|
|
69
|
+
[[ "$output" != *"$decoy_uuid"* ]]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# --- Behavioural contract: marker-scrape fallback ---
|
|
73
|
+
|
|
74
|
+
@test "env-var absent + architect-announced marker present -> returns marker UUID" {
|
|
75
|
+
expected_uuid="44444444-4444-4444-4444-444444444444"
|
|
76
|
+
mark_announced "architect" "$expected_uuid"
|
|
77
|
+
output=$(discover)
|
|
78
|
+
[[ "$output" == *"$expected_uuid"* ]]
|
|
79
|
+
[[ "$output" == *"EXIT:0"* ]]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@test "env-var absent + only jtbd-announced marker present -> returns marker UUID (fallback chain works)" {
|
|
83
|
+
expected_uuid="55555555-5555-5555-5555-555555555555"
|
|
84
|
+
mark_announced "jtbd" "$expected_uuid"
|
|
85
|
+
output=$(discover)
|
|
86
|
+
[[ "$output" == *"$expected_uuid"* ]]
|
|
87
|
+
[[ "$output" == *"EXIT:0"* ]]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@test "env-var absent + no markers anywhere -> empty output, non-zero exit" {
|
|
91
|
+
output=$(discover)
|
|
92
|
+
# Output should be just "EXIT:<non-zero>" with no UUID before it.
|
|
93
|
+
[[ "$output" =~ ^EXIT:[1-9] ]]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@test "deterministic priority: architect-announced beats jtbd-announced when both present" {
|
|
97
|
+
architect_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
98
|
+
jtbd_uuid="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
99
|
+
mark_announced "jtbd" "$jtbd_uuid"
|
|
100
|
+
# Sleep so jtbd marker has older mtime — proves selection is by
|
|
101
|
+
# marker-system priority, not by mtime (the architect-flagged
|
|
102
|
+
# mtime fragility from session 2026-04-26 review).
|
|
103
|
+
sleep 1
|
|
104
|
+
mark_announced "architect" "$architect_uuid"
|
|
105
|
+
output=$(discover)
|
|
106
|
+
[[ "$output" == *"$architect_uuid"* ]]
|
|
107
|
+
[[ "$output" != *"$jtbd_uuid"* ]]
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -238,13 +238,17 @@ Before creating, search existing problems for similar issues. The user may not k
|
|
|
238
238
|
- "I found existing problems that may be related: P011 (stuck saving, CLOSED), P023 (foul drawn garbled, OPEN). Would you like to: (a) Update an existing problem, (b) Create a new problem anyway, (c) Cancel?"
|
|
239
239
|
5. If the user chooses to update, switch to the update flow for that problem ID
|
|
240
240
|
6. If no matches found, proceed to create
|
|
241
|
-
7. **After the grep completes** (whether duplicates were found or not), write the per-session create-gate marker so the `PreToolUse:Write` hook (`packages/itil/hooks/manage-problem-enforce-create.sh`, P119) allows the subsequent Write of the new `.open.md` file. The marker is `/tmp/manage-problem-grep-${SESSION_ID}` and the agent
|
|
241
|
+
7. **After the grep completes** (whether duplicates were found or not), write the per-session create-gate marker so the `PreToolUse:Write` hook (`packages/itil/hooks/manage-problem-enforce-create.sh`, P119) allows the subsequent Write of the new `.open.md` file. The marker is `/tmp/manage-problem-grep-${SESSION_ID}` and the agent writes it via Bash by sourcing the session-id discovery helper (P124) and calling the existing `mark_step2_complete` helper:
|
|
242
242
|
|
|
243
243
|
```bash
|
|
244
|
-
|
|
244
|
+
source packages/itil/hooks/lib/session-id.sh
|
|
245
|
+
source packages/itil/hooks/lib/create-gate.sh
|
|
246
|
+
sid=$(get_current_session_id) && mark_step2_complete "$sid"
|
|
245
247
|
```
|
|
246
248
|
|
|
247
|
-
|
|
249
|
+
`get_current_session_id` (P124) returns the canonical session UUID by reading `CLAUDE_SESSION_ID` if exported, else by scraping the most-reliable per-session announce marker (`/tmp/<system>-announced-<UUID>`, set on prompt 1 of every session per ADR-038 by architect / jtbd / tdd / style-guide / voice-tone / itil-assistant-gate / itil-correction-detect hooks). It exits non-zero if no session can be discovered — the `&&` short-circuits the marker write so the agent never lands `/tmp/manage-problem-grep-` (an empty UUID would never match the hook's stdin-JSON `session_id` and would silently fail later). `mark_step2_complete` (existing helper from `create-gate.sh`) writes the marker file under the canonical path; the marker is per-session (single marker covers all new tickets for the rest of this session), enabling Step 4b multi-concern splits and same-session unrelated-ticket creation without re-running the grep.
|
|
250
|
+
|
|
251
|
+
**Why a helper instead of inline `${CLAUDE_SESSION_ID:-default}`**: the agent's process does NOT export `CLAUDE_SESSION_ID` today; the hook side reads `session_id` from its stdin JSON payload (per the Claude Code PreToolUse contract). The prior fallback wrote the marker under `default` while the hook checked the real UUID — mismatch caused the Write deny on every first ticket of a session until the agent ad-hoc scraped a UUID-bearing marker. The helper canonicalises that scrape so every agent context discovers the SID the same way. P124.
|
|
248
252
|
|
|
249
253
|
**Search strategy**: Search problem filenames AND file content. A match on the filename (kebab-case title) or the Description/Symptoms sections counts. Cast a wide net — false positives are cheap (user chooses), but false negatives mean duplicate problems.
|
|
250
254
|
|