@windyroad/itil 0.23.2 → 0.23.3

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.23.2",
3
+ "version": "0.23.3",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
package/hooks/hooks.json CHANGED
@@ -8,6 +8,10 @@
8
8
  { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-correction-detect.sh" }] }
9
9
  ],
10
10
  "PreToolUse": [
11
+ {
12
+ "matcher": "Bash|Write|Edit|Read",
13
+ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/itil-runtime-sid-marker.sh" }]
14
+ },
11
15
  {
12
16
  "matcher": "Write",
13
17
  "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/manage-problem-enforce-create.sh" }]
@@ -0,0 +1,87 @@
1
+ #!/bin/bash
2
+ # P142 / ADR-050: runtime-SID instrumentation PreToolUse hook.
3
+ #
4
+ # Captures the runtime stdin `session_id` from Claude Code's PreToolUse
5
+ # JSON payload and writes it to a per-machine, per-user, per-project
6
+ # marker file. The `get_current_session_id` helper (lib/session-id.sh)
7
+ # reads this marker as the authoritative current-session UUID, replacing
8
+ # the Phase 3 mtime-based announce-marker selection that misfired in
9
+ # orchestrator main turns AFTER subprocess dispatch (P142 ticket).
10
+ #
11
+ # Why a NEW PreToolUse hook (not an extension of an existing one):
12
+ # - manage-problem-enforce-create.sh already runs on PreToolUse:Write,
13
+ # but its perf-sensitive denial-path needs the runtime SID BEFORE
14
+ # this hook would write it. Writing in a separate, prior hook
15
+ # ensures the marker is in place by the time enforce-create reads.
16
+ # - The architect-enforce-edit / jtbd-enforce-edit / tdd-enforce hooks
17
+ # are owned by sibling plugins; cross-plugin coupling is rejected
18
+ # per ADR-017 (shared-code-sync).
19
+ # - A standalone, single-purpose hook is the cleanest fit for ADR-045
20
+ # Pattern 1 (silent-on-pass, side-effect-only).
21
+ #
22
+ # Matcher: PreToolUse:Bash|Write|Edit|Read covers the tool calls that
23
+ # may invoke `get_current_session_id` indirectly (Bash sources the
24
+ # helper; Write/Edit fires the create-gate that consumes the marker;
25
+ # Read is included for completeness — every tool call that fires a
26
+ # PreToolUse hook contributes a fresh marker).
27
+ #
28
+ # ADR-045 Pattern 1 binding: this hook MUST emit 0 bytes on stdout.
29
+ # Adding stdout output would burn the per-tool-call context budget.
30
+ # All side effects are filesystem writes; observability is via the
31
+ # marker file itself.
32
+ #
33
+ # Fail-open contract: any error path (missing jq, malformed JSON, empty
34
+ # session_id, write failure) exits 0 without modifying state. The hook
35
+ # MUST NOT block tool calls — its only role is to deposit a marker for
36
+ # the helper. If the marker is absent, the helper falls back to the
37
+ # announce-marker priority logic.
38
+ #
39
+ # References:
40
+ # ADR-050 — runtime-SID instrumentation surface (this hook).
41
+ # ADR-048 — gate-misfire recovery (superseded by ADR-050).
42
+ # ADR-045 — hook injection budget; Pattern 1 binding.
43
+ # ADR-038 — announce-marker contract (cold-path fallback consumer).
44
+ # P142 — the ticket this hook closes.
45
+ # P124 — Phase 3 helper this hook complements.
46
+
47
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
48
+ # shellcheck source=lib/runtime-sid.sh
49
+ source "$SCRIPT_DIR/lib/runtime-sid.sh"
50
+
51
+ INPUT=$(cat)
52
+
53
+ # Empty stdin -> no-op. Hook harnesses, manual invocation, or a
54
+ # malformed stdin payload all land here; fail-open per the contract.
55
+ if [ -z "$INPUT" ]; then
56
+ exit 0
57
+ fi
58
+
59
+ # Parse session_id with python3 (universally present on macOS + most
60
+ # Linux distros; also already used by manage-problem-enforce-create.sh
61
+ # as the JSON parser of choice in this plugin). jq fallback if python3
62
+ # is absent. Any parse failure -> empty SESSION_ID -> no-op below.
63
+ SESSION_ID=""
64
+ if command -v python3 >/dev/null 2>&1; then
65
+ SESSION_ID=$(echo "$INPUT" | python3 -c "
66
+ import sys, json
67
+ try:
68
+ data = json.load(sys.stdin)
69
+ print(data.get('session_id', ''))
70
+ except Exception:
71
+ print('')
72
+ " 2>/dev/null || echo "")
73
+ elif command -v jq >/dev/null 2>&1; then
74
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "")
75
+ fi
76
+
77
+ if [ -z "$SESSION_ID" ]; then
78
+ exit 0
79
+ fi
80
+
81
+ # Write the marker. printf (not echo) to avoid trailing newline; the
82
+ # helper's `cat` reads contents verbatim, and a trailing newline would
83
+ # corrupt the SID comparison the runtime hook performs.
84
+ MARKER_PATH=$(runtime_sid_path)
85
+ printf '%s' "$SESSION_ID" > "$MARKER_PATH" 2>/dev/null || true
86
+
87
+ exit 0
@@ -0,0 +1,61 @@
1
+ #!/bin/bash
2
+ # P142 (P124 Phase 4): runtime-SID marker path helper.
3
+ #
4
+ # Computes the per-machine, per-user, per-project marker path that the
5
+ # `itil-runtime-sid-marker.sh` PreToolUse hook writes the runtime
6
+ # `session_id` to (parsed from hook stdin JSON) and that
7
+ # `get_current_session_id` reads as the authoritative current-session
8
+ # identifier. Both producer (hook) and consumer (helper) source this
9
+ # lib so they agree on the path.
10
+ #
11
+ # Why this exists:
12
+ # The Phase 3 helper relied on within-system mtime selection across
13
+ # ADR-038 announce markers. In orchestrator main turns AFTER subprocess
14
+ # dispatch, subprocess announce markers had NEWER mtimes than the
15
+ # orchestrator's, so newest-mtime-wins picked the wrong UUID. No pure-
16
+ # helper algorithm can disambiguate orchestrator vs subprocess context
17
+ # from filesystem state alone (P142 ticket Investigation Tasks). The
18
+ # structural fix is to capture the runtime stdin SID — known with
19
+ # certainty by the hook on every tool call — into a discoverable file
20
+ # the helper can read. See ADR-050.
21
+ #
22
+ # Path scoping:
23
+ # When SESSION_MARKER_DIR is set (sandboxed bats per session-id.bats
24
+ # convention), the marker lives at "${SESSION_MARKER_DIR}/itil-runtime-sid.current"
25
+ # — a single fixed filename, no per-user/per-project scoping. Tests
26
+ # create and tear down their own SANDBOX_TMP, so cross-test pollution
27
+ # is impossible without further scoping.
28
+ #
29
+ # In production (no SESSION_MARKER_DIR), the path is
30
+ # "/tmp/itil-runtime-sid-${USER}-${proj_hash}.current" where
31
+ # proj_hash = cksum of $PWD. Two Claude Code sessions in DIFFERENT
32
+ # projects do not race (different proj_hash). Two sessions in the
33
+ # SAME project on the same machine still race; per ADR-050 this is
34
+ # accepted as a documented limitation — the failure mode is a hook-
35
+ # denied Write that the agent can recover from, not silent corruption.
36
+ #
37
+ # References:
38
+ # ADR-050 — runtime-SID instrumentation via PreToolUse (this surface).
39
+ # ADR-048 — gate-misfire recovery procedure (superseded by ADR-050 +
40
+ # P142 + this lib).
41
+ # ADR-038 — announce-marker contract (cold-path fallback consumer).
42
+ # ADR-009 — gate marker lifecycle.
43
+ # P142 — this fix's ticket.
44
+
45
+ # Echoes the runtime-SID marker path on stdout. Always exits 0.
46
+ #
47
+ # Usage:
48
+ # source packages/itil/hooks/lib/runtime-sid.sh
49
+ # path=$(runtime_sid_path)
50
+ runtime_sid_path() {
51
+ if [ -n "${SESSION_MARKER_DIR:-}" ]; then
52
+ echo "${SESSION_MARKER_DIR}/itil-runtime-sid.current"
53
+ return 0
54
+ fi
55
+ local user="${USER:-anon}"
56
+ local proj_hash
57
+ # cksum is POSIX; portable across macOS BSD and Linux GNU.
58
+ # Trailing whitespace stripped via awk; first field is the checksum.
59
+ proj_hash=$(printf '%s' "${PWD:-/}" | cksum 2>/dev/null | awk '{print $1}')
60
+ echo "/tmp/itil-runtime-sid-${user}-${proj_hash:-0}.current"
61
+ }
@@ -69,6 +69,30 @@ get_current_session_id() {
69
69
  return 0
70
70
  fi
71
71
 
72
+ # P142 / ADR-050: runtime-SID marker. The PreToolUse hook
73
+ # (itil-runtime-sid-marker.sh) writes the runtime stdin session_id
74
+ # to a per-machine marker on EVERY tool call. The helper, running
75
+ # inside a Bash tool call, reads the marker that the same Bash
76
+ # tool call's PreToolUse hook just wrote — by construction the
77
+ # current session's SID. This is the authoritative path; the
78
+ # announce-marker fallback below is the cold-path (no PreToolUse
79
+ # has fired yet in this session).
80
+ local rt_lib_dir
81
+ rt_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
82
+ if [ -f "${rt_lib_dir}/runtime-sid.sh" ]; then
83
+ # shellcheck source=runtime-sid.sh
84
+ source "${rt_lib_dir}/runtime-sid.sh"
85
+ local rt_path rt_sid
86
+ rt_path=$(runtime_sid_path)
87
+ if [ -s "$rt_path" ]; then
88
+ rt_sid=$(cat "$rt_path" 2>/dev/null)
89
+ if [ -n "$rt_sid" ]; then
90
+ echo "$rt_sid"
91
+ return 0
92
+ fi
93
+ fi
94
+ fi
95
+
72
96
  local marker_dir="${SESSION_MARKER_DIR:-/tmp}"
73
97
 
74
98
  # Marker-system priority order. Architect first because architect-
@@ -117,20 +117,14 @@ if check_create_gate "$SESSION_ID"; then
117
117
  exit 0
118
118
  fi
119
119
 
120
- # P144 / ADR-048: gate-misfire recovery hint. When SOME marker exists (for
121
- # any SID) but the gate denies, the agent is likely hitting the P124 Phase 3
122
- # helper regression `mark_step2_complete` succeeded but the marker landed
123
- # under the wrong UUID. Append a recovery pointer to the deny message so
124
- # the agent finds the documented two-tier procedure in SKILL.md Step 2
125
- # substep 7 instead of reaching for the brute-force-marker anti-pattern
126
- # (139-marker incident, 2026-04-28 P144 driver evidence).
127
- #
128
- # Routine first-creation deny (no marker for ANY SID in this session)
129
- # leaves the deny message unchanged the helper-bug signal is conditional.
130
- RECOVERY_HINT=""
131
- if compgen -G '/tmp/manage-problem-grep-*' > /dev/null 2>&1; then
132
- RECOVERY_HINT=" (Helper succeeded but SID mismatch detected — see manage-problem SKILL.md Step 2 substep 7.)"
133
- fi
134
-
135
- create_gate_deny "BLOCKED: Cannot Write '${BASENAME}' under docs/problems/ without running /wr-itil:manage-problem Step 2 (duplicate-check) first. New problem tickets MUST be created via the skill so the duplicate-prevention grep fires before the file lands. Invoke the Skill tool with skill='wr-itil:manage-problem' and a description of the new problem; Step 2 will grep for related existing tickets and surface any matches via AskUserQuestion before creating the new ticket. (P119)${RECOVERY_HINT}"
120
+ # P142 / ADR-050: the runtime-SID instrumentation hook
121
+ # (itil-runtime-sid-marker.sh) writes the runtime stdin session_id to a
122
+ # per-machine marker on every PreToolUse:Bash|Write|Edit|Read event. The
123
+ # `get_current_session_id` helper reads that marker as the authoritative
124
+ # SID, so the marker `mark_step2_complete` writes is bound to the same
125
+ # session_id this hook will see on the subsequent Write. SID-mismatch
126
+ # denial is structurally eliminated; the only remaining deny path is
127
+ # the routine "Step 2 grep has not run yet for this session" case, for
128
+ # which the deny message stays focused and skill-pointing.
129
+ create_gate_deny "BLOCKED: Cannot Write '${BASENAME}' under docs/problems/ without running /wr-itil:manage-problem Step 2 (duplicate-check) first. New problem tickets MUST be created via the skill so the duplicate-prevention grep fires before the file lands. Invoke the Skill tool with skill='wr-itil:manage-problem' and a description of the new problem; Step 2 will grep for related existing tickets and surface any matches via AskUserQuestion before creating the new ticket. (P119)"
136
130
  exit 0
@@ -187,16 +187,23 @@ set_marker() {
187
187
  [[ "$output" != *"BLOCKED"* ]]
188
188
  }
189
189
 
190
- # --- P144 / ADR-048: gate-misfire recovery hint on deny message ---
190
+ # --- P142 / ADR-050: deny-message simplicity post-supersession ---
191
191
  #
192
- # When the deny fires AND any /tmp/manage-problem-grep-* marker exists for
193
- # SOME SID, that's the helper-bug signal (P124 Phase 3 regression helper
194
- # returned wrong SID, marker exists but doesn't match runtime hook stdin).
195
- # The deny message appends a recovery pointer to direct the agent at the
196
- # documented two-tier procedure in SKILL.md Step 2 substep 7.
192
+ # ADR-048 documented a two-tier recovery procedure for SID-mismatch denials
193
+ # (when the helper picked a stale subprocess SID while the runtime hook saw
194
+ # the orchestrator SID). The hook appended a "Helper succeeded but SID
195
+ # mismatch detected see manage-problem SKILL.md Step 2 substep 7."
196
+ # pointer when SOME marker existed for SOME SID (the helper-bug signal).
197
197
  #
198
- # Routine first-creation deny (no marker exists for any SID at all) is
199
- # unchanged recovery hint MUST NOT appear.
198
+ # P142 / ADR-050 superseded ADR-048 by capturing the runtime stdin SID
199
+ # in a per-machine marker via a new PreToolUse hook
200
+ # (`itil-runtime-sid-marker.sh`). The helper reads the marker as
201
+ # authoritative; SID-mismatch is structurally impossible in routine flow.
202
+ # The conditional RECOVERY_HINT was removed; the deny message stays
203
+ # terse and skill-pointing regardless of marker presence.
204
+ #
205
+ # These tests pin that the deny message is INVARIANT of the
206
+ # /tmp/manage-problem-grep-* marker state (no recovery-hint branching).
200
207
 
201
208
  setup_other_sid_marker() {
202
209
  OTHER_SID="other-sid-$$-$RANDOM"
@@ -209,35 +216,21 @@ teardown_other_sid_marker() {
209
216
  fi
210
217
  }
211
218
 
212
- @test "deny without ANY /tmp/manage-problem-grep-* marker → deny message OMITS recovery hint" {
213
- # Scrub any markers so the helper-bug signal cannot fire.
219
+ @test "deny without ANY /tmp/manage-problem-grep-* marker → deny is terse, no recovery prose" {
214
220
  rm -f /tmp/manage-problem-grep-*
215
221
  run run_write_hook "$PWD/docs/problems/999-foo.open.md" "$SID"
216
222
  [ "$status" -eq 0 ]
217
223
  [[ "$output" == *"BLOCKED"* ]]
218
- # No marker exists for any SID → routine first-creation deny → no recovery hint.
219
224
  [[ "$output" != *"SID mismatch"* ]]
220
225
  [[ "$output" != *"Step 2 substep 7"* ]]
221
226
  }
222
227
 
223
- @test "deny with /tmp/manage-problem-grep-* marker for OTHER SID → deny message INCLUDES recovery hint" {
224
- # Scrub other markers first, then set a marker for a different SID.
225
- rm -f /tmp/manage-problem-grep-*
226
- setup_other_sid_marker
227
- run run_write_hook "$PWD/docs/problems/999-foo.open.md" "$SID"
228
- status=$?
229
- teardown_other_sid_marker
230
- [ "$status" -eq 0 ]
231
- [[ "$output" == *"BLOCKED"* ]]
232
- # Marker exists for OTHER SID → helper-bug signal → recovery hint appended.
233
- [[ "$output" == *"SID mismatch"* ]]
234
- [[ "$output" == *"Step 2 substep 7"* ]]
235
- }
236
-
237
- @test "recovery hint avoids ADR-038 jargon (no internal P-number jargon in deny string)" {
238
- # ADR-038 progressive disclosure — deny stays terse + actionable. Architect
239
- # advisory rejected "P124-Phase-3-regression" wording in favour of plain
240
- # "Helper succeeded but SID mismatch detected".
228
+ @test "deny with /tmp/manage-problem-grep-* marker for OTHER SID → deny is terse (post-ADR-050; no recovery hint)" {
229
+ # Pre-ADR-050 contract: an other-SID marker triggered the helper-bug
230
+ # recovery pointer. Post-ADR-050: the runtime-SID marker prevents
231
+ # SID-mismatch in routine flow, so the recovery pointer is removed.
232
+ # The deny message is identical regardless of marker presence — the
233
+ # only signal that matters is "this session has not run Step 2".
241
234
  rm -f /tmp/manage-problem-grep-*
242
235
  setup_other_sid_marker
243
236
  run run_write_hook "$PWD/docs/problems/999-foo.open.md" "$SID"
@@ -245,5 +238,6 @@ teardown_other_sid_marker() {
245
238
  teardown_other_sid_marker
246
239
  [ "$status" -eq 0 ]
247
240
  [[ "$output" == *"BLOCKED"* ]]
248
- [[ "$output" != *"P124-Phase-3-regression"* ]]
241
+ [[ "$output" != *"SID mismatch"* ]]
242
+ [[ "$output" != *"Step 2 substep 7"* ]]
249
243
  }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P142 / ADR-050: itil-runtime-sid-marker.sh PreToolUse hook.
4
+ #
5
+ # Behavioural contract:
6
+ # 1. Hook receives JSON on stdin with a `session_id` field.
7
+ # 2. Hook writes the session_id to the runtime-SID marker path
8
+ # (computed by `runtime_sid_path()` in lib/runtime-sid.sh).
9
+ # 3. Hook emits 0 bytes on stdout (ADR-045 Pattern 1: side-effect-only,
10
+ # silent-on-pass — no context budget burn per tool call).
11
+ # 4. Hook always exits 0 (fail-open — never block a tool call on
12
+ # marker write).
13
+ # 5. Empty session_id -> hook is a no-op (marker not touched).
14
+ # 6. Subsequent invocations OVERWRITE the marker (so a subprocess
15
+ # tool call replaces the orchestrator's SID with the subprocess's
16
+ # SID for the duration of the subprocess; the orchestrator's
17
+ # next tool call after subprocess exit overwrites it back).
18
+ #
19
+ # Per feedback_behavioural_tests.md (P081): tests assert the hook's
20
+ # observable effects (marker contents, stdout bytes, exit code) — NOT
21
+ # the source content of the hook script.
22
+
23
+ setup() {
24
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
25
+ HOOK="$SCRIPT_DIR/itil-runtime-sid-marker.sh"
26
+ SANDBOX_TMP=$(mktemp -d)
27
+ export SESSION_MARKER_DIR="$SANDBOX_TMP"
28
+ MARKER_PATH="$SANDBOX_TMP/itil-runtime-sid.current"
29
+ }
30
+
31
+ teardown() {
32
+ rm -rf "$SANDBOX_TMP"
33
+ unset SESSION_MARKER_DIR
34
+ }
35
+
36
+ # Helper: invoke the hook with a JSON stdin payload.
37
+ fire_hook() {
38
+ local json="$1"
39
+ echo "$json" | bash "$HOOK"
40
+ }
41
+
42
+ @test "hook writes session_id to runtime-SID marker" {
43
+ expected_uuid="aaaaaaaa-1111-2222-3333-444444444444"
44
+ fire_hook "{\"session_id\":\"$expected_uuid\",\"tool_name\":\"Bash\"}"
45
+ [ -f "$MARKER_PATH" ]
46
+ [ "$(cat "$MARKER_PATH")" = "$expected_uuid" ]
47
+ }
48
+
49
+ @test "hook is silent on stdout (ADR-045 Pattern 1)" {
50
+ expected_uuid="bbbbbbbb-1111-2222-3333-444444444444"
51
+ output=$(fire_hook "{\"session_id\":\"$expected_uuid\",\"tool_name\":\"Bash\"}")
52
+ [ -z "$output" ]
53
+ }
54
+
55
+ @test "hook exits 0 on success" {
56
+ expected_uuid="cccccccc-1111-2222-3333-444444444444"
57
+ echo "{\"session_id\":\"$expected_uuid\",\"tool_name\":\"Bash\"}" | bash "$HOOK"
58
+ [ "$?" -eq 0 ]
59
+ }
60
+
61
+ @test "hook overwrites prior marker on subsequent invocation" {
62
+ first_uuid="dddddddd-1111-2222-3333-444444444444"
63
+ second_uuid="eeeeeeee-1111-2222-3333-444444444444"
64
+ fire_hook "{\"session_id\":\"$first_uuid\",\"tool_name\":\"Bash\"}"
65
+ [ "$(cat "$MARKER_PATH")" = "$first_uuid" ]
66
+ fire_hook "{\"session_id\":\"$second_uuid\",\"tool_name\":\"Write\"}"
67
+ [ "$(cat "$MARKER_PATH")" = "$second_uuid" ]
68
+ }
69
+
70
+ @test "hook is a no-op when session_id is empty" {
71
+ fire_hook "{\"tool_name\":\"Bash\"}"
72
+ [ ! -f "$MARKER_PATH" ]
73
+ }
74
+
75
+ @test "hook is a no-op when stdin is not valid JSON" {
76
+ echo "not-json-at-all" | bash "$HOOK"
77
+ [ "$?" -eq 0 ]
78
+ [ ! -f "$MARKER_PATH" ]
79
+ }
80
+
81
+ @test "hook fail-open on jq absent (graceful degradation)" {
82
+ # Simulate jq absent by making PATH not include any jq binary.
83
+ expected_uuid="ffffffff-1111-2222-3333-444444444444"
84
+ result=$(echo "{\"session_id\":\"$expected_uuid\",\"tool_name\":\"Bash\"}" | env PATH="/usr/bin:/bin" bash "$HOOK"; echo "EXIT:$?")
85
+ # Either jq is in /usr/bin (fine — marker written), or it's absent
86
+ # (hook should still exit 0 without crashing). The exit-0 contract
87
+ # is the load-bearing assertion; marker presence is a bonus when
88
+ # jq is available.
89
+ [[ "$result" == *"EXIT:0"* ]]
90
+ }
@@ -176,3 +176,83 @@ mark_announced() {
176
176
  [[ "$output" != *"$middle_uuid"* ]]
177
177
  [[ "$output" == *"EXIT:0"* ]]
178
178
  }
179
+
180
+ # --- Behavioural contract: runtime-SID marker (P142 Phase 4) ---
181
+ #
182
+ # Phase 3 mtime-based within-system selection introduced a regression
183
+ # in orchestrator main turns AFTER subprocess dispatch: subprocess
184
+ # announce markers have NEWER mtime than the orchestrator's, so the
185
+ # helper picked the subprocess SID while the runtime hook stdin still
186
+ # contained the orchestrator SID — marker landed under the wrong UUID,
187
+ # create-gate (P119) denied. Mirror failure mode would fire in
188
+ # subprocess context if the priority list were re-ordered to favour
189
+ # orchestrator-only announce systems (no pure-helper algorithm can
190
+ # distinguish "running in orchestrator main turn" from "running in
191
+ # subprocess" by filesystem state alone).
192
+ #
193
+ # Phase 4 structural fix: a new PreToolUse hook
194
+ # (`itil-runtime-sid-marker.sh`) writes the runtime stdin session_id
195
+ # to a per-machine marker on every tool call. The helper reads this
196
+ # marker FIRST as the authoritative current-session SID, falling back
197
+ # to the existing announce-marker priority logic when the marker is
198
+ # absent (cold path — first tool call of a session, before any
199
+ # PreToolUse fires).
200
+ #
201
+ # Sandbox path: when SESSION_MARKER_DIR is set (test override), the
202
+ # runtime marker lives at `${SESSION_MARKER_DIR}/itil-runtime-sid.current`
203
+ # — a single fixed filename, no per-user/per-project scoping. The
204
+ # scoping suffix used in prod (`-${USER}-${proj_hash}`) is irrelevant
205
+ # under sandbox because every test gets a fresh SANDBOX_TMP.
206
+
207
+ @test "runtime-SID marker present: helper returns marker contents over newer announce markers" {
208
+ runtime_uuid="dddddddd-dddd-dddd-dddd-dddddddddddd"
209
+ decoy_uuid="eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"
210
+ # Decoy: an architect-announced marker with a NEWER mtime than the
211
+ # runtime marker. The Phase 3 helper would have picked decoy_uuid;
212
+ # the Phase 4 helper picks runtime_uuid because the runtime-SID
213
+ # marker is authoritative.
214
+ printf '%s' "$runtime_uuid" > "$SANDBOX_TMP/itil-runtime-sid.current"
215
+ sleep 1
216
+ mark_announced "architect" "$decoy_uuid"
217
+ output=$(discover)
218
+ [[ "$output" == *"$runtime_uuid"* ]]
219
+ [[ "$output" != *"$decoy_uuid"* ]]
220
+ [[ "$output" == *"EXIT:0"* ]]
221
+ }
222
+
223
+ @test "runtime-SID marker empty: helper falls back to announce-marker priority" {
224
+ expected_uuid="ffffffff-ffff-ffff-ffff-ffffffffffff"
225
+ mark_announced "architect" "$expected_uuid"
226
+ # Empty runtime marker (zero-byte file) — helper must NOT return
227
+ # the empty contents; it must fall through to the announce-marker
228
+ # scrape. Empty marker can occur if the hook ran with empty
229
+ # session_id stdin (test harness, hook self-test) and would still
230
+ # leave the file at zero bytes per the hook's empty-input fail-open.
231
+ : > "$SANDBOX_TMP/itil-runtime-sid.current"
232
+ output=$(discover)
233
+ [[ "$output" == *"$expected_uuid"* ]]
234
+ [[ "$output" == *"EXIT:0"* ]]
235
+ }
236
+
237
+ @test "runtime-SID marker absent (cold path): helper uses announce-marker priority unchanged" {
238
+ expected_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
239
+ mark_announced "architect" "$expected_uuid"
240
+ # No runtime marker created — cold path. Helper falls back to
241
+ # existing Phase 3 announce-marker priority. This test pins the
242
+ # backwards-compat contract: sessions whose first tool call hasn't
243
+ # yet fired the PreToolUse hook still discover their SID via the
244
+ # announce-marker fallback (the priority list is preserved as-is).
245
+ output=$(discover)
246
+ [[ "$output" == *"$expected_uuid"* ]]
247
+ [[ "$output" == *"EXIT:0"* ]]
248
+ }
249
+
250
+ @test "env var beats runtime-SID marker (env-var fast path preserved)" {
251
+ env_uuid="11111111-aaaa-bbbb-cccc-222222222222"
252
+ marker_uuid="22222222-aaaa-bbbb-cccc-333333333333"
253
+ printf '%s' "$marker_uuid" > "$SANDBOX_TMP/itil-runtime-sid.current"
254
+ output=$(CLAUDE_SESSION_ID="$env_uuid" SESSION_MARKER_DIR="$SANDBOX_TMP" bash -c "source '$HELPER'; get_current_session_id; echo \"EXIT:\$?\"")
255
+ [[ "$output" == *"$env_uuid"* ]]
256
+ [[ "$output" != *"$marker_uuid"* ]]
257
+ [[ "$output" == *"EXIT:0"* ]]
258
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.23.2",
3
+ "version": "0.23.3",
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"
@@ -271,46 +271,7 @@ Before creating, search existing problems for similar issues. The user may not k
271
271
 
272
272
  **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.
273
273
 
274
- <!-- supersedes-when: P142 ships -->
275
- **Recovery if hook denial persists** (P144 / ADR-048 — auto-supersedes when P142 ships):
276
-
277
- The P124 helper has a Phase 3 regression in orchestrator sessions that have dispatched subprocesses: it sometimes returns a subprocess SID instead of the orchestrator SID, while the runtime hook stdin still contains the orchestrator SID. The marker lands under the wrong UUID; the next `Write` is denied even though `mark_step2_complete` succeeded. The hook deny message includes a `(Helper succeeded but SID mismatch detected — see manage-problem SKILL.md Step 2 substep 7.)` pointer when this signal is observable.
278
-
279
- **Gate-misfire signal** — recovery applies ONLY when ALL three conditions hold:
280
- 1. The agent is **already executing** `/wr-itil:manage-problem` Step 2 in this turn (i.e., the SKILL contract has just ordered the grep for THIS ticket creation — not a marker carried over from a prior unrelated invocation in the same session).
281
- 2. `mark_step2_complete` succeeded (the helper exited zero — no SID-discovery error).
282
- 3. The subsequent `Write` to the new `.<status>.md` file is denied by the P119 hook.
283
-
284
- Routine creation flow does NOT match these conditions and MUST continue through the standard `Write` path. The recovery is mechanical (deterministic from the gate-misfire signal — no `AskUserQuestion` required, per ADR-044's framework-mediated surface catalog extension).
285
-
286
- **First-tier recovery — announce-marker scrape**:
287
-
288
- ```bash
289
- # Discover the orchestrator session UUID via the most-reliable per-session announce marker.
290
- # The orchestrator SID is what the runtime hook stdin contains in the common subprocess case.
291
- sid=$(ls -t /tmp/itil-assistant-gate-announced-* 2>/dev/null | head -1 | sed 's|.*itil-assistant-gate-announced-||')
292
- [ -n "$sid" ] && touch "/tmp/manage-problem-grep-${sid}"
293
- # Retry the Write.
294
- ```
295
-
296
- **Second-tier recovery — python3-via-Bash file-write** (2026-04-29 evidence: runtime hook stdin SID may not be in any announce-marker class; first-tier returns the orchestrator SID, but the runtime SID is a different per-Write SID surfaced only by `architect-reviewed-*` mtime, not by any announce-marker):
297
-
298
- ```bash
299
- # The hook is PreToolUse:Write; python3-in-Bash is not a Write tool call,
300
- # so the hook never fires. Use only when first-tier fails.
301
- python3 -c "from pathlib import Path; Path('docs/problems/<NNN>-<title>.open.md').write_text('''<file body>''')"
302
- ```
303
-
304
- **Audit-trail-preservation test** — the second-tier procedure is sanctioned ONLY in the audit-trail-preserved branch:
305
-
306
- - ✅ **Audit-trail-preserved**: the agent is currently executing `/wr-itil:manage-problem` Step 2 for THIS ticket creation (gate-misfire signal condition 1), AND any `/tmp/manage-problem-grep-*` marker exists. The skill flow itself is the just-ran-grep witness; the marker existence corroborates it.
307
- - ❌ **Audit-trail-violated**: the agent is NOT in `/wr-itil:manage-problem` Step 2 for this ticket creation, OR no marker exists for any SID. Routine first-creation flow MUST hit the gate; the recovery procedure does NOT apply.
308
-
309
- **Anti-pattern bound** — the loose reading "any marker from any earlier `manage-problem` invocation in this session" would let the recovery procedure apply to a fresh ticket creation that happens to reuse a stale marker from a prior unrelated invocation. That is the P131 anti-pattern surface (gate state as a workaround target instead of as a directive). The bound holds because the recovery is invoked from inside an active manage-problem flow where Step 2 has just been ordered for THIS ticket, AND the python3-via-Bash branch is named in this substep so its invocation is itself audit-trail-emitting.
310
-
311
- **DO NOT brute-force-touch markers for every announced UUID.** That pattern (139 markers in one session, 2026-04-28 P144 evidence) satisfies the marker shape while gaming the audit trail the marker is supposed to record. The user has explicitly rejected this pattern: *"WTF? Why did you bypass instead of using the skill?"* (P144 driver correction). Brute-forcing markers for SIDs that did not run Step 2 is the canonical bypass — the recovery procedure above is the canonical use of the skill.
312
-
313
- **Cross-references**: P124 (helper Phase 3 regression — driver of the misfire); P142 (P124 Phase 4 — structural fix that auto-supersedes this recovery when shipped); P131 (gate-exclusions-as-write-permission — adjacent anti-pattern family); ADR-048 (sanctioning + scoping ADR); ADR-009 (gate marker lifecycle); ADR-044 (mechanical-decision framework-mediated surface catalog).
274
+ **Phase 4 (P142 / ADR-050)** — the helper now reads the runtime stdin `session_id` from a per-machine marker written by the `itil-runtime-sid-marker.sh` PreToolUse hook on every tool call. Because every Bash call that sources the helper is itself a PreToolUse:Bash event, the marker the helper reads was written moments earlier with the same `session_id` the runtime Write hook will see — so SID-mismatch denial is structurally impossible in the routine flow. The Phase 3 announce-marker priority logic is preserved as cold-path fallback (first tool call of a session, before any PreToolUse fires).
314
275
 
315
276
  **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.
316
277
 
@@ -1,165 +0,0 @@
1
- #!/usr/bin/env bats
2
- #
3
- # packages/itil/skills/manage-problem/test/manage-problem-p119-recovery-path.bats
4
- #
5
- # Behavioural tests for manage-problem Step 2 substep 7's P119 hook-misfire
6
- # recovery procedure (P144 / ADR-048).
7
- #
8
- # Step 2 substep 7 documents a two-tier recovery for the case where
9
- # `mark_step2_complete` succeeded but the P119 PreToolUse:Write hook still
10
- # denies the new ticket Write — typically because the P124 helper returned
11
- # a subprocess SID instead of the orchestrator SID (ADR-048 Phase 3
12
- # regression). Without documented recovery, the agent reaches for the
13
- # brute-force-touch-every-marker anti-pattern (139-marker incident,
14
- # 2026-04-28). User correction was emphatic: "WTF? Why did you bypass
15
- # instead of using the skill?"
16
- #
17
- # This bats fixes the contract:
18
- # - Sub-block names the gate-misfire signal (active flow + helper-succeeded
19
- # + Write-denied conjunction).
20
- # - Two-tier procedure named (first-tier announce-marker scrape; second-tier
21
- # python3-via-Bash file-write).
22
- # - Audit-trail-preservation test as the gate-on-sanctioning rule.
23
- # - Anti-pattern call-out ("DO NOT brute-force") in durable form.
24
- # - ADR-048, P124, P142 cross-references.
25
- # - <!-- supersedes-when: P142 ships --> HTML comment for cleanup
26
- # discoverability.
27
- #
28
- # tdd-review: structural-permitted (justification: skill behavioural
29
- # harness pending P012 + P081 Phase 2; SKILL.md contract assertions
30
- # bridge until then; expected to migrate to behavioural form once
31
- # the harness exists).
32
- #
33
- # @problem P144
34
- # @adr ADR-048 (Documented recovery from gate misfire is the prescribed surface, not bypass)
35
- # @adr ADR-009 (gate marker lifecycle)
36
- # @adr ADR-013 Rule 5 (policy-authorised silent proceed)
37
- # @adr ADR-022 (problem lifecycle status suffixes)
38
- # @adr ADR-037 / P081 (testing strategy — bridge during harness build)
39
- # @adr ADR-038 (progressive disclosure — deny message terse)
40
- # @adr ADR-044 (decision-delegation — recovery is mechanical)
41
- # @jtbd JTBD-001 / JTBD-101 / JTBD-201
42
-
43
- SKILL_FILE="${BATS_TEST_DIRNAME}/../SKILL.md"
44
-
45
- setup() {
46
- [ -f "$SKILL_FILE" ]
47
- }
48
-
49
- # Bound the search to Step 2 substep 7 region (between Step 2 heading and Step 3 heading).
50
- step2_text() {
51
- awk '/^### 2\. /,/^### 3\. /' "$SKILL_FILE"
52
- }
53
-
54
- # ── Recovery sub-block presence ─────────────────────────────────────────────
55
-
56
- @test "Step 2 SKILL.md contains a Recovery sub-block for hook-denial misfire" {
57
- run step2_text
58
- [ "$status" -eq 0 ]
59
- [[ "$output" == *"Recovery"* ]]
60
- [[ "$output" == *"hook denial"* ]] || [[ "$output" == *"hook still denies"* ]] || [[ "$output" == *"deny"* ]]
61
- }
62
-
63
- # ── Gate-misfire signal definition ──────────────────────────────────────────
64
-
65
- @test "Step 2 SKILL.md names the gate-misfire signal precondition (active manage-problem flow)" {
66
- run step2_text
67
- [ "$status" -eq 0 ]
68
- # The signal requires that the agent is already executing manage-problem
69
- # Step 2 in the current turn — not just any prior session marker.
70
- [[ "$output" == *"already executing"* ]] || [[ "$output" == *"active"* ]] || [[ "$output" == *"this turn"* ]]
71
- }
72
-
73
- @test "Step 2 SKILL.md names mark_step2_complete success as part of the misfire signal" {
74
- run step2_text
75
- [ "$status" -eq 0 ]
76
- [[ "$output" == *"mark_step2_complete"* ]]
77
- }
78
-
79
- # ── Two-tier procedure ──────────────────────────────────────────────────────
80
-
81
- @test "Step 2 SKILL.md names the first-tier recovery (announce-marker scrape)" {
82
- run step2_text
83
- [ "$status" -eq 0 ]
84
- [[ "$output" == *"first-tier"* ]] || [[ "$output" == *"First-tier"* ]]
85
- [[ "$output" == *"itil-assistant-gate-announced"* ]]
86
- }
87
-
88
- @test "Step 2 SKILL.md names the second-tier recovery (python3-via-Bash file-write)" {
89
- run step2_text
90
- [ "$status" -eq 0 ]
91
- [[ "$output" == *"second-tier"* ]] || [[ "$output" == *"Second-tier"* ]]
92
- [[ "$output" == *"python3"* ]]
93
- [[ "$output" == *"Bash"* ]]
94
- }
95
-
96
- # ── Audit-trail-preservation test ───────────────────────────────────────────
97
-
98
- @test "Step 2 SKILL.md states the audit-trail-preservation test as the sanctioning rule" {
99
- run step2_text
100
- [ "$status" -eq 0 ]
101
- [[ "$output" == *"audit-trail"* ]] || [[ "$output" == *"audit trail"* ]]
102
- }
103
-
104
- @test "Step 2 SKILL.md names the anti-pattern bound (any-marker-anywhere is NOT the test)" {
105
- # Architect advisory: the bound must rule out the loose "any marker from any
106
- # earlier invocation in this session" reading — that's the P131 surface.
107
- run step2_text
108
- [ "$status" -eq 0 ]
109
- [[ "$output" == *"this ticket"* ]] || [[ "$output" == *"THIS ticket"* ]]
110
- }
111
-
112
- # ── Anti-pattern call-out (durable surface) ─────────────────────────────────
113
-
114
- @test "Step 2 SKILL.md contains the explicit DO-NOT-brute-force anti-pattern wording" {
115
- run step2_text
116
- [ "$status" -eq 0 ]
117
- [[ "$output" == *"DO NOT brute-force"* ]] || [[ "$output" == *"do not brute-force"* ]] || [[ "$output" == *"Do not brute-force"* ]]
118
- }
119
-
120
- @test "Step 2 SKILL.md cites the 2026-04-28 user correction context for the anti-pattern" {
121
- run step2_text
122
- [ "$status" -eq 0 ]
123
- [[ "$output" == *"P144"* ]]
124
- }
125
-
126
- # ── Cross-references ────────────────────────────────────────────────────────
127
-
128
- @test "Step 2 SKILL.md cites ADR-048 for the recovery procedure scope" {
129
- run step2_text
130
- [ "$status" -eq 0 ]
131
- [[ "$output" == *"ADR-048"* ]]
132
- }
133
-
134
- @test "Step 2 SKILL.md cites P124 as the helper-bug source" {
135
- run step2_text
136
- [ "$status" -eq 0 ]
137
- [[ "$output" == *"P124"* ]]
138
- }
139
-
140
- @test "Step 2 SKILL.md cites P142 as the structural fix (supersession trigger)" {
141
- run step2_text
142
- [ "$status" -eq 0 ]
143
- [[ "$output" == *"P142"* ]]
144
- }
145
-
146
- # ── Supersession comment (CI-enforced cleanup invariant) ────────────────────
147
-
148
- @test "Step 2 SKILL.md carries the supersedes-when HTML comment so cleanup is discoverable" {
149
- # ADR-048 Reassessment Criteria: when P142's resolution ADR is accepted,
150
- # this comment must be removed from SKILL.md source. Today the comment
151
- # is present and this assertion passes; once P142 lands, the cleanup
152
- # signal lives here.
153
- run step2_text
154
- [ "$status" -eq 0 ]
155
- [[ "$output" == *"supersedes-when"* ]]
156
- [[ "$output" == *"P142"* ]]
157
- }
158
-
159
- # ── Mechanical (no-AskUserQuestion) per ADR-044 ─────────────────────────────
160
-
161
- @test "Step 2 SKILL.md states the recovery is mechanical (no AskUserQuestion required)" {
162
- run step2_text
163
- [ "$status" -eq 0 ]
164
- [[ "$output" == *"mechanical"* ]] || [[ "$output" == *"ADR-044"* ]]
165
- }