@windyroad/itil 0.35.13 → 0.35.14-preview.415

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.
@@ -484,5 +484,5 @@
484
484
  }
485
485
  },
486
486
  "name": "wr-itil",
487
- "version": "0.35.13"
487
+ "version": "0.35.14"
488
488
  }
@@ -52,6 +52,39 @@ mark_step2_complete() {
52
52
  : > "/tmp/manage-problem-grep-${SESSION_ID}"
53
53
  }
54
54
 
55
+ # P260 / ADR-050 Option C: write the Step 2 grep marker under EVERY
56
+ # candidate SID read from stdin (one UUID per line, as emitted by
57
+ # `get_candidate_session_ids` in lib/session-id.sh).
58
+ #
59
+ # Under concurrent orchestrator+subprocess sessions in the same project,
60
+ # the per-machine runtime-sid marker is last-writer-wins, so agent-side
61
+ # code cannot reliably PREDICT which single SID the create-gate hook will
62
+ # read from the Write's stdin (ADR-050 §Context). Marking under every recent
63
+ # candidate guarantees a matching marker exists whichever SID the hook reads,
64
+ # eliminating the P260 marker-mismatch deny without a process-topology
65
+ # assumption. The candidate set is bounded to recent announce markers + the
66
+ # runtime-sid value by `get_candidate_session_ids` (NOT a global fail-open —
67
+ # the P119 audit invariant holds: each marker still records that THIS session
68
+ # ran the duplicate-check grep).
69
+ #
70
+ # Reuses the unchanged single-SID `mark_step2_complete` per candidate (same
71
+ # idempotent `/tmp/manage-problem-grep-${SID}` marker class — no new
72
+ # convention). Blank/whitespace lines are skipped. Returns 0 if at least one
73
+ # marker was written, 1 if no candidate SIDs were supplied (fail-closed
74
+ # parity with the empty-SID single-write no-op — the subsequent Write would
75
+ # be denied and the agent recovers by re-running Step 2).
76
+ #
77
+ # Usage: get_candidate_session_ids | mark_step2_complete_candidates
78
+ mark_step2_complete_candidates() {
79
+ local sid count=0
80
+ while IFS= read -r sid; do
81
+ [ -n "$sid" ] || continue
82
+ mark_step2_complete "$sid"
83
+ count=$((count + 1))
84
+ done
85
+ [ "$count" -gt 0 ]
86
+ }
87
+
55
88
  # Returns 0 if the RFC capture-step marker exists for SESSION_ID; 1 otherwise.
56
89
  # Empty SESSION_ID => returns 1 (no marker).
57
90
  #
@@ -153,3 +153,76 @@ get_current_session_id() {
153
153
 
154
154
  return 1
155
155
  }
