cleargate 0.11.3 → 0.11.4

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.
@@ -183,7 +183,12 @@ fi
183
183
  fi
184
184
 
185
185
  STARTED_AT="$(date -u +%FT%TZ)"
186
- SENTINEL_FILE="${SPRINT_DIR}/.pending-task-${TURN_INDEX}.json"
186
+ # BUG-029 fix: uniquify the sentinel filename so that two parallel Task() calls
187
+ # in the same assistant message (same TURN_INDEX) do NOT collide on the same
188
+ # target path. Pattern: ${TURN_INDEX}-${PID}-${RANDOM} mirrors write_dispatch.sh.
189
+ # Old: .pending-task-${TURN_INDEX}.json ← second call overwrites first.
190
+ # New: .pending-task-${TURN_INDEX}-$$-${RANDOM}.json ← each call gets its own file.
191
+ SENTINEL_FILE="${SPRINT_DIR}/.pending-task-${TURN_INDEX}-$$-${RANDOM}.json"
187
192
 
188
193
  # Write the sentinel atomically (tmp + mv).
189
194
  TMP="${SENTINEL_FILE}.tmp.$$"
@@ -98,27 +98,91 @@ ACTIVE_SENTINEL="${REPO_ROOT}/.cleargate/sprint-runs/.active"
98
98
  fi
99
99
  LEDGER="${SPRINT_DIR}/token-ledger.jsonl"
100
100
 
101
- # --- dispatch-marker attribution (CR-016 + CR-026, highest priority) ---
101
+ # --- dispatch-marker attribution (CR-016 + CR-026 + BUG-029, highest priority) ---
102
102
  # The PreToolUse:Task hook (pre-tool-use-task.sh, CR-026) auto-writes:
103
103
  # .cleargate/sprint-runs/<sprint>/.dispatch-<ts>-<pid>-<rand>.json
104
104
  # with { work_item_id, agent_type, spawned_at, session_id, writer }.
105
105
  # Reading this file (if present) gives accurate attribution; falls back to the
106
106
  # per-task pending-task sentinel (second priority) and transcript-scan (third).
107
107
  #
108
- # CR-026 Defect 1 fix — path-B (newest-file lookup):
109
- # The old lookup keyed on the subagent's session_id from the SubagentStop payload,
110
- # which is the *subagent's* session ID, never the orchestrator's. This caused 100%
111
- # lookup failure since SPRINT-15 (BUG-024 §3.1 Defect 1). We now use newest-file
112
- # lookup (ls -t) atomic with the existing mv→.processed-$$ rename pattern.
113
- # See CR-026 M3 plan §"Open Question Q1 resolution — path-A vs path-B".
108
+ # BUG-029 fix — tuple-match replaces newest-file lookup:
109
+ # When two parallel Task() spawns write two distinct .dispatch-*.json files,
110
+ # the old `ls -t | head -1` (newest-file) lookup grabs whichever was written
111
+ # last mis-attributing the ledger row to the wrong story. The fix: extract
112
+ # the work_item_id from the SubagentStop transcript (first user message) and
113
+ # match it against the work_item_id field inside each .dispatch-*.json.
114
+ # Fallback: if no tuple match, fall back to newest-file lookup with a warning.
114
115
  #
115
116
  # Atomicity: rename to .processed-$$ before reading, then delete post-row-write.
116
117
  # This prevents stale dispatch files from leaking attribution to a later subagent.
117
118
  SENTINEL_AGENT_TYPE=""
118
119
  SENTINEL_WORK_ITEM_ID=""
