@windyroad/itil 0.19.6-preview.205 → 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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-itil",
3
- "version": "0.19.6",
3
+ "version": "0.19.7",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.19.6-preview.205",
3
+ "version": "0.19.7-preview.208",
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"
@@ -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 should write it via Bash:
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
- : > "/tmp/manage-problem-grep-${CLAUDE_SESSION_ID:-$(echo "${CLAUDE_HOOK_SESSION_ID:-default}")}"
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
- In practice the session ID is supplied by the hook payload, not as an env var the simplest portable pattern is to ask Claude Code to run a one-line Bash that touches the marker using whatever session_id is available in the current invocation. The exact command shape depends on the runtime; the contract is that the file `/tmp/manage-problem-grep-<session-id>` exists by the time Step 5's Write fires. Per architect direction, 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.
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