156
+
157
+ # P260 / ADR-050 Option C: bounded multi-UUID candidate enumeration.
158
+ #
159
+ # Echoes EVERY candidate session UUID — one per line, deduplicated — that
160
+ # the create-gate hook (manage-problem-enforce-create.sh) might read from
161
+ # the Write's stdin `session_id`. The create-gate marker-write
162
+ # (`mark_step2_complete_candidates`, lib/create-gate.sh) writes the marker
163
+ # under each, so whichever SID the hook reads, a matching marker provably
164
+ # exists.
165
+ #
166
+ # Why enumerate instead of picking one (get_current_session_id):
167
+ # `/wr-itil:work-problems` Step 5 BACKGROUNDS the iter subprocess and runs
168
+ # the orchestrator's poll loop in the main turn, so the orchestrator's
169
+ # PreToolUse hooks fire CONCURRENTLY with the subprocess. Both sessions
170
+ # write the same per-machine runtime-sid marker (same project => same
171
+ # proj_hash), last-writer-wins. When the orchestrator captures a ticket
172
+ # while the subprocess holds the runtime-sid, `get_current_session_id`
173
+ # returns the SUBPROCESS SID, but the orchestrator's Write carries the
174
+ # ORCHESTRATOR SID on its stdin — marker mismatch, create-gate deny (P260).
175
+ # ADR-050 §Context establishes that no agent-side algorithm can PREDICT
176
+ # the right single SID from filesystem state alone. Option C stops
177
+ # predicting and writes under every recent candidate instead.
178
+ #
179
+ # Candidate set (each line is one UUID):
180
+ # 1. `get_current_session_id`'s pick (env-var > runtime-sid > announce-
181
+ # marker priority). Emitting this FIRST guarantees the candidate set is
182
+ # never a strict subset of the prior single-SID behaviour — Option C
183
+ # only ADDS the concurrent-session SIDs.
184
+ # 2. Every announce-marker UUID across ALL systems whose marker mtime is
185
+ # within the window (the concurrently-active sessions: orchestrator +
186
+ # its running subprocess(es)). Announce markers are write-once-per-
187
+ # session (ADR-038, no touch-refresh), so mtime is the announcing
188
+ # session's first-prompt timestamp — a stable bound.
189
+ #
190
+ # Bounding (NOT a global fail-open):
191
+ # The mtime window (SESSION_CANDIDATE_WINDOW_MINS, default 1440 = 24h)
192
+ # bounds the enumeration against the P124 stale-marker pathology (103
193
+ # accumulated UUIDs selecting the wrong SID). 24h comfortably covers any
194
+ # realistic single AFK loop while excluding multi-day marker accumulation.
195
+ # Extra markers under recently-stale UUIDs are HARMLESS — empty files; the
196
+ # hook only matches the marker equal to the Write's stdin SID. The P119
197
+ # audit invariant holds: every marker still records that THIS session ran
198
+ # the duplicate-check grep (the marker is only written because Step 2's
199
+ # grep provably ran this turn; widening WHICH SID files receive that proof
200
+ # does not weaken the proof). A loop running >24h degrades gracefully to
201
+ # the recoverable create-gate deny (status quo), not silent corruption.
202
+ #
203
+ # Test overrides: SESSION_MARKER_DIR (marker dir, default /tmp) +
204
+ # SESSION_CANDIDATE_WINDOW_MINS (window minutes, default 1440).
205
+ #
206
+ # Usage:
207
+ # source packages/itil/hooks/lib/session-id.sh
208
+ # source packages/itil/hooks/lib/create-gate.sh
209
+ # get_candidate_session_ids | mark_step2_complete_candidates
210
+ get_candidate_session_ids() {
211
+ local marker_dir="${SESSION_MARKER_DIR:-/tmp}"
212
+ local window_mins="${SESSION_CANDIDATE_WINDOW_MINS:-1440}"
213
+ {
214
+ # Guaranteed member: the single-SID discovery's pick. Suppress its
215
+ # non-zero exit (no-SID cold path) so the pipeline still emits the
216
+ # enumerated candidates.
217
+ get_current_session_id 2>/dev/null || true
218
+
219
+ # Concurrent-session SIDs: every recent announce marker across all
220
+ # systems, within the mtime window. `*-announced-*` is system-agnostic
221
+ # (picks up any present or future announcing plugin). `-maxdepth 1` and
222
+ # `-mmin -N` are portable across BSD (macOS) and GNU find. The sed strips
223
+ # the leading path then the `<system>-announced-` prefix, leaving the
224
+ # trailing UUID (UUIDs never contain the literal "-announced-").
225
+ find "$marker_dir" -maxdepth 1 -name '*-announced-*' -mmin "-${window_mins}" 2>/dev/null \
226
+ | sed 's|.*/||; s/.*-announced-//'
227
+ } | awk 'NF && !seen[$0]++'
228
+ }
@@ -345,3 +345,80 @@ teardown_other_sid_marker() {
345
345
  [ "$status" -eq 0 ]
346
346
  [[ "$output" != *"BLOCKED"* ]]
347
347
  }
