cleargate 0.8.2 → 0.10.0

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +11 -0
  3. package/dist/MANIFEST.json +259 -28
  4. package/dist/{chunk-OM4FAEA7.js → chunk-Q3BTSXCK.js} +69 -3
  5. package/dist/chunk-Q3BTSXCK.js.map +1 -0
  6. package/dist/cli.cjs +2621 -548
  7. package/dist/cli.cjs.map +1 -1
  8. package/dist/cli.js +2548 -560
  9. package/dist/cli.js.map +1 -1
  10. package/dist/lib/ledger.cjs +120 -0
  11. package/dist/lib/ledger.cjs.map +1 -0
  12. package/dist/lib/ledger.d.cts +64 -0
  13. package/dist/lib/ledger.d.ts +64 -0
  14. package/dist/lib/ledger.js +96 -0
  15. package/dist/lib/ledger.js.map +1 -0
  16. package/dist/templates/cleargate-planning/.claude/agents/architect.md +10 -8
  17. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-contradict.md +108 -0
  18. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +49 -3
  19. package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +6 -1
  20. package/dist/templates/cleargate-planning/.claude/agents/developer.md +29 -2
  21. package/dist/templates/cleargate-planning/.claude/agents/qa.md +50 -1
  22. package/dist/templates/cleargate-planning/.claude/agents/reporter.md +31 -9
  23. package/dist/templates/cleargate-planning/.claude/hooks/pre-tool-use-task.sh +148 -0
  24. package/dist/templates/cleargate-planning/.claude/hooks/session-start.sh +6 -0
  25. package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +314 -96
  26. package/dist/templates/cleargate-planning/.claude/settings.json +4 -0
  27. package/dist/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +473 -0
  28. package/dist/templates/cleargate-planning/.cleargate/config.example.yml +19 -0
  29. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-enforcement.md +542 -0
  30. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +102 -428
  31. package/dist/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +31 -0
  32. package/dist/templates/cleargate-planning/.cleargate/knowledge/sprint-closeout-checklist.md +71 -0
  33. package/dist/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +24 -2
  34. package/dist/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +387 -27
  35. package/dist/templates/cleargate-planning/.cleargate/scripts/dedupe_frontmatter.mjs +219 -0
  36. package/dist/templates/cleargate-planning/.cleargate/scripts/lib/report-filename.mjs +54 -0
  37. package/dist/templates/cleargate-planning/.cleargate/scripts/prep_doc_refresh.mjs +378 -0
  38. package/dist/templates/cleargate-planning/.cleargate/scripts/prep_qa_context.mjs +888 -0
  39. package/dist/templates/cleargate-planning/.cleargate/scripts/sprint_trends.mjs +71 -0
  40. package/dist/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +355 -13
  41. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_flashcard_gate.sh +20 -20
  42. package/dist/templates/cleargate-planning/.cleargate/scripts/test/test_prep_qa_context.sh +482 -0
  43. package/dist/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +125 -0
  44. package/dist/templates/cleargate-planning/.cleargate/templates/Bug.md +24 -1
  45. package/dist/templates/cleargate-planning/.cleargate/templates/CR.md +32 -1
  46. package/dist/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +48 -14
  47. package/dist/templates/cleargate-planning/.cleargate/templates/epic.md +37 -3
  48. package/dist/templates/cleargate-planning/.cleargate/templates/hotfix.md +50 -0
  49. package/dist/templates/cleargate-planning/.cleargate/templates/initiative.md +98 -29
  50. package/dist/templates/cleargate-planning/.cleargate/templates/proposal.md +17 -4
  51. package/dist/templates/cleargate-planning/.cleargate/templates/sprint_report.md +23 -4
  52. package/dist/templates/cleargate-planning/.cleargate/templates/story.md +55 -3
  53. package/dist/templates/cleargate-planning/CLAUDE.md +28 -10
  54. package/dist/templates/cleargate-planning/MANIFEST.json +259 -28
  55. package/dist/{whoami-CX7CXJD5.js → whoami-W4U6DPVG.js} +17 -17
  56. package/dist/whoami-W4U6DPVG.js.map +1 -0
  57. package/package.json +13 -2
  58. package/templates/cleargate-planning/.claude/agents/architect.md +10 -8
  59. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-contradict.md +108 -0
  60. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +49 -3
  61. package/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +6 -1
  62. package/templates/cleargate-planning/.claude/agents/developer.md +29 -2
  63. package/templates/cleargate-planning/.claude/agents/qa.md +50 -1
  64. package/templates/cleargate-planning/.claude/agents/reporter.md +31 -9
  65. package/templates/cleargate-planning/.claude/hooks/pre-tool-use-task.sh +148 -0
  66. package/templates/cleargate-planning/.claude/hooks/session-start.sh +6 -0
  67. package/templates/cleargate-planning/.claude/hooks/token-ledger.sh +314 -96
  68. package/templates/cleargate-planning/.claude/settings.json +4 -0
  69. package/templates/cleargate-planning/.claude/skills/sprint-execution/SKILL.md +473 -0
  70. package/templates/cleargate-planning/.cleargate/config.example.yml +19 -0
  71. package/templates/cleargate-planning/.cleargate/knowledge/cleargate-enforcement.md +542 -0
  72. package/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +102 -428
  73. package/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +31 -0
  74. package/templates/cleargate-planning/.cleargate/knowledge/sprint-closeout-checklist.md +71 -0
  75. package/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +24 -2
  76. package/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +387 -27
  77. package/templates/cleargate-planning/.cleargate/scripts/dedupe_frontmatter.mjs +219 -0
  78. package/templates/cleargate-planning/.cleargate/scripts/lib/report-filename.mjs +54 -0
  79. package/templates/cleargate-planning/.cleargate/scripts/prep_doc_refresh.mjs +378 -0
  80. package/templates/cleargate-planning/.cleargate/scripts/prep_qa_context.mjs +888 -0
  81. package/templates/cleargate-planning/.cleargate/scripts/sprint_trends.mjs +71 -0
  82. package/templates/cleargate-planning/.cleargate/scripts/suggest_improvements.mjs +355 -13
  83. package/templates/cleargate-planning/.cleargate/scripts/test/test_flashcard_gate.sh +20 -20
  84. package/templates/cleargate-planning/.cleargate/scripts/test/test_prep_qa_context.sh +482 -0
  85. package/templates/cleargate-planning/.cleargate/scripts/write_dispatch.sh +125 -0
  86. package/templates/cleargate-planning/.cleargate/templates/Bug.md +24 -1
  87. package/templates/cleargate-planning/.cleargate/templates/CR.md +32 -1
  88. package/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +48 -14
  89. package/templates/cleargate-planning/.cleargate/templates/epic.md +37 -3
  90. package/templates/cleargate-planning/.cleargate/templates/hotfix.md +50 -0
  91. package/templates/cleargate-planning/.cleargate/templates/initiative.md +98 -29
  92. package/templates/cleargate-planning/.cleargate/templates/sprint_report.md +23 -4
  93. package/templates/cleargate-planning/.cleargate/templates/story.md +55 -3
  94. package/templates/cleargate-planning/CLAUDE.md +28 -10
  95. package/templates/cleargate-planning/MANIFEST.json +259 -28
  96. package/dist/chunk-OM4FAEA7.js.map +0 -1
  97. package/dist/whoami-CX7CXJD5.js.map +0 -1
  98. package/templates/cleargate-planning/.cleargate/templates/proposal.md +0 -61
