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.
- package/CHANGELOG.md +15 -0
- package/dist/MANIFEST.json +5 -5
- package/dist/cli.cjs +105 -7
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +104 -7
- package/dist/cli.js.map +1 -1
- package/dist/templates/cleargate-planning/.claude/hooks/pending-task-sentinel.sh +6 -1
- package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +162 -45
- package/dist/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +7 -1
- package/dist/templates/cleargate-planning/MANIFEST.json +5 -5
- package/package.json +1 -1
- package/templates/cleargate-planning/.claude/hooks/pending-task-sentinel.sh +6 -1
- package/templates/cleargate-planning/.claude/hooks/token-ledger.sh +162 -45
- package/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +7 -1
- package/templates/cleargate-planning/MANIFEST.json +5 -5
|
@@ -183,7 +183,12 @@ fi
|
|
|
183
183
|
fi
|
|
184
184
|
|
|
185
185
|
STARTED_AT="$(date -u +%FT%TZ)"
|
|
186
|
-
|
|
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
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
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
|
-
#
|
|
301
|
+
# BUG-027: Before falling to transcript grep, attempt sentinel-aware lookups.
|
|
238
302
|
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
267
|
-
if [[ -n "${
|
|
268
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
"generated_at": "2026-05-
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
+
"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
|
-
|
|
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
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
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
|
-
#
|
|
301
|
+
# BUG-027: Before falling to transcript grep, attempt sentinel-aware lookups.
|
|
238
302
|
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
267
|
-
if [[ -n "${
|
|
268
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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 \
|