348
+
349
+ # --- P260: concurrent orchestrator+subprocess create-gate marker race ---
350
+ #
351
+ # /wr-itil:work-problems Step 5 BACKGROUNDS the iter subprocess
352
+ # (`claude -p ... &`) and runs an idle-timeout poll loop in the
353
+ # orchestrator's main turn, so the orchestrator fires PreToolUse hooks
354
+ # CONCURRENTLY with the running subprocess. Both sessions write the same
355
+ # per-machine runtime-sid marker (same project ⇒ same proj_hash),
356
+ # last-writer-wins. When the orchestrator captures a ticket while the
357
+ # subprocess holds the runtime-sid, the single-SID Step-2 marker-write
358
+ # (`sid=$(get_current_session_id) && mark_step2_complete "$sid"`) lands the
359
+ # marker under the SUBPROCESS SID, but the orchestrator's Write fires the
360
+ # PreToolUse:Write hook whose stdin session_id is the ORCHESTRATOR SID ⇒
361
+ # marker mismatch ⇒ deny. This was P260 (the 2026-05-18 P254/P255 foreground
362
+ # captures). ADR-050 Option C (architect-resolved + human-confirmed
363
+ # 2026-05-26) is the fix: the candidate-set marker-write
364
+ # (`get_candidate_session_ids | mark_step2_complete_candidates`) writes the
365
+ # marker under EVERY recent candidate SID, so whichever SID the hook reads,
366
+ # a matching marker exists.
367
+ #
368
+ # These tests are end-to-end: they run the real agent-side marker-write
369
+ # helpers under the concurrent fixture, then fire the real hook with the
370
+ # orchestrator's stdin SID and assert the observable permit/deny outcome.
371
+
372
+ # Build the concurrent fixture in a sandbox marker dir: orchestrator
373
+ # announced first (older mtime), subprocess announced later (newer mtime),
374
+ # and the subprocess clobbered the runtime-sid marker last.
375
+ p260_setup_concurrent_fixture() {
376
+ P260_SANDBOX=$(mktemp -d)
377
+ P260_ORCH_SID="p260-orch-$$-$RANDOM"
378
+ P260_SUB_SID="p260-sub-$$-$RANDOM"
379
+ : > "$P260_SANDBOX/architect-announced-${P260_ORCH_SID}"
380
+ sleep 1
381
+ : > "$P260_SANDBOX/jtbd-announced-${P260_SUB_SID}"
382
+ printf '%s' "$P260_SUB_SID" > "$P260_SANDBOX/itil-runtime-sid.current"
383
+ }
384
+
385
+ p260_teardown_concurrent_fixture() {
386
+ rm -f "/tmp/manage-problem-grep-${P260_ORCH_SID}" \
387
+ "/tmp/manage-problem-grep-${P260_SUB_SID}"
388
+ rm -rf "$P260_SANDBOX"
389
+ }
390
+
391
+ @test "P260: candidate-set marker-write PERMITS the orchestrator Write despite runtime-sid clobbered to the subprocess SID" {
392
+ p260_setup_concurrent_fixture
393
+ # Agent-side Step 2 marker write under the FULL candidate set (Option C).
394
+ SESSION_MARKER_DIR="$P260_SANDBOX" bash -c "
395
+ source '$SCRIPT_DIR/lib/session-id.sh'
396
+ source '$SCRIPT_DIR/lib/create-gate.sh'
397
+ get_candidate_session_ids | mark_step2_complete_candidates
398
+ "
399
+ # The orchestrator's Write carries the orchestrator SID on its stdin.
400
+ run run_write_hook "$PWD/docs/problems/997-concurrent-capture.open.md" "$P260_ORCH_SID"
401
+ p260_teardown_concurrent_fixture
402
+ [ "$status" -eq 0 ]
403
+ [[ "$output" != *"BLOCKED"* ]]
404
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
405
+ }
406
+
407
+ @test "P260 negative control: single-SID marker-write (pre-Option-C behaviour) DENIES the orchestrator Write under the same race" {
408
+ # Pins WHY Option C is needed: the old single-SID write lands the marker
409
+ # under the subprocess SID (the clobbered runtime value), so the
410
+ # orchestrator's Write — stdin = orchestrator SID — is denied. This is
411
+ # the reproduced P260 failure; the candidate-set write above is what
412
+ # fixes it.
413
+ p260_setup_concurrent_fixture
414
+ SESSION_MARKER_DIR="$P260_SANDBOX" bash -c "
415
+ source '$SCRIPT_DIR/lib/session-id.sh'
416
+ source '$SCRIPT_DIR/lib/create-gate.sh'
417
+ sid=\$(get_current_session_id) && mark_step2_complete \"\$sid\"
418
+ "
419
+ run run_write_hook "$PWD/docs/problems/996-concurrent-capture.open.md" "$P260_ORCH_SID"
420
+ p260_teardown_concurrent_fixture
421
+ [ "$status" -eq 0 ]
422
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
423
+ [[ "$output" == *"BLOCKED"* ]]
424
+ }
@@ -256,3 +256,96 @@ mark_announced() {
256
256
  [[ "$output" != *"$marker_uuid"* ]]
257
257
  [[ "$output" == *"EXIT:0"* ]]
258
258
  }