120
+ DISPATCH_PROCESSED=""
121
+
122
+ # BUG-029: extract work_item_id from the SubagentStop transcript (first user message).
123
+ # This is the orchestrator's dispatch prompt — by convention it starts with the
124
+ # work_item_id (e.g. "STORY=NNN-NN" or "STORY-NNN-NN") or contains it prominently.
125
+ # We use non-capturing groups (no capture groups → scan returns full match string)
126
+ # and a broad alphanumeric suffix to also match letter-suffix IDs like STORY-A, STORY-B
127
+ # used in tests and fast-lane items (not just digit-keyed like the legacy path).
128
+ TRANSCRIPT_WORK_ITEM=""
129
+ if [[ -f "${TRANSCRIPT_PATH}" ]]; then
130
+ # Primary: first user message, scan for work-item reference (TYPE[-=]ID).
131
+ # scan("(?:...)+") with no capture groups returns the full match string.
132
+ TRANSCRIPT_WORK_ITEM="$(jq -rs --arg banner_re "${BANNER_SKIP_RE}" '
133
+ [.[] | select(.type == "user")]
134
+ | [.[] | select(
135
+ (.message.content | if type == "array"
136
+ then map(.text? // "") | join(" ")
137
+ else (. // "") end
138
+ ) | test($banner_re) | not
139
+ )]
140
+ | .[0].message.content
141
+ | if type == "array" then map(.text? // "") | join(" ") else (. // "") end
142
+ | tostring
143
+ | [scan("(?:STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=][A-Za-z0-9]+(?:-[A-Za-z0-9]+)?")]
144
+ | .[0] // ""
145
+ ' "${TRANSCRIPT_PATH}" 2>/dev/null | head -1 | sed 's/=/-/g')"
146
+ # Normalize: replace = with - (STORY=NNN-NN → STORY-NNN-NN)
147
+ TRANSCRIPT_WORK_ITEM="$(printf '%s' "${TRANSCRIPT_WORK_ITEM}" | sed 's/=/-/g')"
148
+ [[ "${TRANSCRIPT_WORK_ITEM}" == "" || "${TRANSCRIPT_WORK_ITEM}" == "null" ]] && TRANSCRIPT_WORK_ITEM=""
149
+ fi
150
+
151
+ # BUG-029: tuple-match — iterate dispatch files, find one whose work_item_id
152
+ # matches TRANSCRIPT_WORK_ITEM. If exactly one matches, consume it; otherwise
153
+ # fall back to newest-file (legacy CR-026 path) with a warning logged.
154
+ DISPATCH_FILE=""
155
+ if [[ -n "${TRANSCRIPT_WORK_ITEM}" ]]; then
156
+ # Search all dispatch files for a content match on work_item_id.
157
+ MATCHED_FILE=""
158
+ MATCH_COUNT=0
159
+ for CANDIDATE in "${SPRINT_DIR}"/.dispatch-*.json; do
160
+ [[ -f "${CANDIDATE}" ]] || continue
161
+ CANDIDATE_WORK_ITEM="$(jq -r '.work_item_id // empty' "${CANDIDATE}" 2>/dev/null)"
162
+ if [[ "${CANDIDATE_WORK_ITEM}" == "${TRANSCRIPT_WORK_ITEM}" ]]; then
163
+ MATCHED_FILE="${CANDIDATE}"
164
+ MATCH_COUNT=$(( MATCH_COUNT + 1 ))
165
+ fi
166
+ done
167
+ if [[ "${MATCH_COUNT}" -eq 1 ]]; then
168
+ DISPATCH_FILE="${MATCHED_FILE}"
169
+ printf '[%s] dispatch-marker tuple-match: transcript_work_item=%s → %s\n' \
170
+ "$(date -u +%FT%TZ)" "${TRANSCRIPT_WORK_ITEM}" "${DISPATCH_FILE}" >> "${HOOK_LOG}"
171
+ elif [[ "${MATCH_COUNT}" -gt 1 ]]; then
172
+ printf '[%s] warn: %d dispatch files matched work_item=%s — falling back to newest-file\n' \
173
+ "$(date -u +%FT%TZ)" "${MATCH_COUNT}" "${TRANSCRIPT_WORK_ITEM}" >> "${HOOK_LOG}"
174
+ fi
175
+ fi
176
+
177
+ # Fallback: newest-file lookup (CR-026 path-B) when tuple-match found nothing.
178
+ if [[ -z "${DISPATCH_FILE}" ]]; then
179
+ if [[ -n "${TRANSCRIPT_WORK_ITEM}" ]]; then
180
+ printf '[%s] warn: no tuple-match for work_item=%s — falling back to newest-file lookup\n' \
181
+ "$(date -u +%FT%TZ)" "${TRANSCRIPT_WORK_ITEM}" >> "${HOOK_LOG}"
182
+ fi
183
+ DISPATCH_FILE="$(ls -t "${SPRINT_DIR}"/.dispatch-*.json 2>/dev/null | head -1)"
184
+ fi
119
185
 
120
- # CR-026: newest-file lookup (path-B) — replaces session-id-keyed lookup.
121
- DISPATCH_FILE="$(ls -t "${SPRINT_DIR}"/.dispatch-*.json 2>/dev/null | head -1)"
122
186
  if [[ -n "${DISPATCH_FILE}" && -f "${DISPATCH_FILE}" ]]; then
123
187
  DISPATCH_PROCESSED="${DISPATCH_FILE%.json}.processed-$$"
124
188
  if mv "${DISPATCH_FILE}" "${DISPATCH_PROCESSED}" 2>/dev/null; then
@@ -234,49 +298,102 @@ ACTIVE_SENTINEL="${REPO_ROOT}/.cleargate/sprint-runs/.active"
234
298
  fi
235
299
 
236
300
  if [[ -z "${WORK_ITEM_ID}" ]]; then
237
- # Legacy: detect work_item_id (PRIMARY: first user message; FALLBACK: anywhere-grep)
301
+ # BUG-027: Before falling to transcript grep, attempt sentinel-aware lookups.
238
302
  #
239
- # CR-026: banner-skip applied before jq scan (BUG-024 §3.1 Defect 2).
240
- # The SessionStart hook emits a banner line of the form:
241
- # "N items blocked: BUG-004: ..."
242
- # This line poisons transcript-grep by matching the work-item regex first.
243
- # We skip it via select(. | test(BANNER_SKIP_RE) | not) in the jq pipeline.
244
- # BANNER_SKIP_RE is defined near the top of this script.
245
- WORK_ITEM_RAW="$(jq -rs --arg banner_re "${BANNER_SKIP_RE}" '
246
- [.[] | select(.type == "user")]
247
- | [.[] | select(
248
- (.message.content | if type == "array"
249
- then map(.text? // "") | join(" ")
250
- else (. // "") end
251
- ) | test($banner_re) | not
252
- )]
253
- | .[0].message.content
254
- | if type == "array" then map(.text? // "") | join(" ") else (. // "") end
255
- | tostring
256
- | scan("(STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=]?([0-9]+(-[0-9]+)?)") | .[0:2] | join("-")
257
- ' "${TRANSCRIPT_PATH}" 2>/dev/null | head -1)"
303
+ # Resolution order (cheapest/most-accurate first):
304
+ # Step 1 Prior ledger row (Option A, M1 open decision):
305
+ # Read the most-recent row from ${LEDGER} (the file this hook appends to).
306
+ # Orchestrator-architect coordination calls happen AFTER a subagent dispatch that
307
+ # correctly tagged the active epic; reusing the last row's work_item_id is both
308
+ # cheap and accurate. This step is the primary fix for the 12 EPIC-001
309
+ # misattributions observed during the SPRINT-02 dogfood (BUG-027 context_source).
310
+ # Step 2 Most-recent dispatch-marker log line:
311
+ # The hook emits "dispatch-marker: session=... work_item=... agent=..." to HOOK_LOG
312
+ # on every successful dispatch-file consumption. Reading the last such line gives
313
+ # accurate attribution for the same class of coordination calls.
314
+ # Step 3 (legacy) First user message transcript scan (CR-026 banner-skip).
315
+ # Step 4 (last resort) Anywhere-grep in transcript (CR-026 banner-skip).
316
+ #
317
+ # Steps 3+4 are kept as final fallbacks; the transcript grep is now the last resort,
318
+ # not the primary path, which eliminates the EPIC-001 lexical-first misattribution.
319
+
320
+ # Step 1: Read most-recent prior ledger row's work_item_id.
321
+ PRIOR_LEDGER_WORK_ITEM=""
322
+ if [[ -f "${LEDGER}" ]]; then
323
+ PRIOR_LEDGER_WORK_ITEM="$(tail -1 "${LEDGER}" 2>/dev/null \
324
+ | jq -r '.work_item_id // empty' 2>/dev/null)"
325
+ # Only accept non-empty, non-"none", non-"unknown" values.
326
+ if [[ -n "${PRIOR_LEDGER_WORK_ITEM}" && \
327
+ "${PRIOR_LEDGER_WORK_ITEM}" != "none" && \
328
+ "${PRIOR_LEDGER_WORK_ITEM}" != "unknown" && \
329
+ "${PRIOR_LEDGER_WORK_ITEM}" != "null" ]]; then
330
+ WORK_ITEM_ID="${PRIOR_LEDGER_WORK_ITEM}"
331
+ printf '[%s] work_item_id from prior ledger row: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
332
+ fi
333
+ fi
258
334
 
259
- if [[ -n "${WORK_ITEM_RAW}" && "${WORK_ITEM_RAW}" != "null" && "${WORK_ITEM_RAW}" != "-" ]]; then
260
- WORK_ITEM_ID="$(printf '%s' "${WORK_ITEM_RAW}" | sed 's/=/-/g')"
261
- else
262
- # CR-026: fallback grep also applies banner-skip via sed filter.
263
- WORK_ITEM_ID="$(sed -E "/${BANNER_SKIP_RE}/d" "${TRANSCRIPT_PATH}" 2>/dev/null \
264
- | grep -oE '(STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=]?[0-9]+(-[0-9]+)?' \
335
+ # Step 2: Read most-recent dispatch-marker log line (if Step 1 did not resolve).
336
+ if [[ -z "${WORK_ITEM_ID}" && -f "${HOOK_LOG}" ]]; then
337
+ DISPATCH_MARKER_WORK_ITEM="$(grep -E '^\[.+\] dispatch-marker: ' "${HOOK_LOG}" 2>/dev/null \
338
+ | tail -1 \
339
+ | grep -oE 'work_item=[^ ]+' \
265
340
  | head -1 \
266
- | sed 's/=/-/g')"
267
- if [[ -n "${WORK_ITEM_ID}" ]]; then
268
- printf '[%s] work_item_id fallback grep: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
341
+ | sed 's/work_item=//')"
342
+ if [[ -n "${DISPATCH_MARKER_WORK_ITEM}" && \
343
+ "${DISPATCH_MARKER_WORK_ITEM}" != "none" && \
344
+ "${DISPATCH_MARKER_WORK_ITEM}" != "unknown" ]]; then
345
+ WORK_ITEM_ID="${DISPATCH_MARKER_WORK_ITEM}"
346
+ printf '[%s] work_item_id from dispatch-marker log: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
269
347
  fi
270
348
  fi
271
- [[ -z "${WORK_ITEM_ID}" ]] && WORK_ITEM_ID=""
272
349
 
273
- # Legacy fallback: if no work_item_id found at all, fall back to old grep for story_id only
350
+ # Step 3: Legacy transcript scan first user message (CR-026 banner-skip applied).
351
+ # Only runs when Steps 1+2 did not resolve.
274
352
  if [[ -z "${WORK_ITEM_ID}" ]]; then
275
- STORY_ID_LEGACY="$(grep -oE 'STORY[-=]?[0-9]{3}-[0-9]{2}' "${TRANSCRIPT_PATH}" 2>/dev/null \
276
- | head -1 \
277
- | sed -E 's/STORY[-=]?([0-9]{3}-[0-9]{2})/STORY-\1/')"
278
- [[ -z "${STORY_ID_LEGACY}" ]] && STORY_ID_LEGACY="none"
279
- WORK_ITEM_ID="${STORY_ID_LEGACY}"
353
+ # CR-026: banner-skip applied before jq scan (BUG-024 §3.1 Defect 2).
354
+ # The SessionStart hook emits a banner line of the form:
355
+ # "N items blocked: BUG-004: ..."
356
+ # This line poisons transcript-grep by matching the work-item regex first.
357
+ # We skip it via select(. | test(BANNER_SKIP_RE) | not) in the jq pipeline.
358
+ # BANNER_SKIP_RE is defined near the top of this script.
359
+ WORK_ITEM_RAW="$(jq -rs --arg banner_re "${BANNER_SKIP_RE}" '
360
+ [.[] | select(.type == "user")]
361
+ | [.[] | select(
362
+ (.message.content | if type == "array"
363
+ then map(.text? // "") | join(" ")
364
+ else (. // "") end
365
+ ) | test($banner_re) | not
366
+ )]
367
+ | .[0].message.content
368
+ | if type == "array" then map(.text? // "") | join(" ") else (. // "") end
369
+ | tostring
370
+ | scan("(STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=]?([0-9]+(-[0-9]+)?)") | .[0:2] | join("-")
371
+ ' "${TRANSCRIPT_PATH}" 2>/dev/null | head -1)"
372
+
373
+ if [[ -n "${WORK_ITEM_RAW}" && "${WORK_ITEM_RAW}" != "null" && "${WORK_ITEM_RAW}" != "-" ]]; then
374
+ WORK_ITEM_ID="$(printf '%s' "${WORK_ITEM_RAW}" | sed 's/=/-/g')"
375
+ else
376
+ # Step 4 (last resort): CR-026: fallback grep also applies banner-skip via sed filter.
377
+ # This is the path that was misattributing EPIC-001 (BUG-027). Now reached only when
378
+ # Steps 1+2+3 all fail to resolve a work_item_id.
379
+ WORK_ITEM_ID="$(sed -E "/${BANNER_SKIP_RE}/d" "${TRANSCRIPT_PATH}" 2>/dev/null \
380
+ | grep -oE '(STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=]?[0-9]+(-[0-9]+)?' \
381
+ | head -1 \
382
+ | sed 's/=/-/g')"
383
+ if [[ -n "${WORK_ITEM_ID}" ]]; then
384
+ printf '[%s] work_item_id fallback grep: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
385
+ fi
386
+ fi
387
+ [[ -z "${WORK_ITEM_ID}" ]] && WORK_ITEM_ID=""
388
+
389
+ # Legacy fallback: if no work_item_id found at all, fall back to old grep for story_id only
390
+ if [[ -z "${WORK_ITEM_ID}" ]]; then
391
+ STORY_ID_LEGACY="$(grep -oE 'STORY[-=]?[0-9]{3}-[0-9]{2}' "${TRANSCRIPT_PATH}" 2>/dev/null \
392
+ | head -1 \
393
+ | sed -E 's/STORY[-=]?([0-9]{3}-[0-9]{2})/STORY-\1/')"
394
+ [[ -z "${STORY_ID_LEGACY}" ]] && STORY_ID_LEGACY="none"
395
+ WORK_ITEM_ID="${STORY_ID_LEGACY}"
396
+ fi
280
397
  fi
281
398
  fi
282
399
 
@@ -107,7 +107,13 @@ if [[ -f "${PKG_JSON}" ]]; then
107
107
  fi
108
108
 
109
109
  # ─── Write dispatch file atomically ─────────────────────────────────────────
110
- DISPATCH_TARGET="${SPRINT_DIR}/.dispatch-${SESSION_ID}.json"
110
+ # BUG-029 fix: uniquify the dispatch filename so that two parallel Task()
111
+ # spawns from the same orchestrator session (same SESSION_ID) do NOT collide
112
+ # on the same target path. Pattern matches pre-tool-use-task.sh:115.
113
+ # Old: .dispatch-${SESSION_ID}.json ← second parallel write silently overwrites first.
114
+ # New: .dispatch-${TS}-${PID}-${RAND}.json ← each spawn gets a distinct file.
115
+ TS_EPOCH="$(date -u +%s)"
116
+ DISPATCH_TARGET="${SPRINT_DIR}/.dispatch-${TS_EPOCH}-$$-${RANDOM}.json"
111
117
  SPAWNED_AT="$(date -u +%FT%TZ)"
112
118
 
113
119
  DISPATCH_JSON="$(jq -cn \
@@ -1,6 +1,6 @@
1
1
  {
2
- "cleargate_version": "0.11.3",
3
- "generated_at": "2026-05-04T23:10:17.763Z",
2
+ "cleargate_version": "0.11.4",
3
+ "generated_at": "2026-05-05T17:48:13.929Z",
4
4
  "files": [
5
5
  {
6
6
  "path": ".claude/agents/architect.md",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  {
69
69
  "path": ".claude/hooks/pending-task-sentinel.sh",
70
- "sha256": "8204286b19287c490cc09014b0c87e2b2686e6bd120393b7165895d215d6b49a",
70
+ "sha256": "a3c2dc71a803c37527afd059b81e2adad2104d4887ae125846a2416f2f719b71",
71
71
  "tier": "hook",
72
72
  "overwrite_policy": "always",
73
73
  "preserve_on_uninstall": false
@@ -123,7 +123,7 @@
123
123
  },
124
124
  {
125
125
  "path": ".claude/hooks/token-ledger.sh",
126
- "sha256": "6678f814520c379b3ab055f3a1b98c92f631fd415ae24752d45aa8bee058c29c",
126
+ "sha256": "37a5bc311ca36f016ed72f1ded92d70d2b0acdd8993d634667b05d3407de0f22",
127
127
  "tier": "hook",
128
128
  "overwrite_policy": "always",
129
129
  "preserve_on_uninstall": false
@@ -389,7 +389,7 @@
389
389
  },
390
390
  {
391
391
  "path": ".cleargate/scripts/write_dispatch.sh",
392
- "sha256": "abdcaf09b09251f3ab42cb7ec8bdedc5806fd0eb337578f55043bffb158d8128",
392
+ "sha256": "2d4ebbd8a6f0e833c86b534c3106377018d54f6e24b3ff1a171b18d807103748",
393
393
  "tier": "script",
394
394
  "overwrite_policy": "always",
395
395
  "preserve_on_uninstall": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cleargate",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Planning framework for Claude Code agents — sprint/epic/story protocol, five-role agent team (architect/developer/qa/devops/reporter), Karpathy-style awareness wiki.",
@@ -183,7 +183,12 @@ fi
183
183
  fi
184
184
 
185
185
  STARTED_AT="$(date -u +%FT%TZ)"
186
- SENTINEL_FILE="${SPRINT_DIR}/.pending-task-${TURN_INDEX}.json"
186
+ # BUG-029 fix: uniquify the sentinel filename so that two parallel Task() calls
187
+ # in the same assistant message (same TURN_INDEX) do NOT collide on the same
188
+ # target path. Pattern: ${TURN_INDEX}-${PID}-${RANDOM} mirrors write_dispatch.sh.
189
+ # Old: .pending-task-${TURN_INDEX}.json ← second call overwrites first.
190
+ # New: .pending-task-${TURN_INDEX}-$$-${RANDOM}.json ← each call gets its own file.
191
+ SENTINEL_FILE="${SPRINT_DIR}/.pending-task-${TURN_INDEX}-$$-${RANDOM}.json"
187
192
 
188
193
  # Write the sentinel atomically (tmp + mv).
189
194
  TMP="${SENTINEL_FILE}.tmp.$$"
@@ -98,27 +98,91 @@ ACTIVE_SENTINEL="${REPO_ROOT}/.cleargate/sprint-runs/.active"
98
98
  fi
99
99
  LEDGER="${SPRINT_DIR}/token-ledger.jsonl"
100
100
 
101
- # --- dispatch-marker attribution (CR-016 + CR-026, highest priority) ---
101
+ # --- dispatch-marker attribution (CR-016 + CR-026 + BUG-029, highest priority) ---
102
102
  # The PreToolUse:Task hook (pre-tool-use-task.sh, CR-026) auto-writes:
103
103
  # .cleargate/sprint-runs/<sprint>/.dispatch-<ts>-<pid>-<rand>.json
104
104
  # with { work_item_id, agent_type, spawned_at, session_id, writer }.
105
105
  # Reading this file (if present) gives accurate attribution; falls back to the
106
106
  # per-task pending-task sentinel (second priority) and transcript-scan (third).
107
107
  #
108
- # CR-026 Defect 1 fix — path-B (newest-file lookup):
109
- # The old lookup keyed on the subagent's session_id from the SubagentStop payload,
110
- # which is the *subagent's* session ID, never the orchestrator's. This caused 100%
111
- # lookup failure since SPRINT-15 (BUG-024 §3.1 Defect 1). We now use newest-file
112
- # lookup (ls -t) atomic with the existing mv→.processed-$$ rename pattern.
113
- # See CR-026 M3 plan §"Open Question Q1 resolution — path-A vs path-B".
108
+ # BUG-029 fix — tuple-match replaces newest-file lookup:
109
+ # When two parallel Task() spawns write two distinct .dispatch-*.json files,
110
+ # the old `ls -t | head -1` (newest-file) lookup grabs whichever was written
111
+ # last mis-attributing the ledger row to the wrong story. The fix: extract
112
+ # the work_item_id from the SubagentStop transcript (first user message) and
113
+ # match it against the work_item_id field inside each .dispatch-*.json.
114
+ # Fallback: if no tuple match, fall back to newest-file lookup with a warning.
114
115
  #
115
116
  # Atomicity: rename to .processed-$$ before reading, then delete post-row-write.
116
117
  # This prevents stale dispatch files from leaking attribution to a later subagent.
117
118
  SENTINEL_AGENT_TYPE=""
118
119
  SENTINEL_WORK_ITEM_ID=""
120
+ DISPATCH_PROCESSED=""
121
+
122
+ # BUG-029: extract work_item_id from the SubagentStop transcript (first user message).
123
+ # This is the orchestrator's dispatch prompt — by convention it starts with the
124
+ # work_item_id (e.g. "STORY=NNN-NN" or "STORY-NNN-NN") or contains it prominently.
125
+ # We use non-capturing groups (no capture groups → scan returns full match string)
126
+ # and a broad alphanumeric suffix to also match letter-suffix IDs like STORY-A, STORY-B
127
+ # used in tests and fast-lane items (not just digit-keyed like the legacy path).
128
+ TRANSCRIPT_WORK_ITEM=""
129
+ if [[ -f "${TRANSCRIPT_PATH}" ]]; then
130
+ # Primary: first user message, scan for work-item reference (TYPE[-=]ID).
131
+ # scan("(?:...)+") with no capture groups returns the full match string.
132
+ TRANSCRIPT_WORK_ITEM="$(jq -rs --arg banner_re "${BANNER_SKIP_RE}" '
133
+ [.[] | select(.type == "user")]
134
+ | [.[] | select(
135
+ (.message.content | if type == "array"
136
+ then map(.text? // "") | join(" ")
137
+ else (. // "") end
138
+ ) | test($banner_re) | not
139
+ )]
140
+ | .[0].message.content
141
+ | if type == "array" then map(.text? // "") | join(" ") else (. // "") end
142
+ | tostring
143
+ | [scan("(?:STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=][A-Za-z0-9]+(?:-[A-Za-z0-9]+)?")]
144
+ | .[0] // ""
145
+ ' "${TRANSCRIPT_PATH}" 2>/dev/null | head -1 | sed 's/=/-/g')"
146
+ # Normalize: replace = with - (STORY=NNN-NN → STORY-NNN-NN)
147
+ TRANSCRIPT_WORK_ITEM="$(printf '%s' "${TRANSCRIPT_WORK_ITEM}" | sed 's/=/-/g')"
148
+ [[ "${TRANSCRIPT_WORK_ITEM}" == "" || "${TRANSCRIPT_WORK_ITEM}" == "null" ]] && TRANSCRIPT_WORK_ITEM=""
149
+ fi
150
+
151
+ # BUG-029: tuple-match — iterate dispatch files, find one whose work_item_id
152
+ # matches TRANSCRIPT_WORK_ITEM. If exactly one matches, consume it; otherwise
153
+ # fall back to newest-file (legacy CR-026 path) with a warning logged.
154
+ DISPATCH_FILE=""
155
+ if [[ -n "${TRANSCRIPT_WORK_ITEM}" ]]; then
156
+ # Search all dispatch files for a content match on work_item_id.
157
+ MATCHED_FILE=""
158
+ MATCH_COUNT=0
159
+ for CANDIDATE in "${SPRINT_DIR}"/.dispatch-*.json; do
160
+ [[ -f "${CANDIDATE}" ]] || continue
161
+ CANDIDATE_WORK_ITEM="$(jq -r '.work_item_id // empty' "${CANDIDATE}" 2>/dev/null)"
162
+ if [[ "${CANDIDATE_WORK_ITEM}" == "${TRANSCRIPT_WORK_ITEM}" ]]; then
163
+ MATCHED_FILE="${CANDIDATE}"
164
+ MATCH_COUNT=$(( MATCH_COUNT + 1 ))
165
+ fi
166
+ done
167
+ if [[ "${MATCH_COUNT}" -eq 1 ]]; then
168
+ DISPATCH_FILE="${MATCHED_FILE}"
169
+ printf '[%s] dispatch-marker tuple-match: transcript_work_item=%s → %s\n' \
170
+ "$(date -u +%FT%TZ)" "${TRANSCRIPT_WORK_ITEM}" "${DISPATCH_FILE}" >> "${HOOK_LOG}"
171
+ elif [[ "${MATCH_COUNT}" -gt 1 ]]; then
172
+ printf '[%s] warn: %d dispatch files matched work_item=%s — falling back to newest-file\n' \
173
+ "$(date -u +%FT%TZ)" "${MATCH_COUNT}" "${TRANSCRIPT_WORK_ITEM}" >> "${HOOK_LOG}"
174
+ fi
175
+ fi
176
+
177
+ # Fallback: newest-file lookup (CR-026 path-B) when tuple-match found nothing.
178
+ if [[ -z "${DISPATCH_FILE}" ]]; then
179
+ if [[ -n "${TRANSCRIPT_WORK_ITEM}" ]]; then
180
+ printf '[%s] warn: no tuple-match for work_item=%s — falling back to newest-file lookup\n' \
181
+ "$(date -u +%FT%TZ)" "${TRANSCRIPT_WORK_ITEM}" >> "${HOOK_LOG}"
182
+ fi
183
+ DISPATCH_FILE="$(ls -t "${SPRINT_DIR}"/.dispatch-*.json 2>/dev/null | head -1)"
184
+ fi
119
185
 
120
- # CR-026: newest-file lookup (path-B) — replaces session-id-keyed lookup.
121
- DISPATCH_FILE="$(ls -t "${SPRINT_DIR}"/.dispatch-*.json 2>/dev/null | head -1)"
122
186
  if [[ -n "${DISPATCH_FILE}" && -f "${DISPATCH_FILE}" ]]; then
123
187
  DISPATCH_PROCESSED="${DISPATCH_FILE%.json}.processed-$$"
124
188
  if mv "${DISPATCH_FILE}" "${DISPATCH_PROCESSED}" 2>/dev/null; then
@@ -234,49 +298,102 @@ ACTIVE_SENTINEL="${REPO_ROOT}/.cleargate/sprint-runs/.active"
234
298
  fi
235
299
 
236
300
  if [[ -z "${WORK_ITEM_ID}" ]]; then
237
- # Legacy: detect work_item_id (PRIMARY: first user message; FALLBACK: anywhere-grep)
301
+ # BUG-027: Before falling to transcript grep, attempt sentinel-aware lookups.
238
302
  #
239
- # CR-026: banner-skip applied before jq scan (BUG-024 §3.1 Defect 2).
240
- # The SessionStart hook emits a banner line of the form:
241
- # "N items blocked: BUG-004: ..."
242
- # This line poisons transcript-grep by matching the work-item regex first.
243
- # We skip it via select(. | test(BANNER_SKIP_RE) | not) in the jq pipeline.
244
- # BANNER_SKIP_RE is defined near the top of this script.
245
- WORK_ITEM_RAW="$(jq -rs --arg banner_re "${BANNER_SKIP_RE}" '
246
- [.[] | select(.type == "user")]
247
- | [.[] | select(
248
- (.message.content | if type == "array"
249
- then map(.text? // "") | join(" ")
250
- else (. // "") end
251
- ) | test($banner_re) | not
252
- )]
253
- | .[0].message.content
254
- | if type == "array" then map(.text? // "") | join(" ") else (. // "") end
255
- | tostring
256
- | scan("(STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=]?([0-9]+(-[0-9]+)?)") | .[0:2] | join("-")
257
- ' "${TRANSCRIPT_PATH}" 2>/dev/null | head -1)"
303
+ # Resolution order (cheapest/most-accurate first):
304
+ # Step 1 Prior ledger row (Option A, M1 open decision):
305
+ # Read the most-recent row from ${LEDGER} (the file this hook appends to).
306
+ # Orchestrator-architect coordination calls happen AFTER a subagent dispatch that
307
+ # correctly tagged the active epic; reusing the last row's work_item_id is both
308
+ # cheap and accurate. This step is the primary fix for the 12 EPIC-001
309
+ # misattributions observed during the SPRINT-02 dogfood (BUG-027 context_source).
310
+ # Step 2 Most-recent dispatch-marker log line:
311
+ # The hook emits "dispatch-marker: session=... work_item=... agent=..." to HOOK_LOG
312
+ # on every successful dispatch-file consumption. Reading the last such line gives
313
+ # accurate attribution for the same class of coordination calls.
314
+ # Step 3 (legacy) First user message transcript scan (CR-026 banner-skip).
315
+ # Step 4 (last resort) Anywhere-grep in transcript (CR-026 banner-skip).
316
+ #
317
+ # Steps 3+4 are kept as final fallbacks; the transcript grep is now the last resort,
318
+ # not the primary path, which eliminates the EPIC-001 lexical-first misattribution.
319
+
320
+ # Step 1: Read most-recent prior ledger row's work_item_id.
321
+ PRIOR_LEDGER_WORK_ITEM=""
322
+ if [[ -f "${LEDGER}" ]]; then
323
+ PRIOR_LEDGER_WORK_ITEM="$(tail -1 "${LEDGER}" 2>/dev/null \
324
+ | jq -r '.work_item_id // empty' 2>/dev/null)"
325
+ # Only accept non-empty, non-"none", non-"unknown" values.
326
+ if [[ -n "${PRIOR_LEDGER_WORK_ITEM}" && \
327
+ "${PRIOR_LEDGER_WORK_ITEM}" != "none" && \
328
+ "${PRIOR_LEDGER_WORK_ITEM}" != "unknown" && \
329
+ "${PRIOR_LEDGER_WORK_ITEM}" != "null" ]]; then
330
+ WORK_ITEM_ID="${PRIOR_LEDGER_WORK_ITEM}"
331
+ printf '[%s] work_item_id from prior ledger row: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
332
+ fi
333
+ fi
258
334
 
259
- if [[ -n "${WORK_ITEM_RAW}" && "${WORK_ITEM_RAW}" != "null" && "${WORK_ITEM_RAW}" != "-" ]]; then
260
- WORK_ITEM_ID="$(printf '%s' "${WORK_ITEM_RAW}" | sed 's/=/-/g')"
261
- else
262
- # CR-026: fallback grep also applies banner-skip via sed filter.
263
- WORK_ITEM_ID="$(sed -E "/${BANNER_SKIP_RE}/d" "${TRANSCRIPT_PATH}" 2>/dev/null \
264
- | grep -oE '(STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=]?[0-9]+(-[0-9]+)?' \
335
+ # Step 2: Read most-recent dispatch-marker log line (if Step 1 did not resolve).
336
+ if [[ -z "${WORK_ITEM_ID}" && -f "${HOOK_LOG}" ]]; then
337
+ DISPATCH_MARKER_WORK_ITEM="$(grep -E '^\[.+\] dispatch-marker: ' "${HOOK_LOG}" 2>/dev/null \
338
+ | tail -1 \
339
+ | grep -oE 'work_item=[^ ]+' \
265
340
  | head -1 \
266
- | sed 's/=/-/g')"
267
- if [[ -n "${WORK_ITEM_ID}" ]]; then
268
- printf '[%s] work_item_id fallback grep: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
341
+ | sed 's/work_item=//')"
342
+ if [[ -n "${DISPATCH_MARKER_WORK_ITEM}" && \
343
+ "${DISPATCH_MARKER_WORK_ITEM}" != "none" && \
344
+ "${DISPATCH_MARKER_WORK_ITEM}" != "unknown" ]]; then
345
+ WORK_ITEM_ID="${DISPATCH_MARKER_WORK_ITEM}"
346
+ printf '[%s] work_item_id from dispatch-marker log: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
269
347
  fi
270
348
  fi
271
- [[ -z "${WORK_ITEM_ID}" ]] && WORK_ITEM_ID=""
272
349
 
273
- # Legacy fallback: if no work_item_id found at all, fall back to old grep for story_id only
350
+ # Step 3: Legacy transcript scan first user message (CR-026 banner-skip applied).
351
+ # Only runs when Steps 1+2 did not resolve.
274
352
  if [[ -z "${WORK_ITEM_ID}" ]]; then
275
- STORY_ID_LEGACY="$(grep -oE 'STORY[-=]?[0-9]{3}-[0-9]{2}' "${TRANSCRIPT_PATH}" 2>/dev/null \
276
- | head -1 \
277
- | sed -E 's/STORY[-=]?([0-9]{3}-[0-9]{2})/STORY-\1/')"
278
- [[ -z "${STORY_ID_LEGACY}" ]] && STORY_ID_LEGACY="none"
279
- WORK_ITEM_ID="${STORY_ID_LEGACY}"
353
+ # CR-026: banner-skip applied before jq scan (BUG-024 §3.1 Defect 2).
354
+ # The SessionStart hook emits a banner line of the form:
355
+ # "N items blocked: BUG-004: ..."
356
+ # This line poisons transcript-grep by matching the work-item regex first.
357
+ # We skip it via select(. | test(BANNER_SKIP_RE) | not) in the jq pipeline.
358
+ # BANNER_SKIP_RE is defined near the top of this script.
359
+ WORK_ITEM_RAW="$(jq -rs --arg banner_re "${BANNER_SKIP_RE}" '
360
+ [.[] | select(.type == "user")]
361
+ | [.[] | select(
362
+ (.message.content | if type == "array"
363
+ then map(.text? // "") | join(" ")
364
+ else (. // "") end
365
+ ) | test($banner_re) | not
366
+ )]
367
+ | .[0].message.content
368
+ | if type == "array" then map(.text? // "") | join(" ") else (. // "") end
369
+ | tostring
370
+ | scan("(STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=]?([0-9]+(-[0-9]+)?)") | .[0:2] | join("-")
371
+ ' "${TRANSCRIPT_PATH}" 2>/dev/null | head -1)"
372
+
373
+ if [[ -n "${WORK_ITEM_RAW}" && "${WORK_ITEM_RAW}" != "null" && "${WORK_ITEM_RAW}" != "-" ]]; then
374
+ WORK_ITEM_ID="$(printf '%s' "${WORK_ITEM_RAW}" | sed 's/=/-/g')"
375
+ else
376
+ # Step 4 (last resort): CR-026: fallback grep also applies banner-skip via sed filter.
377
+ # This is the path that was misattributing EPIC-001 (BUG-027). Now reached only when
378
+ # Steps 1+2+3 all fail to resolve a work_item_id.
379
+ WORK_ITEM_ID="$(sed -E "/${BANNER_SKIP_RE}/d" "${TRANSCRIPT_PATH}" 2>/dev/null \
380
+ | grep -oE '(STORY|PROPOSAL|EPIC|CR|BUG|HOTFIX)[-=]?[0-9]+(-[0-9]+)?' \
381
+ | head -1 \
382
+ | sed 's/=/-/g')"
383
+ if [[ -n "${WORK_ITEM_ID}" ]]; then
384
+ printf '[%s] work_item_id fallback grep: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
385
+ fi
386
+ fi
387
+ [[ -z "${WORK_ITEM_ID}" ]] && WORK_ITEM_ID=""
388
+
389
+ # Legacy fallback: if no work_item_id found at all, fall back to old grep for story_id only
390
+ if [[ -z "${WORK_ITEM_ID}" ]]; then
391
+ STORY_ID_LEGACY="$(grep -oE 'STORY[-=]?[0-9]{3}-[0-9]{2}' "${TRANSCRIPT_PATH}" 2>/dev/null \
392
+ | head -1 \
393
+ | sed -E 's/STORY[-=]?([0-9]{3}-[0-9]{2})/STORY-\1/')"
394
+ [[ -z "${STORY_ID_LEGACY}" ]] && STORY_ID_LEGACY="none"
395
+ WORK_ITEM_ID="${STORY_ID_LEGACY}"
396
+ fi
280
397
  fi
281
398
  fi
282
399
 
@@ -107,7 +107,13 @@ if [[ -f "${PKG_JSON}" ]]; then
107
107
  fi
108
108
 
109
109
  # ─── Write dispatch file atomically ─────────────────────────────────────────
110
- DISPATCH_TARGET="${SPRINT_DIR}/.dispatch-${SESSION_ID}.json"
110
+ # BUG-029 fix: uniquify the dispatch filename so that two parallel Task()
111
+ # spawns from the same orchestrator session (same SESSION_ID) do NOT collide
112
+ # on the same target path. Pattern matches pre-tool-use-task.sh:115.
113
+ # Old: .dispatch-${SESSION_ID}.json ← second parallel write silently overwrites first.
114
+ # New: .dispatch-${TS}-${PID}-${RAND}.json ← each spawn gets a distinct file.
115
+ TS_EPOCH="$(date -u +%s)"
116
+ DISPATCH_TARGET="${SPRINT_DIR}/.dispatch-${TS_EPOCH}-$$-${RANDOM}.json"
111
117
  SPAWNED_AT="$(date -u +%FT%TZ)"
112
118
 
113
119
  DISPATCH_JSON="$(jq -cn \