@@ -5,31 +5,46 @@
5
5
  # Output: appends to .cleargate/sprint-runs/<sprint-id>/token-ledger.jsonl
6
6
  # Cost computation is deferred to the Reporter agent (prices change; keep raw).
7
7
  #
8
- # Active sprint detection (FIXED 2026-04-19):
8
+ # ── Per-task sentinel contract (ADDED 2026-04-20) ─────────────────────────────
9
+ #
10
+ # Before each Task (Agent) dispatch the orchestrator writes:
11
+ # .cleargate/sprint-runs/<sprint>/.pending-task-<N>.json
12
+ # with shape:
13
+ # { "agent_type": "developer"|"architect"|"qa"|"reporter",
14
+ # "work_item_id": "STORY-006-01",
15
+ # "turn_index": <int — count of assistant turns in orchestrator transcript at dispatch>,
16
+ # "started_at": "<ISO-8601>" }
17
+ #
18
+ # On SubagentStop fire the hook:
19
+ # 1. Finds the newest .pending-task-*.json in the active sprint dir.
20
+ # 2. Reads agent_type, work_item_id, turn_index from it.
21
+ # 3. Computes token delta = sum of assistant turns at index >= turn_index in the transcript
22
+ # (NOT the cumulative whole-file sum — that was the SPRINT-05 double-count bug).
23
+ # 4. Writes one JSONL row to token-ledger.jsonl with sentinel attribution.
24
+ # 5. Deletes the sentinel atomically (mv → append → rm .processed-*).
25
+ #
26
+ # If no sentinel exists: falls back to legacy transcript-grep for agent_type/work_item_id
27
+ # and computes the full-transcript delta (single-fire: no prior rows to double-count).
28
+ # Fail-silent on missing sentinel — never block a subagent stop (exit 0 always).
29
+ #
30
+ # ── Active sprint detection ────────────────────────────────────────────────────
31
+ #
9
32
  # Primary : .cleargate/sprint-runs/.active sentinel file (one line: "SPRINT-NN")
10
33
  # Orchestrator writes this at sprint kickoff, removes/updates at close.
11
34
  # Fallback : .cleargate/sprint-runs/_off-sprint/token-ledger.jsonl
12
35
  # When no .active sentinel exists, writes still get captured but
13
- # tagged off-sprint instead of misrouting to a stale sprint dir.
36
+ # tagged _off-sprint instead of misrouting to a stale sprint dir.
14
37
  # (Removed): the old `ls -td sprint-runs/*/ | head -1` mtime heuristic — it
15
38
  # misrouted SPRINT-04 firings to SPRINT-03 because ledger appends
16
39
  # themselves bumped SPRINT-03's mtime. See REPORT.md SPRINT-04.
17
40
  #