259
+
260
+ # --- Behavioural contract: candidate enumeration (Option C, P260) ---
261
+ #
262
+ # Under concurrent orchestrator+subprocess sessions in the SAME project,
263
+ # the per-machine runtime-sid marker is last-writer-wins, so the single-SID
264
+ # get_current_session_id cannot reliably predict which SID the create-gate
265
+ # hook will read from the Write's stdin (the orchestrator's Write carries
266
+ # the orchestrator SID; the subprocess may have just clobbered the runtime
267
+ # marker with its own SID). No agent-side algorithm can PREDICT the right
268
+ # single SID from filesystem state alone (ADR-050 §Context). Option C stops
269
+ # predicting: get_candidate_session_ids returns EVERY recent candidate SID
270
+ # — the get_current_session_id pick PLUS every recent announce-marker UUID
271
+ # across all systems within the mtime window, deduplicated — so the marker
272
+ # can be written under all of them and a match provably exists whichever
273
+ # SID the hook reads. Bounded by SESSION_CANDIDATE_WINDOW_MINS (24h default)
274
+ # to avoid the P124 stale-marker pathology (103 accumulated UUIDs); NOT a
275
+ # global fail-open (the P119 audit invariant holds: every marker still
276
+ # records that THIS session ran the duplicate-check grep).
277
+ #
278
+ # Per feedback_behavioural_tests.md (P081): tests assert the emitted
279
+ # candidate set, not the source content of the helper.
280
+
281
+ # Helper: source the helper and emit the candidate SID set (one per line).
282
+ candidates() {
283
+ bash -c "source '$HELPER'; get_candidate_session_ids"
284
+ }
285
+
286
+ @test "candidates: returns BOTH the orchestrator and subprocess announce-marker UUIDs" {
287
+ orch_uuid="aaaaaaaa-0000-1111-2222-orchestrator0"
288
+ sub_uuid="bbbbbbbb-0000-1111-2222-subprocess00"
289
+ # Orchestrator announced first (loop start); subprocess announced later
290
+ # (dispatched mid-loop) — the subprocess marker has the NEWER mtime,
291
+ # exactly the condition that made the single-SID helper pick the wrong
292
+ # SID and deny the orchestrator's Write (the P260 race). Candidate
293
+ # enumeration must surface BOTH so neither is left out of the marker set.
294
+ mark_announced "architect" "$orch_uuid"
295
+ sleep 1
296
+ mark_announced "jtbd" "$sub_uuid"
297
+ output=$(candidates)
298
+ [[ "$output" == *"$orch_uuid"* ]]
299
+ [[ "$output" == *"$sub_uuid"* ]]
300
+ }
301
+
302
+ @test "candidates: includes the runtime-sid value even when no announce marker carries it" {
303
+ rt_uuid="cccccccc-0000-1111-2222-runtimeonly0"
304
+ printf '%s' "$rt_uuid" > "$SANDBOX_TMP/itil-runtime-sid.current"
305
+ output=$(candidates)
306
+ [[ "$output" == *"$rt_uuid"* ]]
307
+ }
308
+
309
+ @test "candidates: deduplicates a SID present in both the runtime-sid marker and an announce marker" {
310
+ dup_uuid="dddddddd-0000-1111-2222-duplicated00"
311
+ printf '%s' "$dup_uuid" > "$SANDBOX_TMP/itil-runtime-sid.current"
312
+ mark_announced "architect" "$dup_uuid"
313
+ output=$(candidates)
314
+ # The SID must appear exactly once — not once per source.
315
+ count=$(printf '%s\n' "$output" | grep -c "$dup_uuid")
316
+ [ "$count" -eq 1 ]
317
+ }
318
+
319
+ @test "candidates: announce-marker enumeration excludes markers older than the mtime window" {
320
+ rt_uuid="eeeeeeee-0000-1111-2222-runtimepick0"
321
+ fresh_uuid="ffffffff-0000-1111-2222-freshmarker0"
322
+ stale_uuid="99999999-0000-1111-2222-stalemarker0"
323
+ # runtime-sid wins get_current_session_id (so its single pick is the
324
+ # runtime value, not an announce marker) — this isolates the window
325
+ # behaviour to the announce-marker ENUMERATION step.
326
+ printf '%s' "$rt_uuid" > "$SANDBOX_TMP/itil-runtime-sid.current"
327
+ mark_announced "jtbd" "$fresh_uuid"
328
+ mark_announced "voice-tone" "$stale_uuid"
329
+ # Backdate the stale marker well beyond the window. touch -t is POSIX
330
+ # and portable across BSD (macOS) and GNU find/touch.
331
+ touch -t 202001010000 "$SANDBOX_TMP/voice-tone-announced-${stale_uuid}"
332
+ output=$(SESSION_CANDIDATE_WINDOW_MINS=60 bash -c "source '$HELPER'; get_candidate_session_ids")
333
+ [[ "$output" == *"$rt_uuid"* ]]
334
+ [[ "$output" == *"$fresh_uuid"* ]]
335
+ [[ "$output" != *"$stale_uuid"* ]]
336
+ }
337
+
338
+ @test "candidates: empty output when no markers, no runtime-sid, and no env var" {
339
+ output=$(candidates)
340
+ [ -z "$output" ]
341
+ }
342
+
343
+ @test "candidates: env-var SID is included as a candidate" {
344
+ env_uuid="12121212-0000-1111-2222-envvarsid000"
345
+ other_uuid="34343434-0000-1111-2222-announced000"
346
+ mark_announced "architect" "$other_uuid"
347
+ output=$(CLAUDE_SESSION_ID="$env_uuid" SESSION_MARKER_DIR="$SANDBOX_TMP" bash -c "source '$HELPER'; get_candidate_session_ids")
348
+ [[ "$output" == *"$env_uuid"* ]]
349
+ # The concurrent announce marker is still enumerated alongside the env SID.
350
+ [[ "$output" == *"$other_uuid"* ]]
351
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.35.13",
3
+ "version": "0.35.14-preview.415",
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"
@@ -153,15 +153,15 @@ The **3-keyword cap** is a hard-coded constant. Do NOT make it env-overridable
153
153
 
154
154
  If matches are found: list them in the final report. **Do NOT halt or branch.** Capture proceeds. The user can resolve duplicates at the next `/wr-itil:review-problems` invocation (or invoke `/wr-itil:manage-problem` directly if the duplicate-check shape needs a structured branch).
155
155
 
156
- **After the grep completes**, write the per-session create-gate marker so the `PreToolUse:Write` hook (P119) permits the subsequent Write of the new `.open.md` file:
156
+ **After the grep completes**, write the per-session create-gate marker so the `PreToolUse:Write` hook (P119) permits the subsequent Write of the new `.open.md` file. Per **P260 / ADR-050 Option C**, write it under EVERY recent candidate session SID (not just one) so a concurrent orchestrator+subprocess race cannot land the marker under the wrong UUID:
157
157
 
158
158
  ```bash
159
159
  source packages/itil/hooks/lib/session-id.sh
160
160
  source packages/itil/hooks/lib/create-gate.sh
161
- sid=$(get_current_session_id) && mark_step2_complete "$sid"
161
+ get_candidate_session_ids | mark_step2_complete_candidates
162
162
  ```
163
163
 
164
- The marker is shared between `manage-problem` and `capture-problem` per ADR-032 amendment — same `/tmp/manage-problem-grep-${SESSION_ID}` path, idempotent across cross-skill ordering.
164
+ The marker is shared between `manage-problem` and `capture-problem` per ADR-032 amendment — same `/tmp/manage-problem-grep-${SESSION_ID}` path, idempotent across cross-skill ordering. `get_candidate_session_ids` enumerates the `get_current_session_id` pick (P124) plus every recent `/tmp/<system>-announced-<UUID>` UUID within a 24h mtime window, and `mark_step2_complete_candidates` writes the marker under each — so whichever SID the hook reads from the Write's stdin, a matching marker exists. This closes the P260 create-gate race that fires when the orchestrator main turn captures a ticket while a backgrounded iter subprocess holds the per-machine runtime-sid marker (last-writer-wins). The candidate set is bounded to recent same-machine markers — not a global fail-open (the P119 audit invariant holds: each marker still records that THIS session ran the duplicate-check grep). See `/wr-itil:manage-problem` Step 2 substep 7 for the full mechanism.
165
165
 
166
166
  ### 3. Compute the next ID
167
167
 
@@ -321,19 +321,21 @@ Before creating, search existing problems for similar issues. The user may not k
321
321
  - "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?"
322
322
  5. If the user chooses to update, switch to the update flow for that problem ID
323
323
  6. If no matches found, proceed to create
324
- 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:
324
+ 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}`. Per **P260 / ADR-050 Option C**, the agent writes it under EVERY recent candidate session SID — not just one — by sourcing the discovery helpers (P124) and piping the candidate set into `mark_step2_complete_candidates`:
325
325
 
326
326
  ```bash
327
327
  source packages/itil/hooks/lib/session-id.sh
328
328
  source packages/itil/hooks/lib/create-gate.sh
329
- sid=$(get_current_session_id) && mark_step2_complete "$sid"
329
+ get_candidate_session_ids | mark_step2_complete_candidates
330
330
  ```
331
331
 
332
- `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.
332
+ **Why every candidate, not one (P260 / ADR-050 Option C)**: under `/wr-itil:work-problems` the orchestrator main turn fires PreToolUse hooks concurrently with its backgrounded iter subprocess (Step 5). Both sessions write the same per-machine runtime-sid marker (last-writer-wins), so the single-SID `get_current_session_id` can return the subprocess SID while the orchestrator's Write carries the orchestrator SID on its stdin — marker mismatch, create-gate deny. No agent-side algorithm can predict the right single SID from filesystem state alone (ADR-050 §Context). `get_candidate_session_ids` instead enumerates EVERY candidate SID the hook might read — the `get_current_session_id` pick (env-var > runtime-sid > announce-marker priority, P124) PLUS every recent `/tmp/<system>-announced-<UUID>` UUID within a 24h mtime window (ADR-038 announce markers, set on prompt 1 of every session by architect / jtbd / tdd / style-guide / voice-tone / itil-assistant-gate / itil-correction-detect hooks) and `mark_step2_complete_candidates` writes the marker under each. Whichever SID the hook reads from the Write's stdin, a matching marker provably exists. The candidate set is **bounded** to recent same-machine announce markers + the runtime-sid value NOT a global fail-open: the P119 audit invariant holds (every marker still records that THIS session ran the duplicate-check grep). The marker is per-session, so a single write 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.
333
333
 
334
- **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.
334
+ **Why helpers 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 helpers canonicalise that scrape so every agent context discovers candidate SIDs the same way. P124.
335
335
 
336
- **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).
336
+ **Phase 4 (P142 / ADR-050)** — the helper 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 non-concurrent flow**. The Phase 3 announce-marker priority logic is preserved as cold-path fallback (first tool call of a session, before any PreToolUse fires).
337
+
338
+ **Phase 5 (P260 / ADR-050 Option C)** — the "structurally impossible" guarantee above holds ONLY when no second session writes the runtime-sid marker concurrently. Under `/wr-itil:work-problems`, the orchestrator main turn and its backgrounded subprocess write the per-machine runtime-sid marker concurrently (last-writer-wins), re-introducing the mismatch (this was P260, surfaced by the 2026-05-18 P254/P255 foreground captures). The candidate-set marker-write above is the mitigation: it does not depend on the runtime marker carrying the right SID at Write-time, because it marks under every recent candidate SID.
337
339
 
338
340
  **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.
339
341