18
- # Work-item ID detection (GENERALIZED 2026-04-19 STORY-008-04, SCOPED 2026-04-26 BUG-010):
41
+ # ── Work-item ID detection (legacy / no-sentinel path) ────────────────────────
42
+ #
19
43
  # Primary : first user message in the transcript — that's the orchestrator's
20
44
  # dispatch prompt, which by agent convention starts with
21
45
  # `STORY=NNN-NN`, `PROPOSAL-NNN`, `EPIC-NNN`, `CR-NNN`, or `BUG-NNN`.
22
- # Scoping : look ONLY at lines whose FIRST CHARACTER matches the dispatch-marker
23
- # pattern (line-anchored). The SessionStart hook emits a "blocked items"
24
- # reminder that lists work-item IDs in prose/bullets — those lines start
25
- # with "- " or whitespace, never with the marker prefix. Scanning the full
26
- # content caused BUG-010: all SPRINT-14 rows tagged BUG-002 (first item in
27
- # the reminder list). Fix: join content with \n, split, filter line-starts.
28
- # Pattern : ^(STORY|PROPOSAL|PROP|EPIC|CR|BUG)[-=][0-9]...
29
- # PROP-NNN is normalised to PROPOSAL-NNN after match (BUG-009, 2026-04-26)
30
- # Tie-break: when multiple dispatch-marker lines appear in the first user message,
31
- # the FIRST line wins (deterministic; orchestrator puts the marker first).
32
- # Fallback : grep first line-start match anywhere in the transcript.
46
+ # Pattern : (STORY|PROPOSAL|EPIC|CR|BUG)[-=]?[0-9]+(-[0-9]+)?
47
+ # Fallback : grep first match anywhere in the transcript.
33
48
  # story_id : populated only when the match is a STORY-* (backward compat).
34
49
  # work_item_id: always populated when detection succeeds; equals story_id for STORY items.
35
50
  # (Removed): grep-first-anywhere as PRIMARY — it picked up SPRINT-05 mentions
@@ -41,6 +56,11 @@
41
56
 
42
57
  set -u
43
58
 
59
+ # CR-026: SessionStart blocked-items banner poisons transcript-grep (BUG-024 §3.1 Defect 2).
60
+ # Skip-pattern: any line starting with "<N> items? blocked: " is the SessionStart banner.
61
+ # Applied in the legacy work-item ID resolution block below via sed filtering.
62
+ BANNER_SKIP_RE='^[0-9]+ items? blocked: '
63
+
44
64
  REPO_ROOT="${ORCHESTRATOR_PROJECT_DIR:-${CLAUDE_PROJECT_DIR}}"
45
65
  LOG_DIR="${REPO_ROOT}/.cleargate/hook-log"
46
66
  mkdir -p "${LOG_DIR}"
@@ -78,85 +98,187 @@ ACTIVE_SENTINEL="${REPO_ROOT}/.cleargate/sprint-runs/.active"
78
98
  fi
79
99
  LEDGER="${SPRINT_DIR}/token-ledger.jsonl"
80
100
 
81
- # --- walk transcript, sum usage across all assistant turns in this subagent run ---
82
- USAGE_JSON="$(jq -cs '
83
- map(select(.type == "assistant" and .message.usage))
84
- | (map(.message.usage.input_tokens // 0) | add) as $in
85
- | (map(.message.usage.output_tokens // 0) | add) as $out
86
- | (map(.message.usage.cache_creation_input_tokens // 0) | add) as $cc
87
- | (map(.message.usage.cache_read_input_tokens // 0) | add) as $cr
88
- | (map(.message.model) | unique | map(select(. != null)) | join(",")) as $models
89
- | (length) as $turns
90
- | {input: $in, output: $out, cache_creation: $cc, cache_read: $cr, model: $models, turns: $turns}
91
- ' "${TRANSCRIPT_PATH}" 2>/dev/null)"
101
+ # --- dispatch-marker attribution (CR-016 + CR-026, highest priority) ---
102
+ # The PreToolUse:Task hook (pre-tool-use-task.sh, CR-026) auto-writes:
103
+ # .cleargate/sprint-runs/<sprint>/.dispatch-<ts>-<pid>-<rand>.json
104
+ # with { work_item_id, agent_type, spawned_at, session_id, writer }.
105
+ # Reading this file (if present) gives accurate attribution; falls back to the
106
+ # per-task pending-task sentinel (second priority) and transcript-scan (third).
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".
114
+ #
115
+ # Atomicity: rename to .processed-$$ before reading, then delete post-row-write.
116
+ # This prevents stale dispatch files from leaking attribution to a later subagent.
117
+ SENTINEL_AGENT_TYPE=""
118
+ SENTINEL_WORK_ITEM_ID=""
119
+
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
+ if [[ -n "${DISPATCH_FILE}" && -f "${DISPATCH_FILE}" ]]; then
123
+ DISPATCH_PROCESSED="${DISPATCH_FILE%.json}.processed-$$"
124
+ if mv "${DISPATCH_FILE}" "${DISPATCH_PROCESSED}" 2>/dev/null; then
125
+ DISPATCH_JSON="$(cat "${DISPATCH_PROCESSED}" 2>/dev/null)"
126
+ DISPATCH_AGENT="$(printf '%s' "${DISPATCH_JSON}" | jq -r '.agent_type // empty' 2>/dev/null)"
127
+ DISPATCH_WORK_ITEM="$(printf '%s' "${DISPATCH_JSON}" | jq -r '.work_item_id // empty' 2>/dev/null)"
128
+ if [[ -n "${DISPATCH_AGENT}" && -n "${DISPATCH_WORK_ITEM}" ]]; then
129
+ SENTINEL_AGENT_TYPE="${DISPATCH_AGENT}"
130
+ SENTINEL_WORK_ITEM_ID="${DISPATCH_WORK_ITEM}"
131
+ printf '[%s] dispatch-marker: session=%s work_item=%s agent=%s\n' \
132
+ "$(date -u +%FT%TZ)" "${SESSION_ID}" "${SENTINEL_WORK_ITEM_ID}" "${SENTINEL_AGENT_TYPE}" >> "${HOOK_LOG}"
133
+ else
134
+ printf '[%s] warn: dispatch file malformed or missing fields, falling back: %s\n' \
135
+ "$(date -u +%FT%TZ)" "${DISPATCH_PROCESSED}" >> "${HOOK_LOG}"
136
+ fi
137
+ else
138
+ printf '[%s] warn: could not rename dispatch file %s (race?), skipping\n' \
139
+ "$(date -u +%FT%TZ)" "${DISPATCH_FILE}" >> "${HOOK_LOG}"
140
+ fi
141
+ fi
142
+ # DISPATCH_PROCESSED is deleted after row-write (see "delete processed dispatch" block below).
143
+
144
+ # --- per-task sentinel: find newest .pending-task-*.json in sprint dir ---
145
+ # Second-priority fallback when dispatch-marker is absent or malformed.
146
+ # When dispatch-marker already populated SENTINEL_AGENT_TYPE + SENTINEL_WORK_ITEM_ID,
147
+ # still read the pending-task sentinel to get turn_index + started_at for delta accounting.
148
+ SENTINEL_FILE=""
149
+ SENTINEL_TURN_INDEX=0
150
+ SENTINEL_STARTED_AT=""
151
+ # Preserve SENTINEL_AGENT_TYPE / SENTINEL_WORK_ITEM_ID from dispatch block above.
152
+
153
+ if [[ -d "${SPRINT_DIR}" ]]; then
154
+ # Find newest pending-task sentinel (ls -t sorts newest first)
155
+ SENTINEL_FILE="$(ls -t "${SPRINT_DIR}"/.pending-task-*.json 2>/dev/null | head -1)"
156
+ fi
157
+
158
+ if [[ -n "${SENTINEL_FILE}" && -f "${SENTINEL_FILE}" ]]; then
159
+ SENTINEL_JSON="$(cat "${SENTINEL_FILE}" 2>/dev/null)"
160
+ # Only use attribution from pending-task if dispatch-marker did not already provide it.
161
+ if [[ -z "${SENTINEL_AGENT_TYPE}" ]]; then
162
+ SENTINEL_AGENT_TYPE="$(printf '%s' "${SENTINEL_JSON}" | jq -r '.agent_type // empty' 2>/dev/null)"
163
+ fi
164
+ if [[ -z "${SENTINEL_WORK_ITEM_ID}" ]]; then
165
+ SENTINEL_WORK_ITEM_ID="$(printf '%s' "${SENTINEL_JSON}" | jq -r '.work_item_id // empty' 2>/dev/null)"
166
+ fi
167
+ SENTINEL_TURN_INDEX="$(printf '%s' "${SENTINEL_JSON}" | jq -r '.turn_index // 0' 2>/dev/null)"
168
+ SENTINEL_STARTED_AT="$(printf '%s' "${SENTINEL_JSON}" | jq -r '.started_at // empty' 2>/dev/null)"
169
+ printf '[%s] found sentinel=%s agent=%s work_item=%s turn_index=%s\n' \
170
+ "$(date -u +%FT%TZ)" "${SENTINEL_FILE}" "${SENTINEL_AGENT_TYPE}" "${SENTINEL_WORK_ITEM_ID}" "${SENTINEL_TURN_INDEX}" >> "${HOOK_LOG}"
171
+ fi
172
+
173
+ # --- compute token usage ---
174
+ # DELTA MODEL (per-task sentinel path): slice transcript from turn_index forward.
175
+ # CUMULATIVE GUARD (no-sentinel path): use entire transcript — single-fire so no double-count.
176
+ #
177
+ # turn_index counts assistant turns (0-based). We select assistant turns, skip the first
178
+ # turn_index of them, then sum the remainder. This isolates tokens attributable to this subagent run.
179
+ if [[ -n "${SENTINEL_FILE}" && -f "${SENTINEL_FILE}" && -n "${SENTINEL_AGENT_TYPE}" ]]; then
180
+ # Delta model: slice from turn_index
181
+ USAGE_JSON="$(jq -cs --argjson idx "${SENTINEL_TURN_INDEX}" '
182
+ [ .[] | select(.type == "assistant" and .message.usage) ]
183
+ | .[$idx:]
184
+ | (map(.message.usage.input_tokens // 0) | add // 0) as $in
185
+ | (map(.message.usage.output_tokens // 0) | add // 0) as $out
186
+ | (map(.message.usage.cache_creation_input_tokens // 0) | add // 0) as $cc
187
+ | (map(.message.usage.cache_read_input_tokens // 0) | add // 0) as $cr
188
+ | (map(.message.model) | unique | map(select(. != null)) | join(",")) as $models
189
+ | (length) as $turns
190
+ | {input: $in, output: $out, cache_creation: $cc, cache_read: $cr, model: $models, turns: $turns}
191
+ ' "${TRANSCRIPT_PATH}" 2>/dev/null)"
192
+ else
193
+ # No sentinel: sum all assistant turns (full transcript — first fire, no prior rows to double-count)
194
+ USAGE_JSON="$(jq -cs '
195
+ map(select(.type == "assistant" and .message.usage))
196
+ | (map(.message.usage.input_tokens // 0) | add // 0) as $in
197
+ | (map(.message.usage.output_tokens // 0) | add // 0) as $out
198
+ | (map(.message.usage.cache_creation_input_tokens // 0) | add // 0) as $cc
199
+ | (map(.message.usage.cache_read_input_tokens // 0) | add // 0) as $cr
200
+ | (map(.message.model) | unique | map(select(. != null)) | join(",")) as $models
201
+ | (length) as $turns
202
+ | {input: $in, output: $out, cache_creation: $cc, cache_read: $cr, model: $models, turns: $turns}
203
+ ' "${TRANSCRIPT_PATH}" 2>/dev/null)"
204
+ fi
92
205
 
93
206
  if [[ -z "${USAGE_JSON}" || "${USAGE_JSON}" == "null" ]]; then
94
207
  printf '[%s] could not parse usage from %s\n' "$(date -u +%FT%TZ)" "${TRANSCRIPT_PATH}" >> "${HOOK_LOG}"
95
208
  exit 0
96
209
  fi
97
210
 
98
- # --- detect agent_type ---
99
- # Subagent transcripts contain `subagent_type` in the parent's tool invocation.
100
- # Best-effort extraction; falls back to grepping role markers in the transcript body.
101
- AGENT_TYPE="$(jq -rs '
102
- [.[] | select(.type == "user") | .message.content]
103
- | tostring
104
- | capture("subagent_type[\"\\s:=]+(?<t>[a-zA-Z0-9_-]+)"; "g")?.t
105
- // "unknown"
106
- ' "${TRANSCRIPT_PATH}" 2>/dev/null)"
107
- [[ -z "${AGENT_TYPE}" || "${AGENT_TYPE}" == "null" ]] && AGENT_TYPE="unknown"
108
-
109
- if [[ "${AGENT_TYPE}" == "unknown" ]]; then
110
- for role in architect developer qa reporter; do
111
- if grep -qiE "\\b${role}\\b agent|role: ${role}|you are the ${role}" "${TRANSCRIPT_PATH}" 2>/dev/null; then
112
- AGENT_TYPE="${role}"
113
- break
114
- fi
115
- done
211
+ # --- resolve agent_type and work_item_id ---
212
+ # Sentinel takes precedence; fall back to legacy transcript-grep when no sentinel.
213
+ AGENT_TYPE="${SENTINEL_AGENT_TYPE}"
214
+ WORK_ITEM_ID="${SENTINEL_WORK_ITEM_ID}"
215
+
216
+ if [[ -z "${AGENT_TYPE}" ]]; then
217
+ # Legacy: grep subagent_type from user messages, then role markers
218
+ AGENT_TYPE="$(jq -rs '
219
+ [.[] | select(.type == "user") | .message.content]
220
+ | tostring
221
+ | capture("subagent_type[\"\\s:=]+(?<t>[a-zA-Z0-9_-]+)"; "g")?.t
222
+ // "unknown"
223
+ ' "${TRANSCRIPT_PATH}" 2>/dev/null)"
224
+ [[ -z "${AGENT_TYPE}" || "${AGENT_TYPE}" == "null" ]] && AGENT_TYPE="unknown"
225
+
226
+ if [[ "${AGENT_TYPE}" == "unknown" ]]; then
227
+ for role in architect developer qa reporter cleargate-wiki-contradict; do
228
+ if grep -qiE "\\b${role}\\b agent|role: ${role}|you are the ${role}" "${TRANSCRIPT_PATH}" 2>/dev/null; then
229
+ AGENT_TYPE="${role}"
230
+ break
231
+ fi
232
+ done
233
+ fi
116
234
  fi
117
235
 
118
- # --- detect work_item_id (PRIMARY: first user message; FALLBACK: anywhere-grep) ---
119
- # Orchestrator convention (.claude/agents/developer.md): the FIRST LINE of the
120
- # dispatch prompt is `STORY=NNN-NN` (or CR=NNN, BUG=NNN, EPIC=NNN, PROPOSAL=NNN,
121
- # PROP=NNN). The SessionStart hook injects a "blocked items" reminder whose lines
122
- # start with "- " or whitespace never with the marker prefix. We therefore
123
- # ONLY match lines whose first character is the start of the marker (line-anchored).
124
- #
125
- # BUG-010 (2026-04-26): joined with " " (space) before — scan() found BUG-002 from
126
- # the reminder text. Fix: join with "\n" so line-anchored regex works.
127
- #
128
- # Pattern covers: STORY-NNN-NN, PROPOSAL-NNN, EPIC-NNN, CR-NNN, BUG-NNN
129
- # (and = separator variants like STORY=008-04)
130
- # Tie-break: first line matching the anchor wins (orchestrator puts marker first).
131
- WORK_ITEM_RAW="$(jq -rs '
132
- [.[] | select(.type == "user")] | .[0].message.content
133
- | if type == "array" then map(.text? // "") | join("\n") else (. // "") end
134
- | tostring
135
- | split("\n")
136
- | map(select(test("^(STORY|PROPOSAL|PROP|EPIC|CR|BUG)[-=][0-9]")))
137
- | .[0] // ""
138
- | capture("^(?<kind>STORY|PROPOSAL|PROP|EPIC|CR|BUG)[-=](?<id>[0-9]+(-[0-9]+)?)")
139
- | (.kind + "-" + .id)
140
- ' "${TRANSCRIPT_PATH}" 2>/dev/null | head -1)"
141
-
142
- if [[ -n "${WORK_ITEM_RAW}" && "${WORK_ITEM_RAW}" != "null" && "${WORK_ITEM_RAW}" != "-" ]]; then
143
- # Normalize: replace any = separator with - in the result
144
- # Then normalize PROP-NNN PROPOSAL-NNN (canonical form matches wiki/proposals/PROPOSAL-NNN.md filenames)
145
- # BUG-009 (2026-04-26): PROP↔PROPOSAL normalization — PRESERVED BY BUG-010.
146
- WORK_ITEM_ID="$(printf '%s' "${WORK_ITEM_RAW}" | sed 's/=/-/g' | sed 's/^PROP-/PROPOSAL-/')"
147
- else
148
- # Fallback: grep for line-start dispatch markers anywhere in the transcript.
149
- # Line-anchored (^) so that "- BUG-002:" reminder bullet lines are not matched —
150
- # only lines whose first character starts the marker prefix.
151
- # BUG-009 normalization preserved: sed 's/=/-/g' | sed 's/^PROP-/PROPOSAL-/'
152
- WORK_ITEM_ID="$(grep -oE '^(STORY|PROPOSAL|PROP|EPIC|CR|BUG)[-=][0-9][0-9]*(-[0-9]+)?' "${TRANSCRIPT_PATH}" 2>/dev/null \
153
- | head -1 \
154
- | sed 's/=/-/g' | sed 's/^PROP-/PROPOSAL-/')"
155
- if [[ -n "${WORK_ITEM_ID}" ]]; then
156
- printf '[%s] work_item_id fallback grep: %s\n' "$(date -u +%FT%TZ)" "${WORK_ITEM_ID}" >> "${HOOK_LOG}"
236
+ if [[ -z "${WORK_ITEM_ID}" ]]; then
237
+ # Legacy: detect work_item_id (PRIMARY: first user message; FALLBACK: anywhere-grep)
238
+ #
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)"
258
+
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]+)?' \
265
+ | 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}"
269
+ fi
270
+ fi
271
+ [[ -z "${WORK_ITEM_ID}" ]] && WORK_ITEM_ID=""
272
+
273
+ # Legacy fallback: if no work_item_id found at all, fall back to old grep for story_id only
274
+ 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}"
157
280
  fi
158
281
  fi
159
- [[ -z "${WORK_ITEM_ID}" ]] && WORK_ITEM_ID=""
160
282
 
161
283
  # story_id is populated only when the work item is a STORY-* (backward compat)
162
284
  STORY_ID=""
@@ -164,17 +286,97 @@ ACTIVE_SENTINEL="${REPO_ROOT}/.cleargate/sprint-runs/.active"
164
286
  STORY_ID="${WORK_ITEM_ID}"
165
287
  fi
166
288
 
167
- # Legacy fallback: if no work_item_id found at all, fall back to old grep for story_id only
168
- if [[ -z "${WORK_ITEM_ID}" ]]; then
169
- STORY_ID="$(grep -oE 'STORY[-=]?[0-9]{3}-[0-9]{2}' "${TRANSCRIPT_PATH}" 2>/dev/null \
170
- | head -1 \
171
- | sed -E 's/STORY[-=]?([0-9]{3}-[0-9]{2})/STORY-\1/')"
172
- [[ -z "${STORY_ID}" ]] && STORY_ID="none"
173
- WORK_ITEM_ID="${STORY_ID}"
289
+ # --- atomic sentinel deletion (mv append rm) ---
290
+ PROCESSED_FILE=""
291
+ if [[ -n "${SENTINEL_FILE}" && -f "${SENTINEL_FILE}" ]]; then
292
+ PROCESSED_FILE="${SENTINEL_FILE%.json}.processed-$$"
293
+ if ! mv "${SENTINEL_FILE}" "${PROCESSED_FILE}" 2>/dev/null; then
294
+ # mv failed (race condition or permissions): proceed without deletion
295
+ PROCESSED_FILE=""
296
+ printf '[%s] warn: could not mv sentinel %s\n' "$(date -u +%FT%TZ)" "${SENTINEL_FILE}" >> "${HOOK_LOG}"
297
+ fi
298
+ fi
299
+
300
+ # --- per-turn delta math (CR-018) ---
301
+ # USAGE_JSON (computed above) is the intra-fire cumulative session total from the transcript.
302
+ # We maintain .session-totals.json keyed by session_id with the last-known cumulative totals.
303
+ # Cross-fire delta = current_session_total − prior_session_total.
304
+ # For the first fire of a session, delta == session_total.
305
+ #
306
+ # Atomic update via mktemp + mv (POSIX rename is atomic on the same filesystem).
307
+ # Concurrent-fire safety: two worktree SubagentStop fires may race. The atomic rename
308
+ # ensures one writer wins; the other sees the updated file on its next read.
309
+ SESSION_TOTALS_FILE="${SPRINT_DIR}/.session-totals.json"
310
+
311
+ # Read current session totals (or empty object if file absent)
312
+ PRIOR_SESSION_JSON="{}"
313
+ if [[ -f "${SESSION_TOTALS_FILE}" ]]; then
314
+ PRIOR_SESSION_JSON="$(cat "${SESSION_TOTALS_FILE}" 2>/dev/null || printf '{}')"
315
+ [[ -z "${PRIOR_SESSION_JSON}" ]] && PRIOR_SESSION_JSON="{}"
174
316
  fi
175
317
 
176
- # --- assemble ledger row ---
318
+ # Compute current intra-fire totals from USAGE_JSON
319
+ CURRENT_IN="$(printf '%s' "${USAGE_JSON}" | jq -r '.input // 0')"
320
+ CURRENT_OUT="$(printf '%s' "${USAGE_JSON}" | jq -r '.output // 0')"
321
+ CURRENT_CC="$(printf '%s' "${USAGE_JSON}" | jq -r '.cache_creation // 0')"
322
+ CURRENT_CR="$(printf '%s' "${USAGE_JSON}" | jq -r '.cache_read // 0')"
323
+ CURRENT_MODEL="$(printf '%s' "${USAGE_JSON}" | jq -r '.model // ""')"
324
+ CURRENT_TURNS="$(printf '%s' "${USAGE_JSON}" | jq -r '.turns // 0')"
325
+
326
+ # Look up prior totals for this session_id
327
+ PRIOR_IN="$(printf '%s' "${PRIOR_SESSION_JSON}" | jq -r --arg sid "${SESSION_ID}" '.[$sid].input // 0' 2>/dev/null || printf '0')"
328
+ PRIOR_OUT="$(printf '%s' "${PRIOR_SESSION_JSON}" | jq -r --arg sid "${SESSION_ID}" '.[$sid].output // 0' 2>/dev/null || printf '0')"
329
+ PRIOR_CC="$(printf '%s' "${PRIOR_SESSION_JSON}" | jq -r --arg sid "${SESSION_ID}" '.[$sid].cache_creation // 0' 2>/dev/null || printf '0')"
330
+ PRIOR_CR="$(printf '%s' "${PRIOR_SESSION_JSON}" | jq -r --arg sid "${SESSION_ID}" '.[$sid].cache_read // 0' 2>/dev/null || printf '0')"
331
+
332
+ # Compute delta = current − prior (floor at 0 to guard against transcript-reread edge cases)
333
+ DELTA_IN=$(( CURRENT_IN - PRIOR_IN ))
334
+ DELTA_OUT=$(( CURRENT_OUT - PRIOR_OUT ))
335
+ DELTA_CC=$(( CURRENT_CC - PRIOR_CC ))
336
+ DELTA_CR=$(( CURRENT_CR - PRIOR_CR ))
337
+ [[ "${DELTA_IN}" -lt 0 ]] && DELTA_IN=0
338
+ [[ "${DELTA_OUT}" -lt 0 ]] && DELTA_OUT=0
339
+ [[ "${DELTA_CC}" -lt 0 ]] && DELTA_CC=0
340
+ [[ "${DELTA_CR}" -lt 0 ]] && DELTA_CR=0
341
+
342
+ # Build delta and session_total JSON blocks
343
+ DELTA_JSON="$(jq -cn \
344
+ --argjson in "${DELTA_IN}" \
345
+ --argjson out "${DELTA_OUT}" \
346
+ --argjson cc "${DELTA_CC}" \
347
+ --argjson cr "${DELTA_CR}" \
348
+ '{input: $in, output: $out, cache_creation: $cc, cache_read: $cr}')"
349
+
350
+ SESSION_TOTAL_JSON="$(jq -cn \
351
+ --argjson in "${CURRENT_IN}" \
352
+ --argjson out "${CURRENT_OUT}" \
353
+ --argjson cc "${CURRENT_CC}" \
354
+ --argjson cr "${CURRENT_CR}" \
355
+ '{input: $in, output: $out, cache_creation: $cc, cache_read: $cr}')"
356
+
357
+ # Atomically update .session-totals.json with new totals for this session_id
177
358
  TS="$(date -u +%FT%TZ)"
359
+ NEW_SESSION_TOTALS="$(printf '%s' "${PRIOR_SESSION_JSON}" | jq -c \
360
+ --arg sid "${SESSION_ID}" \
361
+ --argjson in "${CURRENT_IN}" \
362
+ --argjson out "${CURRENT_OUT}" \
363
+ --argjson cc "${CURRENT_CC}" \
364
+ --argjson cr "${CURRENT_CR}" \
365
+ --arg ts "${TS}" \
366
+ --argjson ti "${SENTINEL_TURN_INDEX}" \
367
+ '.[$sid] = {input: $in, output: $out, cache_creation: $cc, cache_read: $cr, last_ts: $ts, last_turn_index: $ti}' \
368
+ 2>/dev/null)"
369
+
370
+ if [[ -n "${NEW_SESSION_TOTALS}" ]]; then
371
+ SESSION_TOTALS_TMP="$(mktemp "${SESSION_TOTALS_FILE}.tmp.XXXXXX" 2>/dev/null)"
372
+ if [[ -n "${SESSION_TOTALS_TMP}" ]]; then
373
+ printf '%s\n' "${NEW_SESSION_TOTALS}" > "${SESSION_TOTALS_TMP}"
374
+ mv "${SESSION_TOTALS_TMP}" "${SESSION_TOTALS_FILE}" 2>/dev/null || \
375
+ printf '[%s] warn: could not atomic-rename session-totals\n' "${TS}" >> "${HOOK_LOG}"
376
+ fi
377
+ fi
378
+
379
+ # --- assemble ledger row (v2 schema: delta + session_total replace flat input/output/cache_*) ---
178
380
  ROW="$(jq -cn \
179
381
  --arg ts "${TS}" \
180
382
  --arg agent "${AGENT_TYPE}" \
@@ -183,15 +385,31 @@ ACTIVE_SENTINEL="${REPO_ROOT}/.cleargate/sprint-runs/.active"
183
385
  --arg session "${SESSION_ID}" \
184
386
  --arg transcript "${TRANSCRIPT_PATH}" \
185
387
  --arg sprint "${SPRINT_ID}" \
186
- --argjson usage "${USAGE_JSON}" \
187
- '{ts: $ts, sprint_id: $sprint, agent_type: $agent, story_id: $story, work_item_id: $work_item, session_id: $session, transcript: $transcript} + $usage')"
388
+ --arg sentinel_started_at "${SENTINEL_STARTED_AT}" \
389
+ --argjson turn_index "${SENTINEL_TURN_INDEX}" \
390
+ --argjson delta "${DELTA_JSON}" \
391
+ --argjson session_total "${SESSION_TOTAL_JSON}" \
392
+ --arg model "${CURRENT_MODEL}" \
393
+ --argjson turns "${CURRENT_TURNS}" \
394
+ '{ts: $ts, sprint_id: $sprint, story_id: $story, work_item_id: $work_item, agent_type: $agent, session_id: $session, transcript: $transcript, sentinel_started_at: $sentinel_started_at, delta_from_turn: $turn_index, delta: $delta, session_total: $session_total, model: $model, turns: $turns}')"
188
395
 
189
396
  printf '%s\n' "${ROW}" >> "${LEDGER}"
190
- printf '[%s] wrote row: sprint=%s agent=%s work_item=%s story=%s tokens=in:%s/out:%s\n' \
397
+ printf '[%s] wrote row: sprint=%s agent=%s work_item=%s story=%s delta=in:%s/out:%s session_total=in:%s/out:%s delta_from=%s\n' \
191
398
  "${TS}" "${SPRINT_ID}" "${AGENT_TYPE}" "${WORK_ITEM_ID}" "${STORY_ID}" \
192
- "$(printf '%s' "${USAGE_JSON}" | jq -r '.input')" \
193
- "$(printf '%s' "${USAGE_JSON}" | jq -r '.output')" \
399
+ "${DELTA_IN}" "${DELTA_OUT}" \
400
+ "${CURRENT_IN}" "${CURRENT_OUT}" \
401
+ "${SENTINEL_TURN_INDEX}" \
194
402
  >> "${HOOK_LOG}"
403
+
404
+ # --- delete processed sentinel ---
405
+ if [[ -n "${PROCESSED_FILE}" && -f "${PROCESSED_FILE}" ]]; then
406
+ rm -f "${PROCESSED_FILE}"
407
+ fi
408
+
409
+ # --- delete processed dispatch file ---
410
+ if [[ -n "${DISPATCH_PROCESSED:-}" && -f "${DISPATCH_PROCESSED}" ]]; then
411
+ rm -f "${DISPATCH_PROCESSED}"
412
+ fi
195
413
  } 2>> "${HOOK_LOG}"
196
414
 
197
415
  exit 0
@@ -17,6 +17,10 @@
17
17
  {
18
18
  "type": "command",
19
19
  "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/pending-task-sentinel.sh"
20
+ },
21
+ {
22
+ "type": "command",
23
+ "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/pre-tool-use-task.sh"
20
24
  }
21
25
  ]
22
26
  },