forgedock 1.0.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 (44) hide show
  1. package/LICENSE +244 -0
  2. package/README.md +251 -0
  3. package/bin/forgedock.mjs +232 -0
  4. package/commands/analytics.md +777 -0
  5. package/commands/audit-agents.md +410 -0
  6. package/commands/audit.md +410 -0
  7. package/commands/autopilot.md +432 -0
  8. package/commands/cleanup.md +264 -0
  9. package/commands/deploy-info.md +292 -0
  10. package/commands/failure-recon.md +375 -0
  11. package/commands/forge-stats.md +183 -0
  12. package/commands/geo-audit.md +343 -0
  13. package/commands/incident-response.md +384 -0
  14. package/commands/issue.md +506 -0
  15. package/commands/milestone.md +599 -0
  16. package/commands/orchestrate.md +1044 -0
  17. package/commands/pipeline-health.md +2343 -0
  18. package/commands/qa-sweep.md +237 -0
  19. package/commands/quality-gate.md +777 -0
  20. package/commands/review-pr-agents.md +1711 -0
  21. package/commands/review-pr-staging.md +325 -0
  22. package/commands/review-pr.md +888 -0
  23. package/commands/rollback.md +253 -0
  24. package/commands/security-audit.md +529 -0
  25. package/commands/sync-ecosystem.md +527 -0
  26. package/commands/validate.md +131 -0
  27. package/commands/work-on/build/architect.md +291 -0
  28. package/commands/work-on/build/context.md +349 -0
  29. package/commands/work-on/build/implement.md +280 -0
  30. package/commands/work-on/build/validate.md +292 -0
  31. package/commands/work-on/build.md +262 -0
  32. package/commands/work-on/close.md +350 -0
  33. package/commands/work-on/decompose.md +254 -0
  34. package/commands/work-on/investigate.md +437 -0
  35. package/commands/work-on/review.md +245 -0
  36. package/commands/work-on-monolithic.md +298 -0
  37. package/commands/work-on.md +1089 -0
  38. package/docs/CODEX.md +92 -0
  39. package/docs/WORKFLOW.md +104 -0
  40. package/package.json +45 -0
  41. package/scripts/verify-env-vars.sh +201 -0
  42. package/scripts/verify-host-headers.sh +117 -0
  43. package/scripts/verify-route-registration.sh +118 -0
  44. package/scripts/verify-sops-chain.sh +163 -0
@@ -0,0 +1,2343 @@
1
+ ---
2
+ description: Self-analysis — measures pipeline performance, correlates with prompt changes, proposes improvements
3
+ argument-hint: [project repo slug or "all"]
4
+ ---
5
+
6
+ # /pipeline-health — Forge Self-Analysis
7
+
8
+ **Input**: $ARGUMENTS
9
+
10
+ You are the Forge pipeline's self-awareness layer. Your job is to measure how well the pipeline is performing, correlate performance with recent prompt changes, identify weak spots, and propose concrete improvements.
11
+
12
+ **FORGE_HOME**: `~/projects/forge`
13
+ **This command is READ-ONLY on the target project.** It creates issues in the Forge repo only.
14
+
15
+ ---
16
+
17
+ ## Phase 1: Identify Context
18
+
19
+ ### 1A: Resolve the target project
20
+
21
+ ```bash
22
+ # Default to current repo if no argument
23
+ if [ -z "$ARGUMENTS" ] || [ "$ARGUMENTS" = "all" ]; then
24
+ REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null)
25
+ else
26
+ REPO="$ARGUMENTS"
27
+ fi
28
+ echo "Target project: $REPO"
29
+ ```
30
+
31
+ ### 1B: Get the analysis window
32
+
33
+ ```bash
34
+ # Last pipeline-health run (if any) — from Forge git log
35
+ LAST_RUN=$(cd ~/projects/forge && git log --all --oneline --grep="pipeline-health: $REPO" --format="%aI" -1 2>/dev/null || echo "")
36
+ if [ -z "$LAST_RUN" ]; then
37
+ # First run — analyze last 30 days
38
+ SINCE=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
39
+ echo "First health check — analyzing last 30 days"
40
+ else
41
+ SINCE="$LAST_RUN"
42
+ echo "Last health check: $LAST_RUN — analyzing since then"
43
+ fi
44
+ ```
45
+
46
+ ### 1C: Load Prior Health Report
47
+
48
+ Fetch the most recent `health-report` issue for the same repo and parse its metrics for trend computation. This must run before Phase 2 so that PRIOR_* variables are available when Phase 3A builds the trend column.
49
+
50
+ ```bash
51
+ # Find the most recent health-report issue for this repo
52
+ PRIOR_REPORT=$(gh issue list -R RapierCraftStudios/forge \
53
+ --state all --label "health-report" --limit 10 \
54
+ --json number,title,body \
55
+ --jq "[.[] | select(.title | contains(\"$REPO\"))] | sort_by(.number) | last")
56
+
57
+ if [ -z "$PRIOR_REPORT" ] || [ "$PRIOR_REPORT" = "null" ]; then
58
+ echo "No prior health report found for $REPO — this is a baseline run. All trends will show '—'."
59
+ PRIOR_HEALTH_SCORE=""
60
+ PRIOR_BUILD_PASS_RATE=""
61
+ PRIOR_FINDINGS_PER_PR=""
62
+ PRIOR_FALSE_POSITIVE_RATE=""
63
+ PRIOR_FIXUP_RATE=""
64
+ PRIOR_SELF_CORRECTION_RATE=""
65
+ PRIOR_ORIGINAL_WORK_RATIO=""
66
+ PRIOR_MILESTONE_MERGE_RATE=""
67
+ PRIOR_POST_DEPLOY_FAILURE_RATE=""
68
+ PRIOR_REVIEW_ESCAPE_RATE=""
69
+ PRIOR_SEC=0; PRIOR_AUTH=0; PRIOR_BILL=0; PRIOR_DB=0; PRIOR_FE=0
70
+ PRIOR_INFRA=0; PRIOR_API=0; PRIOR_CONC=0; PRIOR_SCRP=0; PRIOR_ISSUE_SPEC=0
71
+ PRIOR_TRANSCRIPT_SCORE=""
72
+ PRIOR_SESSION_COUNT=""
73
+ PRIOR_COMPACTION_MEDIAN=""
74
+ else
75
+ PRIOR_BODY=$(echo "$PRIOR_REPORT" | jq -r '.body')
76
+ PRIOR_NUMBER=$(echo "$PRIOR_REPORT" | jq -r '.number')
77
+ echo "Prior health report: #$PRIOR_NUMBER — parsing metrics for trend computation"
78
+
79
+ # Parse health score: matches "**Health Score**: 78/100" or "Score: 78/100"
80
+ PRIOR_HEALTH_SCORE=$(echo "$PRIOR_BODY" | grep -oP '(?<=\*\*Health Score\*\*: )\d+(?=/100)' | head -1)
81
+ [ -z "$PRIOR_HEALTH_SCORE" ] && PRIOR_HEALTH_SCORE=$(echo "$PRIOR_BODY" | grep -oP '(?<=Score: )\d+(?=/100)' | head -1)
82
+
83
+ # Parse metric table rows — format: "| Metric name | VALUE | target | status |"
84
+ # Build pass rate
85
+ PRIOR_BUILD_PASS_RATE=$(echo "$PRIOR_BODY" | grep -i "Build pass rate" | grep -oP '\d+(?=%)' | head -1)
86
+ # Review findings per PR
87
+ PRIOR_FINDINGS_PER_PR=$(echo "$PRIOR_BODY" | grep -i "Review findings per PR" | grep -oP '[0-9]+\.?[0-9]*' | sed -n '2p')
88
+ # False positive rate
89
+ PRIOR_FALSE_POSITIVE_RATE=$(echo "$PRIOR_BODY" | grep -i "False positive rate" | grep -oP '\d+(?=%)' | head -1)
90
+ # Manual fix-up rate
91
+ PRIOR_FIXUP_RATE=$(echo "$PRIOR_BODY" | grep -i "Manual fix-up rate" | grep -oP '\d+(?=%)' | head -1)
92
+ # Pipeline self-correction rate
93
+ PRIOR_SELF_CORRECTION_RATE=$(echo "$PRIOR_BODY" | grep -i "self-correction rate" | grep -oP '\d+(?=%)' | head -1)
94
+ # Original work ratio
95
+ PRIOR_ORIGINAL_WORK_RATIO=$(echo "$PRIOR_BODY" | grep -i "Original work ratio" | grep -oP '\d+(?=%)' | head -1)
96
+ # Milestone merge success
97
+ PRIOR_MILESTONE_MERGE_RATE=$(echo "$PRIOR_BODY" | grep -i "Milestone merge success" | grep -oP '\d+(?=%)' | head -1)
98
+ # Post-deploy failure rate
99
+ PRIOR_POST_DEPLOY_FAILURE_RATE=$(echo "$PRIOR_BODY" | grep -i "Post-deploy failure rate" | grep -oP '\d+(?=%)' | head -1)
100
+ # Review escape rate
101
+ PRIOR_REVIEW_ESCAPE_RATE=$(echo "$PRIOR_BODY" | grep -i "Review escape rate" | grep -oP '\d+(?=%)' | head -1)
102
+ # Transcript health score (from Phase 3C — supplementary signal)
103
+ PRIOR_TRANSCRIPT_SCORE=$(echo "$PRIOR_BODY" | grep -i "Transcript health score" | grep -oP '\d+(?=/100)' | head -1)
104
+ # Pipeline sessions in window (from Transcript Flow Metrics section)
105
+ PRIOR_SESSION_COUNT=$(echo "$PRIOR_BODY" | grep -i "Pipeline sessions parsed" | grep -oP '^\d+' | head -1)
106
+ # Compaction events median (from Aggregated Session Metrics table)
107
+ PRIOR_COMPACTION_MEDIAN=$(echo "$PRIOR_BODY" | grep -i "Context compaction events" | grep -oP '[0-9]+\.?[0-9]*' | head -1)
108
+
109
+ # Parse defect category counts from the "Defect Category Breakdown" section
110
+ # Matches table rows like "| SEC | 4 | 22% | ↑ +1 |" or "| SEC | 4 | 22% | — |"
111
+ _parse_cat() {
112
+ echo "$PRIOR_BODY" | grep -P "^\| $1 \|" | grep -oP '(?<=\| )\d+(?= \|)' | head -1
113
+ }
114
+ PRIOR_SEC=$( _parse_cat "SEC"); [ -z "$PRIOR_SEC" ] && PRIOR_SEC=0
115
+ PRIOR_AUTH=$( _parse_cat "AUTH"); [ -z "$PRIOR_AUTH" ] && PRIOR_AUTH=0
116
+ PRIOR_BILL=$( _parse_cat "BILL"); [ -z "$PRIOR_BILL" ] && PRIOR_BILL=0
117
+ PRIOR_DB=$( _parse_cat "DB"); [ -z "$PRIOR_DB" ] && PRIOR_DB=0
118
+ PRIOR_FE=$( _parse_cat "FE"); [ -z "$PRIOR_FE" ] && PRIOR_FE=0
119
+ PRIOR_INFRA=$( _parse_cat "INFRA"); [ -z "$PRIOR_INFRA" ] && PRIOR_INFRA=0
120
+ PRIOR_API=$( _parse_cat "API"); [ -z "$PRIOR_API" ] && PRIOR_API=0
121
+ PRIOR_CONC=$( _parse_cat "CONC"); [ -z "$PRIOR_CONC" ] && PRIOR_CONC=0
122
+ PRIOR_SCRP=$( _parse_cat "SCRP"); [ -z "$PRIOR_SCRP" ] && PRIOR_SCRP=0
123
+ PRIOR_ISSUE_SPEC=$(_parse_cat "ISSUE_SPEC"); [ -z "$PRIOR_ISSUE_SPEC" ] && PRIOR_ISSUE_SPEC=0
124
+
125
+ echo "Prior health score: ${PRIOR_HEALTH_SCORE:-N/A}"
126
+ echo "Prior defect counts: SEC=$PRIOR_SEC AUTH=$PRIOR_AUTH BILL=$PRIOR_BILL DB=$PRIOR_DB FE=$PRIOR_FE INFRA=$PRIOR_INFRA API=$PRIOR_API CONC=$PRIOR_CONC SCRP=$PRIOR_SCRP ISSUE_SPEC=$PRIOR_ISSUE_SPEC"
127
+ echo "Prior transcript: score=${PRIOR_TRANSCRIPT_SCORE:-N/A} sessions=${PRIOR_SESSION_COUNT:-N/A} compaction_median=${PRIOR_COMPACTION_MEDIAN:-N/A}"
128
+ fi
129
+
130
+ # Helper function: compute trend string for a numeric category count
131
+ # Usage: _trend CURRENT PRIOR → outputs "↑ +N", "↓ -N", "=", or "—"
132
+ _trend() {
133
+ local current="$1" prior="$2"
134
+ if [ -z "$prior" ] || [ "$prior" = "0" ] && [ "$current" = "0" ]; then
135
+ echo "—"
136
+ elif [ -z "$prior" ]; then
137
+ echo "—"
138
+ elif [ "$current" -gt "$prior" ] 2>/dev/null; then
139
+ echo "↑ +$(( current - prior ))"
140
+ elif [ "$current" -lt "$prior" ] 2>/dev/null; then
141
+ echo "↓ -$(( prior - current ))"
142
+ else
143
+ echo "="
144
+ fi
145
+ }
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Phase 2: Collect Pipeline Metrics
151
+
152
+ Run ALL of these in parallel (note: Phase 2I fetches trajectory comments per issue — run it concurrently with the others but expect it to take longer for large windows; Phase 2K queries persisted audit-agents summaries from Forge's tracking issue and is independent of the target project; Phase 2L queries `audit-finding` labeled issues in the Forge repo and is independent of the target project; Phase 2M scans `~/.claude/history.jsonl` and maps session IDs to JSONL files on disk — it is independent of the target project and completes in <5s for 25K entries; Phase 2N reads local JSONL transcript files using the JSONL Parser Utility and aggregates pipeline flow metrics — it requires Phase 2M to have run first and depends on `SESSION_JSONL_FILES`; Phase 2O extracts per-session structured metrics (phase detection, model distribution, tool call counts, agent spawns) from the same JSONL files — it requires Phase 2N to have run first and reads `TRANSCRIPT_DATA_AVAILABLE` set by Phase 2N; Phase 2P analyzes subagent JSONL files per session, maps each agent to its pipeline role, and computes per-role efficiency metrics — it requires Phase 2O to have run and depends on `TRANSCRIPT_DATA_AVAILABLE` and `SESSION_JSONL_FILES`; Phase 2Q aggregates per-session metrics from Phase 2O into a privacy-safe, report-ready summary — it strips session IDs and content fields, computes distributions, and emits guardrail footnotes — it requires Phase 2O to have run and reads `PER_SESSION_METRICS_RAW`):
153
+
154
+ ### 2A: Forge changelog (what changed in the pipeline itself)
155
+
156
+ ```bash
157
+ cd ~/projects/forge
158
+ git log --oneline --since="$SINCE" -- commands/ docs/
159
+ ```
160
+
161
+ Record each commit — these are the independent variables. Each prompt change is a hypothesis: "this change should improve X."
162
+
163
+ ### 2B: Review finding metrics (the primary quality signal)
164
+
165
+ ```bash
166
+ # Total review findings created in window
167
+ gh issue list -R $REPO --state all --label "review-finding" --json number,createdAt,labels,title,state \
168
+ --jq "[.[] | select(.createdAt > \"$SINCE\")] | length"
169
+
170
+ # Breakdown by severity (from title/labels)
171
+ gh issue list -R $REPO --state all --label "review-finding" --limit 500 --json number,title,labels,state,createdAt \
172
+ --jq "[.[] | select(.createdAt > \"$SINCE\")]"
173
+ ```
174
+
175
+ **Categorize each finding** by its agent prefix (SEC, AUTH, BILL, CONC, SCRP, FE, API, DB, INFRA) extracted from the title or finding tags.
176
+
177
+ ### 2C: Invalidation rate (false positive signal)
178
+
179
+ ```bash
180
+ # Review findings that were closed as invalid
181
+ gh issue list -R $REPO --state closed --label "review-finding,workflow:invalid" --json number,createdAt \
182
+ --jq "[.[] | select(.createdAt > \"$SINCE\")] | length"
183
+
184
+ # Total review findings closed in window
185
+ gh issue list -R $REPO --state closed --label "review-finding" --json number,createdAt \
186
+ --jq "[.[] | select(.createdAt > \"$SINCE\")] | length"
187
+ ```
188
+
189
+ **False positive rate** = invalid / total closed. Target: < 10%.
190
+
191
+ ### 2D: Build failure rate
192
+
193
+ ```bash
194
+ # staging→main PRs and their outcomes
195
+ gh pr list -R $REPO --state all --base main --json number,state,mergedAt,createdAt \
196
+ --jq "[.[] | select(.createdAt > \"$SINCE\")] | {total: length, merged: [.[] | select(.mergedAt != null)] | length, failed: [.[] | select(.state == \"CLOSED\" and .mergedAt == null)] | length}"
197
+
198
+ # milestone→staging PRs and their outcomes
199
+ gh pr list -R $REPO --state all --limit 200 --json number,state,mergedAt,createdAt,baseRefName,headRefName \
200
+ --jq "[.[] | select(.createdAt > \"$SINCE\" and .baseRefName == \"staging\" and (.headRefName | startswith(\"milestone/\")))] | {total: length, merged: [.[] | select(.mergedAt != null)] | length, failed: [.[] | select(.state == \"CLOSED\" and .mergedAt == null)] | length}"
201
+ ```
202
+
203
+ ### 2E: Review findings per PR (density metric)
204
+
205
+ ```bash
206
+ # PRs merged in window
207
+ MERGED_COUNT=$(gh pr list -R $REPO --state closed --json mergedAt,createdAt \
208
+ --jq "[.[] | select(.mergedAt != null and .createdAt > \"$SINCE\")] | length")
209
+
210
+ # Review findings created in window
211
+ FINDING_COUNT=$(gh issue list -R $REPO --state all --label "review-finding" --json createdAt \
212
+ --jq "[.[] | select(.createdAt > \"$SINCE\")] | length")
213
+
214
+ echo "Findings per PR: $(echo "scale=2; $FINDING_COUNT / $MERGED_COUNT" | bc 2>/dev/null || echo 'N/A')"
215
+ ```
216
+
217
+ Target: < 0.5 findings per PR (meaning most PRs pass clean).
218
+
219
+ ### 2F: Issue lifecycle velocity
220
+
221
+ ```bash
222
+ # Average time from issue creation to close (in days) for non-review-findings
223
+ gh issue list -R $REPO --state closed --json createdAt,closedAt,labels \
224
+ --jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | index(\"review-finding\") | not))] | .[].createdAt" | head -20
225
+ ```
226
+
227
+ ### 2G: Pipeline command usage (which commands are exercised most)
228
+
229
+ ```bash
230
+ # Count PR titles and issue comments that reference pipeline actions
231
+ gh pr list -R $REPO --state all --limit 200 --json title,createdAt \
232
+ --jq "[.[] | select(.createdAt > \"$SINCE\")] | [.[].title] | group_by(. | split(\":\" )[0]) | map({prefix: .[0] | split(\":\")[0], count: length}) | sort_by(-.count)"
233
+ ```
234
+
235
+ ### 2H: Manual fix-up commits (builder completeness signal)
236
+
237
+ The pipeline builds a PR, but sometimes a human must intervene on the same branch to patch gaps the builder missed. These fix-up commits are a direct measure of builder completeness.
238
+
239
+ **Detection strategy**:
240
+
241
+ ```bash
242
+ # For each PR merged in the window, get the full commit list with authors
243
+ MERGED_PRS=$(gh pr list -R $REPO --state closed --limit 100 --json number,headRefName,mergedAt,createdAt \
244
+ --jq "[.[] | select(.mergedAt != null and .createdAt > \"$SINCE\")] | .[].number")
245
+
246
+ for PR in $MERGED_PRS; do
247
+ # Get all commits on this PR
248
+ gh api /repos/$REPO/pulls/$PR/commits \
249
+ --jq '.[] | {sha: .sha[0:7], author: .author.login, message: .commit.message | split("\n")[0], date: .commit.author.date}'
250
+ done
251
+ ```
252
+
253
+ **Classification rules** — for each PR's commit list:
254
+
255
+ 1. **Pipeline commit**: author is the project bot account, or commit message matches Forge conventional-commit pattern (`Fix:`, `Feat:`, `Refactor:` with issue reference).
256
+ 2. **Manual fix-up commit**: author is a human AND the commit appears AFTER the initial pipeline commits on the same PR AND the message indicates a correction (e.g., contains "fix", "patch", "missing", "forgot", "actually", "also need", "oops", or references the same component the pipeline just touched).
257
+ 3. **Intentional human commit**: human commit that is clearly additive scope (new feature, unrelated change) — exclude from fix-up count.
258
+
259
+ When ambiguous, read the commit message and diff summary to determine if the human was correcting a pipeline gap or adding new scope.
260
+
261
+ **Also classify PRs by origin** — for each merged PR, determine if it is:
262
+ - **Original work**: primary feature/bug PR from an issue
263
+ - **Review-finding fix**: PR that fixes a review-finding issue (references a `review-finding` labeled issue)
264
+ - **Squash regression fix**: PR whose title/body indicates restoring code lost during a milestone squash merge
265
+
266
+ **Metrics to compute**:
267
+ - **Fix-up rate**: PRs with ≥1 manual fix-up commit / total pipeline-built PRs. Target: < 15%.
268
+ - **Fix-up density**: total manual fix-up commits / total pipeline-built PRs. Target: < 0.3.
269
+ - **Pipeline self-correction rate**: review-finding fix PRs / original work PRs. Target: < 15%.
270
+ - **Squash regression rate**: squash regression fix PRs / total PRs. Target: 0%.
271
+ - **Original work ratio**: original work PRs / total PRs. Target: > 70%.
272
+ - **Fix-up categories**: group by what was missing (config sync, edge case, test gap, incomplete refactor, wrong API usage, UI polish).
273
+
274
+ **Why this matters**: Review findings catch defects post-build. Fix-up commits reveal defects the builder produced that were caught informally (by the developer, by testing, or by deploy failure) — these are "silent" quality gaps that review findings don't capture. The PR composition breakdown shows how much pipeline capacity goes to forward progress vs self-correction.
275
+
276
+ ### 2I: Trajectory analytics (pipeline completeness signal)
277
+
278
+ `FORGE:TRAJECTORY` comments are posted on every completed issue by `/work-on`. They contain the per-issue phase table, investigation verdict, task type, key decisions, and anomalies. Aggregating these reveals investigation accuracy, task type distribution, and anomaly patterns — signals invisible to the PR/issue label metrics above.
279
+
280
+ **Note**: This section makes ~1 API call per closed issue in the window (to fetch comments). For windows with 100+ issues this can take 2–3 minutes.
281
+
282
+ ```bash
283
+ # Collect all issues closed in the window
284
+ CLOSED_ISSUES=$(gh issue list -R $REPO --state closed --limit 100 --json number,closedAt \
285
+ --jq "[.[] | select(.closedAt > \"$SINCE\")] | .[].number")
286
+
287
+ # Accumulators
288
+ TRAJ_TOTAL=0
289
+ TRAJ_CONFIRMED=0
290
+ TRAJ_PARTIAL=0
291
+ TRAJ_INVALID=0
292
+ declare -A TASK_TYPE_COUNTS
293
+ ANOMALY_STRINGS=()
294
+
295
+ for NUM in $CLOSED_ISSUES; do
296
+ TRAJ=$(gh api repos/$REPO/issues/$NUM/comments \
297
+ --jq '.[] | select(.body | contains("FORGE:TRAJECTORY")) | .body' 2>/dev/null)
298
+
299
+ # Skip issues with no trajectory comment (pre-pipeline or manually closed)
300
+ [ -z "$TRAJ" ] && continue
301
+
302
+ TRAJ_TOTAL=$((TRAJ_TOTAL + 1))
303
+
304
+ # Parse investigation verdict from the phase table row
305
+ # Matches: "| Phase 1: Investigation | ✅ CONFIRMED (HIGH) |" or "✅ PARTIAL (MEDIUM) |" etc.
306
+ VERDICT=$(echo "$TRAJ" | grep -oP '(?<=Investigation \| ✅ )(CONFIRMED|PARTIAL|INVALID)' | head -1)
307
+ case "$VERDICT" in
308
+ CONFIRMED) TRAJ_CONFIRMED=$((TRAJ_CONFIRMED + 1)) ;;
309
+ PARTIAL) TRAJ_PARTIAL=$((TRAJ_PARTIAL + 1)) ;;
310
+ INVALID) TRAJ_INVALID=$((TRAJ_INVALID + 1)) ;;
311
+ esac
312
+
313
+ # Parse task type from "Task type: Feature" or "Task type: Bug Fix"
314
+ TASK_TYPE=$(echo "$TRAJ" | grep -oP '(?<=Task type: )[^\|]+' | tr -d ' ' | head -1)
315
+ if [ -n "$TASK_TYPE" ]; then
316
+ TASK_TYPE_COUNTS[$TASK_TYPE]=$(( ${TASK_TYPE_COUNTS[$TASK_TYPE]:-0} + 1 ))
317
+ fi
318
+
319
+ # Collect non-None anomaly strings
320
+ ANOMALY=$(echo "$TRAJ" | grep -oP '(?<=\*\*Anomalies\*\*: )(?!None)[^\n]+' | head -1)
321
+ [ -n "$ANOMALY" ] && ANOMALY_STRINGS+=("$NUM: $ANOMALY")
322
+ done
323
+
324
+ # Compute investigation accuracy rate (CONFIRMED + PARTIAL) / total
325
+ TRAJ_ACCURATE=$((TRAJ_CONFIRMED + TRAJ_PARTIAL))
326
+ if [ "$TRAJ_TOTAL" -gt 0 ]; then
327
+ echo "Trajectory coverage: $TRAJ_TOTAL / $(echo "$CLOSED_ISSUES" | wc -w) closed issues"
328
+ echo "Investigation accuracy: $TRAJ_ACCURATE / $TRAJ_TOTAL ($(echo "scale=0; $TRAJ_ACCURATE * 100 / $TRAJ_TOTAL" | bc)%)"
329
+ echo " CONFIRMED: $TRAJ_CONFIRMED | PARTIAL: $TRAJ_PARTIAL | INVALID: $TRAJ_INVALID"
330
+ echo ""
331
+ echo "Task type distribution:"
332
+ for TT in "${!TASK_TYPE_COUNTS[@]}"; do
333
+ echo " $TT: ${TASK_TYPE_COUNTS[$TT]}"
334
+ done
335
+ echo ""
336
+ echo "Anomalies flagged: ${#ANOMALY_STRINGS[@]}"
337
+ for A in "${ANOMALY_STRINGS[@]}"; do echo " - Issue $A"; done
338
+ else
339
+ echo "No FORGE:TRAJECTORY comments found in window — no trajectory data available"
340
+ fi
341
+ ```
342
+
343
+ **Metrics to compute**:
344
+ - **Investigation accuracy rate**: (CONFIRMED + PARTIAL) / total issues with trajectory. Target: > 85%.
345
+ - **INVALID rate**: INVALID / total. Target: < 15% (same as Phase 2J invalidation rate — these should converge).
346
+ - **Task type distribution**: counts of Bug Fix / Feature / Refactor / Maintenance. Reveals whether the pipeline is doing forward-progress work or mostly self-correction.
347
+ - **Anomaly count**: total issues with non-None anomaly strings. Target: < 10% of window. High anomaly rates indicate pipeline instability.
348
+
349
+ **Why this matters**: Labels tell you what issues are in what state. Trajectory comments tell you *why* — what the investigator concluded, what anomalies were hit, and what decisions were made. Investigation accuracy is the deepest signal for whether the investigation phase is operating correctly. Task type distribution shows whether the pipeline is delivering forward-progress work (Feature/Bug Fix) vs. churn (Refactor/Maintenance cycles).
350
+
351
+ ### 2J: Issue quality metrics (pipeline entry-point signal)
352
+
353
+ Bad issues — vague descriptions, missing files, wrong domains — cause cascading failures: wasted investigation, incomplete fixes, and audit findings classified as `ISSUE_SPEC`. This section measures how healthy the pipeline's entry point is.
354
+
355
+ ```bash
356
+ # 1. Investigation invalidation rate
357
+ # Issues closed as workflow:invalid (excluding review-findings — those are tracked in 2C)
358
+ INVALID_ISSUES=$(gh issue list -R $REPO --state closed --label "workflow:invalid" --limit 500 --json number,createdAt,labels \
359
+ --jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | index(\"review-finding\") | not))] | length")
360
+
361
+ TOTAL_INVESTIGATED=$(gh issue list -R $REPO --state all --limit 500 --json number,createdAt,labels \
362
+ --jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | any(. == \"workflow:investigating\" or . == \"workflow:building\" or . == \"workflow:in-review\" or . == \"workflow:merged\" or . == \"workflow:invalid\"))) ] | length")
363
+
364
+ echo "Investigation invalidation rate: $INVALID_ISSUES / $TOTAL_INVESTIGATED"
365
+ # Target: < 15%
366
+
367
+ # 2. ISSUE_SPEC audit findings
368
+ # Count of /audit findings classified as ISSUE_SPEC in the window
369
+ ISSUE_SPEC_COUNT=$(gh issue list -R RapierCraftStudios/forge --state all --label "audit-finding" --limit 200 \
370
+ --json number,title,body,createdAt \
371
+ --jq "[.[] | select(.createdAt > \"$SINCE\" and (.body | contains(\"ISSUE_SPEC\")))] | length")
372
+
373
+ echo "ISSUE_SPEC audit findings: $ISSUE_SPEC_COUNT"
374
+ # Target: 0. Any ISSUE_SPEC finding means /issue adoption is insufficient or issue quality is degraded.
375
+
376
+ # 3. Re-investigation rate
377
+ # Issues where FORGE:INVESTIGATOR comment was deleted and recreated (interrupted investigation → restart)
378
+ # Proxy: issues that were labeled workflow:investigating more than once (check via timeline events)
379
+ REINVESTIGATED=$(gh issue list -R $REPO --state all --limit 500 --json number,createdAt \
380
+ --jq "[.[] | select(.createdAt > \"$SINCE\")] | .[].number" | while read NUM; do
381
+ # Check if issue has timeline events showing workflow:investigating label added 2+ times
382
+ INVEST_EVENTS=$(gh api repos/$REPO/issues/$NUM/timeline \
383
+ --jq '[.[] | select(.event == "labeled" and .label.name == "workflow:investigating")] | length' 2>/dev/null || echo 0)
384
+ if [ "$INVEST_EVENTS" -gt 1 ] 2>/dev/null; then echo "$NUM"; fi
385
+ done | wc -l)
386
+
387
+ echo "Re-investigation count: $REINVESTIGATED"
388
+ # Target: 0. Each re-investigation signals the original issue spec was unclear or wrong.
389
+
390
+ # 4. Issue structure compliance (/issue adoption proxy)
391
+ # Issues created in window that have an "Affected Files" section in their body
392
+ TOTAL_ISSUES=$(gh issue list -R $REPO --state all --limit 500 --json number,createdAt,labels \
393
+ --jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | index(\"review-finding\") | not))] | length")
394
+
395
+ STRUCTURED_ISSUES=$(gh issue list -R $REPO --state all --limit 500 --json number,createdAt,body,labels \
396
+ --jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | index(\"review-finding\") | not) and (.body | contains(\"Affected Files\")))] | length")
397
+
398
+ echo "Issue structure compliance: $STRUCTURED_ISSUES / $TOTAL_ISSUES"
399
+ # Target: > 80%. Issues without Affected Files are harder to investigate and more likely to produce ISSUE_SPEC failures.
400
+ ```
401
+
402
+ **Issue quality score** = weighted average of sub-metrics:
403
+ - Invalidation rate: 40% weight (direct signal — these issues failed)
404
+ - ISSUE_SPEC audit findings: 30% weight (downstream cost signal)
405
+ - Re-investigation rate: 20% weight (process cost signal)
406
+ - Structure compliance: 10% weight (adoption/leading indicator)
407
+
408
+ **Why this matters**: The investigation phase can only be as good as the issue it receives. A 20% invalidation rate means 1 in 5 investigations was a complete waste. ISSUE_SPEC audit findings are the downstream cost of bad issues reaching production. Tracking these signals closes the loop between issue creation quality and pipeline output quality.
409
+
410
+ ### 2K: Orchestration efficiency (agent throughput signal)
411
+
412
+ `/audit-agents` measures per-run agent efficiency (idle%, resume cycles, stall boundaries) but produces ephemeral output. This section collects persisted `<!-- FORGE:AUDIT-AGENTS -->` summary comments (posted by `audit-agents --persist`) from the Forge tracking issue and aggregates them into pipeline-wide efficiency metrics. If no persisted data exists, it falls back to mining resume-cycle anomalies from FORGE:TRAJECTORY comments collected in Phase 2I.
413
+
414
+ **Step 1 — Query persisted audit-agents summaries**:
415
+
416
+ ```bash
417
+ # Persisted audit-agents summaries are posted to a designated Forge tracking issue
418
+ # tagged with label "orchestration-metrics". Find it:
419
+ TRACKING_ISSUE=$(gh issue list -R RapierCraftStudios/forge \
420
+ --state open --label "orchestration-metrics" --limit 1 \
421
+ --json number --jq '.[0].number' 2>/dev/null)
422
+
423
+ if [ -n "$TRACKING_ISSUE" ]; then
424
+ # Fetch all FORGE:AUDIT-AGENTS comments posted since $SINCE
425
+ AUDIT_COMMENTS=$(gh api repos/RapierCraftStudios/forge/issues/$TRACKING_ISSUE/comments \
426
+ --jq "[.[] | select(.body | contains(\"FORGE:AUDIT-AGENTS\")) | select(.created_at > \"$SINCE\")]")
427
+ echo "Found $(echo "$AUDIT_COMMENTS" | jq 'length') persisted audit-agents summaries in window"
428
+ else
429
+ echo "No orchestration-metrics tracking issue found — skipping persisted data query"
430
+ AUDIT_COMMENTS="[]"
431
+ fi
432
+ ```
433
+
434
+ **Step 2 — Extract metrics from persisted summaries**:
435
+
436
+ Each `<!-- FORGE:AUDIT-AGENTS -->` comment contains a structured block (posted by `audit-agents --persist`) with these fields:
437
+
438
+ ```
439
+ Session: {SESSION_ID}
440
+ Date: {ISO_TIMESTAMP}
441
+ Agents: {TOTAL_AGENT_COUNT}
442
+ Avg idle%: {VALUE}
443
+ Avg resumes: {VALUE}
444
+ Clean agents: {N}/{TOTAL}
445
+ Stall boundaries: {boundary_1}({count}), {boundary_2}({count}), ...
446
+ ```
447
+
448
+ ```bash
449
+ # Parse each comment and accumulate metrics
450
+ ORCH_SESSIONS=0
451
+ ORCH_TOTAL_IDLE=0
452
+ ORCH_TOTAL_RESUMES=0
453
+ ORCH_CLEAN_AGENTS=0
454
+ ORCH_TOTAL_AGENTS=0
455
+ declare -A STALL_BOUNDARY_COUNTS
456
+
457
+ echo "$AUDIT_COMMENTS" | jq -r '.[] | .body + "\u0000"' | while IFS= read -r -d $'\0' COMMENT; do
458
+ ORCH_SESSIONS=$((ORCH_SESSIONS + 1))
459
+
460
+ AVG_IDLE=$(echo "$COMMENT" | grep -oP '(?<=Avg idle%: )[0-9.]+' | head -1)
461
+ AVG_RESUMES=$(echo "$COMMENT" | grep -oP '(?<=Avg resumes: )[0-9.]+' | head -1)
462
+ CLEAN=$(echo "$COMMENT" | grep -oP '(?<=Clean agents: )\d+' | head -1)
463
+ TOTAL=$(echo "$COMMENT" | grep -oP '(?<=Clean agents: \d{1,3}\/)\d+' | head -1)
464
+
465
+ [ -n "$AVG_IDLE" ] && ORCH_TOTAL_IDLE=$(echo "$ORCH_TOTAL_IDLE + $AVG_IDLE" | bc)
466
+ [ -n "$AVG_RESUMES" ] && ORCH_TOTAL_RESUMES=$(echo "$ORCH_TOTAL_RESUMES + $AVG_RESUMES" | bc)
467
+ [ -n "$CLEAN" ] && ORCH_CLEAN_AGENTS=$((ORCH_CLEAN_AGENTS + CLEAN))
468
+ [ -n "$TOTAL" ] && ORCH_TOTAL_AGENTS=$((ORCH_TOTAL_AGENTS + TOTAL))
469
+
470
+ # Accumulate stall boundary counts
471
+ BOUNDARIES=$(echo "$COMMENT" | grep -oP '(?<=Stall boundaries: )[^\n]+' | head -1)
472
+ # Format: "investigate→build(4), context→architect(3)"
473
+ echo "$BOUNDARIES" | grep -oP '[a-z→]+\(\d+\)' | while IFS= read -r ENTRY; do
474
+ BOUNDARY=$(echo "$ENTRY" | grep -oP '^[a-z→]+')
475
+ COUNT=$(echo "$ENTRY" | grep -oP '\d+')
476
+ STALL_BOUNDARY_COUNTS[$BOUNDARY]=$(( ${STALL_BOUNDARY_COUNTS[$BOUNDARY]:-0} + COUNT ))
477
+ done
478
+ done
479
+
480
+ # Compute aggregate metrics
481
+ if [ "$ORCH_SESSIONS" -gt 0 ]; then
482
+ AVG_IDLE_OVERALL=$(echo "scale=1; $ORCH_TOTAL_IDLE / $ORCH_SESSIONS" | bc)
483
+ AVG_RESUMES_OVERALL=$(echo "scale=2; $ORCH_TOTAL_RESUMES / $ORCH_SESSIONS" | bc)
484
+ CLEAN_RATE=$(echo "scale=0; $ORCH_CLEAN_AGENTS * 100 / $ORCH_TOTAL_AGENTS" | bc 2>/dev/null || echo "N/A")
485
+ echo "Orchestration efficiency ($ORCH_SESSIONS sessions, $ORCH_TOTAL_AGENTS agents):"
486
+ echo " Avg idle%: $AVG_IDLE_OVERALL% (target: < 30%)"
487
+ echo " Avg resumes per agent: $AVG_RESUMES_OVERALL (target: < 1)"
488
+ echo " Clean agent rate: $CLEAN_RATE% (target: > 60%)"
489
+ echo " Top stall boundaries:"
490
+ for B in "${!STALL_BOUNDARY_COUNTS[@]}"; do
491
+ echo " $B: ${STALL_BOUNDARY_COUNTS[$B]} occurrences"
492
+ done | sort -t: -k2 -rn | head -5
493
+ ORCH_DATA_AVAILABLE=true
494
+ else
495
+ echo "No persisted audit-agents data in window"
496
+ ORCH_DATA_AVAILABLE=false
497
+ fi
498
+ ```
499
+
500
+ **Step 3 — Fallback: mine FORGE:TRAJECTORY for resume-cycle signals**:
501
+
502
+ If `$ORCH_DATA_AVAILABLE` is false (no persisted summaries), use the trajectory data already collected in Phase 2I as a proxy. FORGE:TRAJECTORY `**Anomalies**` strings from `/work-on` often include resume cycle counts and stall descriptions.
503
+
504
+ ```bash
505
+ if [ "$ORCH_DATA_AVAILABLE" = "false" ]; then
506
+ echo "Falling back to FORGE:TRAJECTORY anomaly mining for orchestration proxy signals"
507
+ # Count how many trajectory issues had anomalies mentioning "resume" or "stall"
508
+ RESUME_ANOMALIES=0
509
+ for A in "${ANOMALY_STRINGS[@]}"; do
510
+ echo "$A" | grep -qi 'resume\|stall\|end.turn\|routing' && RESUME_ANOMALIES=$((RESUME_ANOMALIES + 1))
511
+ done
512
+ echo "Issues with resume/stall anomalies in trajectory: $RESUME_ANOMALIES / $TRAJ_TOTAL"
513
+ echo "(Proxy only — run 'audit-agents --persist' after orchestration runs for full efficiency data)"
514
+ fi
515
+ ```
516
+
517
+ **Metrics to compute**:
518
+ - **Avg agent idle%**: average idle time percentage across all agents in all sessions. Target: < 30%.
519
+ - **Avg resume cycles per agent**: how often orchestrator must resume stalled agents. Target: < 1.
520
+ - **Clean agent rate**: % of agents that complete without any stalls. Target: > 60%.
521
+ - **Top stall boundaries**: which phase transitions cause the most stalls (e.g., investigate→build, context→architect). No target — use for diagnosis.
522
+
523
+ **Why this matters**: Outcome metrics (findings per PR, fix-up rate) measure what the pipeline produces. Orchestration efficiency measures how much wasted motion occurs getting there. An agent that takes 40 minutes to do 8 minutes of actual work has 80% idle time — all of which is pipeline overhead. Reducing stall time directly improves throughput without changing code quality. This is the only metric that reveals whether the routing loop in `work-on.md` is functioning or causing agents to stop and wait.
524
+
525
+ ### 2L: Post-deploy failure metrics (pipeline escape signal)
526
+
527
+ `/audit` creates `audit-finding` labeled issues in the Forge repo whenever a defect escapes the pipeline and reaches staging or production. Each finding is tagged with a `**Failure point**:` field indicating which pipeline phase failed (INVESTIGATION, IMPLEMENTATION, REVIEW, REVIEW_FALSE_NEG, DEPLOY_GATE, etc.). Aggregating these reveals the post-deploy defect rate and review escape rate — signals invisible to the pre-merge metrics above.
528
+
529
+ **Why this matters**: Build pass rate measures whether PRs merge successfully. Post-deploy failure rate measures whether those merged PRs actually work correctly in production. The health score can read 90+ while production failures accumulate if post-deploy data is excluded. Review escape rate directly measures review agent effectiveness: a REVIEW_FALSE_NEG finding means the review agent saw the change but approved a defect — the most actionable failure mode.
530
+
531
+ ```bash
532
+ # All audit-finding issues created in the window
533
+ AUDIT_FINDINGS=$(gh issue list -R RapierCraftStudios/forge --state all --label "audit-finding" \
534
+ --limit 200 --json number,title,body,createdAt \
535
+ --jq "[.[] | select(.createdAt > \"$SINCE\")]")
536
+
537
+ AUDIT_TOTAL=$(echo "$AUDIT_FINDINGS" | jq 'length')
538
+ echo "Audit findings in window: $AUDIT_TOTAL"
539
+
540
+ # Post-deploy failure rate = audit findings / PRs merged in window
541
+ # (MERGED_COUNT computed in Phase 2E)
542
+ if [ "$MERGED_COUNT" -gt 0 ] && [ "$AUDIT_TOTAL" -gt 0 ]; then
543
+ echo "Post-deploy failure rate: $(echo "scale=1; $AUDIT_TOTAL * 100 / $MERGED_COUNT" | bc)% (target: < 5%)"
544
+ else
545
+ echo "Post-deploy failure rate: 0% (no audit findings in window)"
546
+ fi
547
+
548
+ # Review escape rate = REVIEW_FALSE_NEG findings / PRs merged in window
549
+ REVIEW_ESCAPE_COUNT=$(echo "$AUDIT_FINDINGS" | \
550
+ jq '[.[] | select(.body | test("\\*\\*Failure point\\*\\*:.*REVIEW_FALSE_NEG"))] | length')
551
+ echo "Review escape count: $REVIEW_ESCAPE_COUNT"
552
+ if [ "$MERGED_COUNT" -gt 0 ]; then
553
+ echo "Review escape rate: $(echo "scale=1; $REVIEW_ESCAPE_COUNT * 100 / $MERGED_COUNT" | bc)% (target: < 2%)"
554
+ fi
555
+
556
+ # Failure category distribution (by Failure point field)
557
+ echo ""
558
+ echo "Failure point distribution:"
559
+ echo "$AUDIT_FINDINGS" | jq -r \
560
+ '.[] | .body | capture("\\*\\*Failure point\\*\\*: *(?<fp>[^\\n]+)").fp // "UNKNOWN"' \
561
+ | sort | uniq -c | sort -rn
562
+
563
+ # Mean time to detect (days between issue createdAt and linked PR's mergedAt)
564
+ # Simplified: use audit finding createdAt as proxy for detection time relative to window start
565
+ # Full MTTD requires joining audit-finding body for the source PR number and querying its mergedAt
566
+ echo ""
567
+ echo "Mean time to detect (proxy — days from window open to finding creation):"
568
+ echo "$AUDIT_FINDINGS" | jq -r \
569
+ --arg since "$SINCE" \
570
+ '[.[] | {days: ((.createdAt | fromdateiso8601) - ($since | fromdateiso8601)) / 86400}] | if length > 0 then (map(.days) | add / length) else 0 end' \
571
+ | awk '{printf "%.1f days\n", $1}'
572
+
573
+ # Severity breakdown (P1 vs P2 vs P3 from labels)
574
+ echo ""
575
+ echo "Severity breakdown:"
576
+ gh issue list -R RapierCraftStudios/forge --state all --label "audit-finding" \
577
+ --limit 200 --json number,createdAt,labels \
578
+ --jq "[.[] | select(.createdAt > \"$SINCE\")] | group_by(.labels | map(.name) | map(select(startswith(\"P\"))) | .[0]) | map({severity: .[0].labels | map(.name) | map(select(startswith(\"P\"))) | .[0], count: length})"
579
+ ```
580
+
581
+ **Metrics to compute**:
582
+ - **Post-deploy failure rate**: audit findings in window / PRs merged in window. Target: < 5%. Each finding represents a defect that passed the full pipeline and required post-merge remediation.
583
+ - **Review escape rate**: REVIEW_FALSE_NEG findings / PRs merged in window. Target: < 2%. This is the most direct measure of review agent effectiveness — these are defects the review agent saw but approved.
584
+ - **Failure category distribution**: breakdown by failure point (INVESTIGATION, IMPLEMENTATION, REVIEW, REVIEW_FALSE_NEG, DEPLOY_GATE, etc.). No target — use for diagnosis. High IMPLEMENTATION counts indicate builder gaps; high REVIEW_FALSE_NEG counts indicate review agent gaps.
585
+ - **Mean time to detect (MTD)**: average days from PR merge to audit-finding creation. Target: < 7 days. Long MTD means defects linger undetected in production.
586
+
587
+ ### 2M: Session discovery and date-gated filtering (conversation transcript analytics)
588
+
589
+ This phase locates conversation JSONL files for pipeline command sessions (work-on, orchestrate, review-pr, quality-gate, review-pr-staging) that ran within the analysis window. It produces `SESSION_JSONL_FILES` — consumed by downstream Conversation Transcript Analytics phases to extract flow metrics (phase durations, tool patterns, subagent efficiency, stalls, rate-limits).
590
+
591
+ **Why `history.jsonl` and not mtime**: `history.jsonl` timestamps record session START time (accurate to the minute). File mtime records the last WRITE — it trails session start by an average of 156 minutes. Date-gating on mtime would misclassify sessions that started before `$SINCE` but were still writing at the cutoff.
592
+
593
+ **Why XML `<command-name>` tag and not `display` field**: The `display` field records what the user TYPED, not what actually ran. A session where the user types `/work-on 350` then immediately `/clear` appears as a pipeline session in `display` but the JSONL contains only a `/clear` run. In testing, the `display`-only filter produced a 40% false positive rate. The `<command-name>` XML tag in the first few lines of the JSONL file records the actual executed command and is authoritative.
594
+
595
+ **Path construction algorithm** (empirically verified from investigation #348):
596
+
597
+ ```bash
598
+ # Convert a Claude Code project path to its ~/.claude/projects/ directory name
599
+ # Rule: both '/' and '.' are replaced with '-'
600
+ # Examples:
601
+ # /home/user/projects/forge -> -home-user-projects-forge
602
+ # /home/user/projects/ScraperAPI/.claude/worktrees/fix-branch
603
+ # -> -home-user-projects-ScraperAPI--claude-worktrees-fix-branch
604
+ # (note: .claude -> --claude because '.' becomes '-' and surrounding '/' also become '-')
605
+ _session_project_dir() {
606
+ echo "$1" | sed 's|[/.]|-|g'
607
+ }
608
+ ```
609
+
610
+ **Step 1 — Parse history.jsonl and filter to pipeline sessions in window**:
611
+
612
+ ```bash
613
+ HISTORY_FILE="$HOME/.claude/history.jsonl"
614
+ PIPELINE_COMMANDS='work-on|orchestrate|review-pr|quality-gate|review-pr-staging'
615
+
616
+ if [ ! -f "$HISTORY_FILE" ]; then
617
+ echo "Phase 2M: $HISTORY_FILE not found — skipping session discovery"
618
+ SESSION_JSONL_FILES=()
619
+ SESSION_TOTAL=0
620
+ else
621
+ echo "Phase 2M: parsing $HISTORY_FILE for pipeline sessions since $SINCE"
622
+
623
+ # Extract pipeline sessions: entries where display starts with a pipeline command
624
+ # and timestamp is after $SINCE. One JSON object per line.
625
+ # Output: tab-separated sessionId, project, timestamp, display
626
+ SESSION_CANDIDATES=$(jq -r --arg since "$SINCE" --arg cmds "$PIPELINE_COMMANDS" '
627
+ select(
628
+ .timestamp != null and
629
+ .timestamp > $since and
630
+ .sessionId != null and
631
+ .project != null and
632
+ .display != null and
633
+ (.display | test("^/(" + $cmds + ")( |$)"))
634
+ ) |
635
+ [.sessionId, .project, .timestamp, (.display | split(" ") | .[0])] | @tsv
636
+ ' "$HISTORY_FILE" 2>/dev/null)
637
+
638
+ # Deduplicate by sessionId — keep only the earliest entry per session
639
+ # (earliest timestamp = session start time, most accurate for date filtering)
640
+ DEDUPED_SESSIONS=$(echo "$SESSION_CANDIDATES" | sort -t$'\t' -k3,3 | sort -t$'\t' -k1,1 -u)
641
+
642
+ SESSION_COUNT=$(echo "$DEDUPED_SESSIONS" | grep -c . 2>/dev/null || echo 0)
643
+ echo "Phase 2M: found $SESSION_COUNT unique pipeline sessions in window (pre-disk verification)"
644
+ fi
645
+ ```
646
+
647
+ **Step 2 — Map session IDs to JSONL files on disk**:
648
+
649
+ ```bash
650
+ SESSION_JSONL_FILES=()
651
+ SESSION_MISSING_COUNT=0
652
+ SESSION_VERIFIED_COUNT=0
653
+ SESSION_FP_COUNT=0
654
+
655
+ CLAUDE_PROJECTS_DIR="$HOME/.claude/projects"
656
+
657
+ while IFS=$'\t' read -r SESSION_ID PROJECT TIMESTAMP DISPLAY_CMD; do
658
+ [ -z "$SESSION_ID" ] && continue
659
+
660
+ # Convert project path to directory name
661
+ PROJECT_DIR=$(_session_project_dir "$PROJECT")
662
+ JSONL_PATH="$CLAUDE_PROJECTS_DIR/$PROJECT_DIR/$SESSION_ID.jsonl"
663
+
664
+ # Skip if file does not exist on disk (older/pruned sessions, different machine)
665
+ if [ ! -f "$JSONL_PATH" ]; then
666
+ SESSION_MISSING_COUNT=$((SESSION_MISSING_COUNT + 1))
667
+ continue
668
+ fi
669
+
670
+ # Verify actual command via first <command-name> XML tag in JSONL
671
+ # Pipeline commands are stored with double-slash prefix: //work-on, //orchestrate, etc.
672
+ # Read only the first 5 lines (enough to find the command tag — it appears in the first message)
673
+ ACTUAL_CMD=$(head -5 "$JSONL_PATH" 2>/dev/null | \
674
+ grep -oP '(?<=<command-name>)//[^<]+(?=</command-name>)' | head -1)
675
+
676
+ # Strip the double-slash prefix for comparison
677
+ ACTUAL_CMD_CLEAN="${ACTUAL_CMD#//}"
678
+
679
+ # Verify the actual command matches a pipeline command
680
+ if ! echo "$ACTUAL_CMD_CLEAN" | grep -qP "^($PIPELINE_COMMANDS)$"; then
681
+ SESSION_FP_COUNT=$((SESSION_FP_COUNT + 1))
682
+ continue # False positive — display said pipeline command but JSONL ran something else
683
+ fi
684
+
685
+ # Determine subagent directory (JSONL files for agent sub-calls within this session)
686
+ # Sub-agent JSONL files share the same project dir but have different session IDs
687
+ # The parent session's own JSONL is the primary; subagents appear as sibling files
688
+ SUBAGENT_DIR="$CLAUDE_PROJECTS_DIR/$PROJECT_DIR"
689
+
690
+ SESSION_JSONL_FILES+=("$SESSION_ID $ACTUAL_CMD_CLEAN $TIMESTAMP $JSONL_PATH $SUBAGENT_DIR")
691
+ SESSION_VERIFIED_COUNT=$((SESSION_VERIFIED_COUNT + 1))
692
+
693
+ done <<< "$DEDUPED_SESSIONS"
694
+
695
+ SESSION_TOTAL=${#SESSION_JSONL_FILES[@]}
696
+
697
+ echo "Phase 2M: session discovery complete"
698
+ echo " Verified pipeline sessions: $SESSION_TOTAL"
699
+ echo " Missing on disk (pruned): $SESSION_MISSING_COUNT"
700
+ echo " False positives filtered: $SESSION_FP_COUNT"
701
+ echo ""
702
+ echo "SESSION_JSONL_FILES contains $SESSION_TOTAL entries — available for transcript analytics phases"
703
+ ```
704
+
705
+ **Output format** (each element in `SESSION_JSONL_FILES` is a tab-separated record):
706
+
707
+ | Field | Content | Example |
708
+ |-------|---------|---------|
709
+ | `sessionId` | Claude Code session UUID | `abc123-def456-...` |
710
+ | `command` | Actual pipeline command run | `work-on` |
711
+ | `timestamp` | Session start (from history.jsonl) | `2026-05-28T14:23:01Z` |
712
+ | `jsonlPath` | Absolute path to JSONL file | `~/.claude/projects/-home-.../abc123.jsonl` |
713
+ | `subagentDir` | Directory containing sibling JSONL files | `~/.claude/projects/-home-.../` |
714
+
715
+ **Performance characteristics** (from investigation #348 empirical measurement):
716
+ - `history.jsonl` parse + filter (25K entries): **<100ms**
717
+ - mtime scan of 900 disk files: **<2ms**
718
+ - Head-5 JSONL read for XML tag: **negligible** (<1ms per file)
719
+ - Total Phase 2M runtime: **<5s** for 25K history entries with 300 pipeline sessions
720
+
721
+ **Skip conditions**: If `~/.claude/history.jsonl` does not exist (different machine, fresh install), set `SESSION_TOTAL=0` and emit a warning. Downstream transcript analytics phases must gracefully skip when `SESSION_TOTAL == 0`.
722
+
723
+ ---
724
+
725
+ ### JSONL Parser Utility
726
+
727
+ <!-- FORGE:JSONL_PARSER_UTIL -->
728
+
729
+ This utility defines a reusable Python snippet for parsing Claude Code's local conversation JSONL files. It is **not a numbered phase** — it is a helper block that transcript analytics phases (added by the Conversation Transcript Analytics milestone) invoke at runtime.
730
+
731
+ **Schema source**: #346 (investigate: map Claude Code JSONL transcript schema) — 8 message types mapped across 979 records.
732
+
733
+ **Privacy contract**: Raw conversation content is NEVER emitted. Only structural metadata is extracted: tool names, skill identifiers, agent descriptions, timing data, and session metadata.
734
+
735
+ **Usage** (phases that call this parser pass a JSONL file path):
736
+ ```bash
737
+ # Parse a single JSONL file and emit one structured event per line (NDJSON)
738
+ JSONL_FILE="~/.claude/projects/{project}/{sessionId}.jsonl"
739
+ python3 - "$JSONL_FILE" << 'PYEOF'
740
+ import json, sys, os
741
+
742
+ jsonl_file = sys.argv[1] if len(sys.argv) > 1 else None
743
+ if not jsonl_file:
744
+ print(json.dumps({"error": "No JSONL file path provided"}), flush=True)
745
+ sys.exit(1)
746
+
747
+ def parse_jsonl_file(path):
748
+ """Parse a Claude Code JSONL session file. Emits one JSON event per line."""
749
+ try:
750
+ with open(path, "r", encoding="utf-8") as f:
751
+ lines = f.readlines()
752
+ except (OSError, IOError) as e:
753
+ print(json.dumps({"error": f"Cannot open {path}: {e}"}), flush=True)
754
+ return
755
+
756
+ for lineno, raw in enumerate(lines, 1):
757
+ raw = raw.strip()
758
+ if not raw:
759
+ continue
760
+ try:
761
+ msg = json.loads(raw)
762
+ except json.JSONDecodeError:
763
+ # Malformed line — skip silently (schema resilience rule)
764
+ continue
765
+
766
+ msg_type = msg.get("type", "")
767
+ timestamp = msg.get("timestamp", "")
768
+ session_id = msg.get("sessionId", "")
769
+ is_subagent = bool(msg.get("isSidechain", False))
770
+ agent_id = msg.get("agentId")
771
+
772
+ # Base event skeleton — only structural fields, no content
773
+ base = {
774
+ "timestamp": timestamp,
775
+ "session_id": session_id,
776
+ "is_subagent": is_subagent,
777
+ "lineno": lineno,
778
+ }
779
+ if agent_id:
780
+ base["agent_id"] = agent_id
781
+
782
+ if msg_type == "assistant":
783
+ # Extract tool_use blocks from assistant message content
784
+ content = msg.get("message", {}).get("content", [])
785
+ if not isinstance(content, list):
786
+ content = []
787
+ for block in content:
788
+ if not isinstance(block, dict):
789
+ continue
790
+ block_type = block.get("type", "")
791
+ if block_type == "tool_use":
792
+ tool_name = block.get("name", "")
793
+ tool_input = block.get("input", {}) or {}
794
+
795
+ if tool_name == "Skill":
796
+ # Skill invocation — extract skill name and args (structural only)
797
+ event = {**base,
798
+ "event_type": "skill",
799
+ "tool_name": "Skill",
800
+ "skill_name": tool_input.get("skill", ""),
801
+ "skill_args": tool_input.get("args", ""),
802
+ }
803
+ elif tool_name == "Agent":
804
+ # Agent spawn — extract description and model (no prompt content)
805
+ event = {**base,
806
+ "event_type": "agent_spawn",
807
+ "tool_name": "Agent",
808
+ "agent_desc": tool_input.get("description", ""),
809
+ "agent_model": tool_input.get("model", ""),
810
+ }
811
+ else:
812
+ # All other tool calls — name only, no input content
813
+ event = {**base,
814
+ "event_type": "tool_use",
815
+ "tool_name": tool_name,
816
+ }
817
+ print(json.dumps(event), flush=True)
818
+ # text and thinking blocks: skip (raw content — privacy rule)
819
+
820
+ elif msg_type == "system":
821
+ subtype = msg.get("subtype", "")
822
+ if subtype == "turn_duration":
823
+ # Authoritative phase timing signal — use durationMs, NOT timestamp arithmetic
824
+ event = {**base,
825
+ "event_type": "turn_duration",
826
+ "duration_ms": msg.get("durationMs", 0),
827
+ "parent_uuid": msg.get("parentUuid", ""),
828
+ }
829
+ print(json.dumps(event), flush=True)
830
+ elif subtype == "compact_boundary":
831
+ # Context compaction marker
832
+ compact_meta = msg.get("compactMetadata", {}) or {}
833
+ event = {**base,
834
+ "event_type": "compact",
835
+ "pre_tokens": compact_meta.get("preTokens", 0),
836
+ "trigger": compact_meta.get("trigger", ""),
837
+ }
838
+ print(json.dumps(event), flush=True)
839
+ # local_command and other system subtypes: skip (content-bearing)
840
+
841
+ elif msg_type == "user":
842
+ # user messages: only emit session-start marker for first message (parentUuid=null)
843
+ if msg.get("parentUuid") is None and not is_subagent:
844
+ event = {**base,
845
+ "event_type": "session_start",
846
+ "is_meta": bool(msg.get("isMeta", False)),
847
+ }
848
+ print(json.dumps(event), flush=True)
849
+
850
+ elif msg_type == "progress":
851
+ # Agent progress — only structural signal (agentId, parentToolUseID)
852
+ data = msg.get("data", {}) or {}
853
+ if data.get("type") == "agent_progress":
854
+ event = {**base,
855
+ "event_type": "agent_progress",
856
+ "progress_agent_id": data.get("agentId", ""),
857
+ "parent_tool_use_id": msg.get("parentToolUseID", ""),
858
+ }
859
+ print(json.dumps(event), flush=True)
860
+ # hook_progress: skip
861
+
862
+ elif msg_type in ("queue-operation", "pr-link", "last-prompt", "file-history-snapshot"):
863
+ # Known non-analytics types — skip silently
864
+ pass
865
+
866
+ else:
867
+ # Unknown type — emit as unknown_type for forward-compatibility diagnostics
868
+ event = {**base,
869
+ "event_type": "unknown_type",
870
+ "type": msg_type,
871
+ }
872
+ print(json.dumps(event), flush=True)
873
+
874
+ parse_jsonl_file(jsonl_file)
875
+ PYEOF
876
+ ```
877
+
878
+ **Calling convention**: Pass the JSONL file path as the first argument:
879
+ ```bash
880
+ python3 -c "..." "$JSONL_FILE" # inline version (for short scripts)
881
+ python3 - "$JSONL_FILE" << 'PYEOF' # heredoc version — '-' reads from stdin, arg follows
882
+ ```
883
+
884
+ **Output format** (NDJSON — one JSON object per line):
885
+
886
+ | `event_type` | Fields emitted | Source |
887
+ |---|---|---|
888
+ | `tool_use` | `tool_name`, `timestamp`, `session_id`, `is_subagent` | `assistant` content `tool_use` blocks |
889
+ | `skill` | `tool_name="Skill"`, `skill_name`, `skill_args`, `timestamp`, `is_subagent` | `assistant` content where `name=="Skill"` |
890
+ | `agent_spawn` | `tool_name="Agent"`, `agent_desc`, `agent_model`, `timestamp`, `is_subagent` | `assistant` content where `name=="Agent"` |
891
+ | `turn_duration` | `duration_ms`, `parent_uuid`, `timestamp` | `system {subtype: "turn_duration"}` |
892
+ | `compact` | `pre_tokens`, `trigger`, `timestamp` | `system {subtype: "compact_boundary"}` |
893
+ | `session_start` | `is_meta`, `timestamp` | first `user` message (`parentUuid=null`) |
894
+ | `agent_progress` | `progress_agent_id`, `parent_tool_use_id`, `timestamp` | `progress {data.type: "agent_progress"}` |
895
+ | `unknown_type` | `type` | any unrecognized `type` value |
896
+
897
+ **Schema resilience rules**:
898
+ 1. `msg.get('field')` throughout — absent fields return `None`, never crash
899
+ 2. Malformed JSON lines are silently skipped (try/except per line)
900
+ 3. Unknown `type` values emit `unknown_type` event — not silently dropped
901
+ 4. Both `usage` variants handled (parser doesn't touch `usage` — structural extraction only)
902
+ 5. `is_subagent` populated from `isSidechain` field, not filename heuristic
903
+
904
+ **Subagent detection**:
905
+ - `isSidechain: true` → `is_subagent: true` in all events from that file
906
+ - `agentId` field present → `agent_id` field in output
907
+ - Subagent files located at: `~/.claude/projects/{project}/{sessionId}/subagents/agent-{agentId}.jsonl`
908
+
909
+ **Worktree session detection** (for session discovery phases):
910
+ ```bash
911
+ # Project directory name regex to detect worktree sessions
912
+ echo "$PROJECT_DIR" | grep -qP '^-.*--claude-worktrees-.*$' && echo "worktree" || echo "main"
913
+ ```
914
+
915
+ ---
916
+
917
+ ### 2N: Transcript flow metrics (session efficiency signal)
918
+
919
+ Parses the JSONL files discovered in Phase 2M to extract pipeline session flow metrics: per-turn durations, tool call frequency, context compaction events, stall turns (turns taking >2 min), and rate-limit indicator turns. This is the only metric that sees _inside_ a running session — all other Phase 2 metrics observe outcomes (PRs merged, findings created) not internal dynamics.
920
+
921
+ **Prerequisite**: Phase 2M must have run. If `SESSION_TOTAL == 0`, skip this phase and set `TRANSCRIPT_DATA_AVAILABLE=false`.
922
+
923
+ **This phase reads local disk only — no GitHub API calls.**
924
+
925
+ **Step 1 — Check prerequisite and select files to parse**:
926
+
927
+ ```bash
928
+ if [ "${SESSION_TOTAL:-0}" -eq 0 ]; then
929
+ echo "Phase 2N: SESSION_TOTAL=0 (Phase 2M found no sessions or history.jsonl missing) — skipping"
930
+ TRANSCRIPT_DATA_AVAILABLE=false
931
+ else
932
+ TRANSCRIPT_DATA_AVAILABLE=true
933
+
934
+ # Performance guard: cap at 50 sessions to bound parse time.
935
+ # If SESSION_TOTAL > 50: select 30 most recent + 10 largest (by JSONL file size) + 10 random from remainder.
936
+ # Deduplication is implicit — most-recent and largest sets are drawn from the same array without replacement.
937
+ if [ "${SESSION_TOTAL}" -gt 50 ]; then
938
+ echo "Phase 2N: $SESSION_TOTAL sessions found — sampling 50 (30 most recent + 10 largest + 10 random) for performance"
939
+
940
+ # SESSION_JSONL_FILES is a bash array; each element: sessionId\tcommand\ttimestamp\tjsonlPath\tsubagentDir
941
+ # Step A: sort by timestamp (field 3) descending → pick first 30
942
+ SORTED_BY_TIME=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}" | sort -t$'\t' -k3,3 -r)
943
+ RECENT_30=$(echo "$SORTED_BY_TIME" | head -30)
944
+ RECENT_IDS=$(echo "$RECENT_30" | cut -f1)
945
+
946
+ # Step B: from remaining entries (not in RECENT_IDS), sort by JSONL file size → pick 10 largest
947
+ REMAINDER=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}" | grep -vFf <(echo "$RECENT_IDS") 2>/dev/null || true)
948
+ LARGEST_10=""
949
+ if [ -n "$REMAINDER" ]; then
950
+ LARGEST_10=$(echo "$REMAINDER" | while IFS=$'\t' read -r SID CMD TS JP SD; do
951
+ SZ=$(stat -c%s "$JP" 2>/dev/null || echo 0)
952
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$SZ" "$SID" "$CMD" "$TS" "$JP" "$SD"
953
+ done | sort -rn | head -10 | cut -f2-)
954
+ LARGEST_IDS=$(echo "$LARGEST_10" | cut -f1)
955
+ fi
956
+
957
+ # Step C: from entries not yet selected, pick 10 at random
958
+ EXCLUDED=$(printf '%s\n%s\n' "$RECENT_IDS" "${LARGEST_IDS:-}")
959
+ POOL=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}" | grep -vFf <(echo "$EXCLUDED") 2>/dev/null || true)
960
+ RANDOM_10=""
961
+ if [ -n "$POOL" ]; then
962
+ RANDOM_10=$(echo "$POOL" | shuf | head -10)
963
+ fi
964
+
965
+ # Combine and rebuild SESSION_JSONL_FILES (deduplicated)
966
+ COMBINED=$(printf '%s\n%s\n%s\n' "$RECENT_30" "${LARGEST_10:-}" "${RANDOM_10:-}" | grep -v '^$' | sort -u)
967
+ SESSION_TOTAL_ORIGINAL=$SESSION_TOTAL
968
+ SESSION_JSONL_FILES=()
969
+ while IFS= read -r ENTRY; do
970
+ [ -z "$ENTRY" ] && continue
971
+ SESSION_JSONL_FILES+=("$ENTRY")
972
+ done <<< "$COMBINED"
973
+ SESSION_TOTAL=${#SESSION_JSONL_FILES[@]}
974
+ echo "Phase 2N: sampled $SESSION_TOTAL of $SESSION_TOTAL_ORIGINAL sessions (30 most recent + 10 largest + 10 random)"
975
+ else
976
+ echo "Phase 2N: parsing $SESSION_TOTAL session JSONL files for flow metrics"
977
+ SESSION_TOTAL_ORIGINAL=$SESSION_TOTAL
978
+ fi
979
+ fi
980
+ ```
981
+
982
+ **Step 2 — Parse each session and aggregate metrics (inline Python)**:
983
+
984
+ **IMPORTANT**: Run as a single Python script. JSONL files can be large (500+ lines, 100KB+). Budget: 2 minutes total.
985
+
986
+ ```python
987
+ import json, sys, os
988
+ from collections import defaultdict
989
+
990
+ # Read SESSION_JSONL_FILES from Phase 2M environment
991
+ # Each entry is a tab-separated record: sessionId \t command \t timestamp \t jsonlPath \t subagentDir
992
+ session_records_raw = os.environ.get('SESSION_JSONL_FILES_RAW', '')
993
+
994
+ # Aggregate accumulators
995
+ all_durations_ms = [] # from turn_duration events
996
+ total_tool_counts = defaultdict(int) # tool_name → call count
997
+ total_compactions = 0 # compact_boundary events
998
+ total_stall_turns = 0 # turns > 120000 ms
999
+ total_rate_limit_turns = 0 # turns 60000–120000 ms (possible rate-limit)
1000
+ parent_session_count = 0
1001
+ subagent_session_count = 0
1002
+ parse_errors = 0
1003
+
1004
+ def process_jsonl_file(jsonl_path, is_subagent=False):
1005
+ """Parse one JSONL file using the JSONL Parser Utility event model."""
1006
+ global total_compactions, total_stall_turns, total_rate_limit_turns, parse_errors
1007
+
1008
+ try:
1009
+ with open(jsonl_path, encoding='utf-8', errors='replace') as f:
1010
+ for line in f:
1011
+ line = line.strip()
1012
+ if not line:
1013
+ continue
1014
+ try:
1015
+ msg = json.loads(line)
1016
+ except json.JSONDecodeError:
1017
+ parse_errors += 1
1018
+ continue
1019
+
1020
+ msg_type = msg.get('type', '')
1021
+ file_is_subagent = bool(msg.get('isSidechain', False)) or is_subagent
1022
+
1023
+ if msg_type == 'assistant':
1024
+ # Tool call frequency
1025
+ content = msg.get('message', {}).get('content', [])
1026
+ if isinstance(content, list):
1027
+ for block in content:
1028
+ if isinstance(block, dict) and block.get('type') == 'tool_use':
1029
+ total_tool_counts[block.get('name', 'unknown')] += 1
1030
+
1031
+ elif msg_type == 'system':
1032
+ subtype = msg.get('subtype', '')
1033
+ if subtype == 'turn_duration':
1034
+ dur = msg.get('durationMs')
1035
+ if isinstance(dur, (int, float)) and dur > 0:
1036
+ all_durations_ms.append(dur)
1037
+ if dur > 120000:
1038
+ total_stall_turns += 1
1039
+ elif dur > 60000:
1040
+ total_rate_limit_turns += 1
1041
+ elif subtype == 'compact_boundary':
1042
+ total_compactions += 1
1043
+
1044
+ # Unknown types: silently skip (schema resilience)
1045
+
1046
+ except (IOError, OSError):
1047
+ parse_errors += 1
1048
+
1049
+ # Process each session from SESSION_JSONL_FILES_RAW
1050
+ for record in session_records_raw.strip().split('\n'):
1051
+ if not record.strip():
1052
+ continue
1053
+ parts = record.split('\t')
1054
+ if len(parts) < 4:
1055
+ continue
1056
+ session_id, command, timestamp, jsonl_path = parts[0], parts[1], parts[2], parts[3]
1057
+ subagent_dir = parts[4] if len(parts) > 4 else ''
1058
+
1059
+ # Expand ~ in path
1060
+ jsonl_path = os.path.expanduser(jsonl_path)
1061
+ if not os.path.isfile(jsonl_path):
1062
+ continue
1063
+
1064
+ parent_session_count += 1
1065
+ process_jsonl_file(jsonl_path, is_subagent=False)
1066
+
1067
+ # Also parse subagent files in the same session directory
1068
+ if subagent_dir:
1069
+ sub_dir = os.path.expanduser(os.path.join(os.path.dirname(jsonl_path), session_id, 'subagents'))
1070
+ if os.path.isdir(sub_dir):
1071
+ for fname in os.listdir(sub_dir):
1072
+ if fname.startswith('agent-') and fname.endswith('.jsonl'):
1073
+ subagent_session_count += 1
1074
+ process_jsonl_file(os.path.join(sub_dir, fname), is_subagent=True)
1075
+
1076
+ # Output aggregated metrics
1077
+ total_turns = len(all_durations_ms)
1078
+ avg_duration_ms = sum(all_durations_ms) / len(all_durations_ms) if all_durations_ms else 0
1079
+ max_duration_ms = max(all_durations_ms) if all_durations_ms else 0
1080
+ stall_rate = (total_stall_turns / total_turns * 100) if total_turns > 0 else 0
1081
+
1082
+ print(f"\n=== Phase 2N: Transcript Flow Metrics ===")
1083
+ print(f"Pipeline sessions parsed: {parent_session_count} parent, {subagent_session_count} subagent")
1084
+ print(f"Total turns with timing: {total_turns}")
1085
+ print(f"Avg turn duration: {avg_duration_ms/1000:.1f}s")
1086
+ print(f"Max turn duration: {max_duration_ms/1000:.1f}s")
1087
+ print(f"Stall turns (>120s): {total_stall_turns} ({stall_rate:.1f}% of turns) — target: < 10%")
1088
+ print(f"Rate-limit indicator turns (60–120s): {total_rate_limit_turns}")
1089
+ print(f"Context compaction events: {total_compactions}")
1090
+ print()
1091
+ print("Tool call frequency (top 10):")
1092
+ for tool, count in sorted(total_tool_counts.items(), key=lambda x: -x[1])[:10]:
1093
+ print(f" {tool}: {count}")
1094
+ ```
1095
+
1096
+ To run this step:
1097
+
1098
+ ```bash
1099
+ if [ "$TRANSCRIPT_DATA_AVAILABLE" = "true" ]; then
1100
+ # Serialize SESSION_JSONL_FILES array to newline-separated string for Python
1101
+ export SESSION_JSONL_FILES_RAW
1102
+ SESSION_JSONL_FILES_RAW=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}")
1103
+ PHASE_2N_OUTPUT=$(python3 - << 'PYEOF'
1104
+ # [paste the Python script above here at runtime]
1105
+ PYEOF
1106
+ )
1107
+ echo "$PHASE_2N_OUTPUT"
1108
+
1109
+ # Extract bash variables from Phase 2N output for use in Phase 3C health score
1110
+ # "Stall turns (>120s): N (X.Y% of turns) — target: < 10%"
1111
+ STALL_TURN_RATE=$(echo "$PHASE_2N_OUTPUT" | grep -oP 'Stall turns \(>120s\): \d+ \(\K[\d.]+(?=% of turns)')
1112
+ STALL_TURN_RATE=${STALL_TURN_RATE:-0}
1113
+ # "Context compaction events: N"
1114
+ COMPACTION_TOTAL=$(echo "$PHASE_2N_OUTPUT" | grep -oP 'Context compaction events: \K\d+')
1115
+ COMPACTION_TOTAL=${COMPACTION_TOTAL:-0}
1116
+ export STALL_TURN_RATE COMPACTION_TOTAL
1117
+ echo "Phase 2N exports: STALL_TURN_RATE=$STALL_TURN_RATE COMPACTION_TOTAL=$COMPACTION_TOTAL"
1118
+ fi
1119
+ ```
1120
+
1121
+ **Metrics produced**:
1122
+ - **Pipeline sessions in window**: sessions confirmed in Phase 2M that actually ran a pipeline command (`work-on`, `orchestrate`, `review-pr`, etc.)
1123
+ - **Avg turn duration (ms)**: mean of all `system.turn_duration.durationMs` values. Uses `durationMs` directly — NOT timestamp arithmetic (timestamps are non-monotonic).
1124
+ - **Stall turns**: turns exceeding 120s. Each stall is a turn where the agent spent >2 min — often a rate-limit backoff or very large context. Target: < 10% of turns.
1125
+ - **Rate-limit indicator turns**: turns 60–120s — possible but not certain rate-limit waits. Informational.
1126
+ - **Context compaction events**: count of `system.compact_boundary` records. High compaction rates suggest prompts or tool outputs are too large, causing agents to lose intermediate state.
1127
+ - **Tool call frequency**: which tools dominate. Reveals whether agents are read-heavy (Bash/Read/Grep) vs coordination-heavy (Skill/Agent).
1128
+
1129
+ **Skip condition**: If `TRANSCRIPT_DATA_AVAILABLE` is false, emit:
1130
+ > `Phase 2N: Transcript flow metrics unavailable — no pipeline sessions found by Phase 2M (SESSION_TOTAL=0). Run /pipeline-health on the same machine where Claude Code sessions are stored.`
1131
+
1132
+ ---
1133
+
1134
+ ### 2O: Per-session metric extraction (structured session metrics)
1135
+
1136
+ Reads the same JSONL files discovered by Phase 2M to produce a **structured metrics dict per session**. Where Phase 2N aggregates across all sessions, Phase 2O isolates each session independently — enabling downstream phases (subagent flow #352, privacy aggregation #353, health report #354) to bucket, normalize, and compare sessions by command type, task complexity, and model distribution.
1137
+
1138
+ **Prerequisite**: Phase 2N must have run. If `TRANSCRIPT_DATA_AVAILABLE != true`, skip this phase.
1139
+
1140
+ **This phase reads local disk only — no GitHub API calls.**
1141
+
1142
+ **Metrics per session (HIGH and MEDIUM actionability only per #347)**:
1143
+ - `total_duration_ms` — sum of all `system.turn_duration.durationMs` values (active computation time; NOT wall-clock)
1144
+ - `tool_call_counts` — dict of tool_name → count for all tool_use blocks
1145
+ - `skill_invocations` — list of `{phase_name, skill_name, skill_args}` in call order
1146
+ - `agent_spawns` — list of `{agent_desc, model}` for each Agent tool call
1147
+ - `model_distribution` — dict of model_name → count (from `message.model` on assistant messages; proxy for sonnet/opus fallback pressure)
1148
+ - `compaction_count` — count of `system {subtype: compact_boundary}` events
1149
+
1150
+ **NOT included** (SKIP tier from #347): wall-clock duration, rate-limit event detection.
1151
+
1152
+ **Step 1 — Check prerequisite**:
1153
+
1154
+ ```bash
1155
+ if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" != "true" ]; then
1156
+ echo "Phase 2O: TRANSCRIPT_DATA_AVAILABLE=false (Phase 2N skipped or no sessions) — skipping"
1157
+ PER_SESSION_METRICS_RAW=""
1158
+ else
1159
+ echo "Phase 2O: extracting per-session metrics from $SESSION_TOTAL sessions"
1160
+ fi
1161
+ ```
1162
+
1163
+ **Step 2 — Parse each session and emit structured NDJSON (inline Python)**:
1164
+
1165
+ ```python
1166
+ import json, sys, os
1167
+ from collections import defaultdict
1168
+
1169
+ # Skill name → pipeline phase name mapping
1170
+ SKILL_PHASE_MAP = {
1171
+ 'work-on': 'work-on',
1172
+ 'quality-gate': 'build:validate',
1173
+ 'review-pr': 'review',
1174
+ 'review-pr-staging':'review',
1175
+ 'cleanup': 'close',
1176
+ 'orchestrate': 'orchestrate',
1177
+ 'frontend-design': 'build:implement',
1178
+ 'validate': 'validate',
1179
+ 'audit': 'audit',
1180
+ 'pipeline-health': 'pipeline-health',
1181
+ }
1182
+
1183
+ def parse_session_jsonl(jsonl_path):
1184
+ """Parse one JSONL file and return per-session metric dict."""
1185
+ metrics = {
1186
+ 'total_duration_ms': 0,
1187
+ 'tool_call_counts': defaultdict(int),
1188
+ 'skill_invocations': [], # [{phase_name, skill_name, skill_args}]
1189
+ 'agent_spawns': [], # [{agent_desc, model}]
1190
+ 'model_distribution': defaultdict(int), # model_name → count
1191
+ 'compaction_count': 0,
1192
+ 'parse_errors': 0,
1193
+ }
1194
+
1195
+ try:
1196
+ with open(jsonl_path, encoding='utf-8', errors='replace') as f:
1197
+ for line in f:
1198
+ line = line.strip()
1199
+ if not line:
1200
+ continue
1201
+ try:
1202
+ msg = json.loads(line)
1203
+ except json.JSONDecodeError:
1204
+ metrics['parse_errors'] += 1
1205
+ continue
1206
+
1207
+ msg_type = msg.get('type', '')
1208
+
1209
+ if msg_type == 'assistant':
1210
+ # Track model distribution (SAFE field per #347)
1211
+ model = msg.get('message', {}).get('model', '')
1212
+ if model:
1213
+ metrics['model_distribution'][model] += 1
1214
+
1215
+ # Extract tool_use blocks
1216
+ content = msg.get('message', {}).get('content', [])
1217
+ if not isinstance(content, list):
1218
+ content = []
1219
+ for block in content:
1220
+ if not isinstance(block, dict):
1221
+ continue
1222
+ if block.get('type') != 'tool_use':
1223
+ continue
1224
+ tool_name = block.get('name', 'unknown')
1225
+ tool_input = block.get('input', {}) or {}
1226
+
1227
+ # Count all tool calls by name
1228
+ metrics['tool_call_counts'][tool_name] += 1
1229
+
1230
+ if tool_name == 'Skill':
1231
+ # Map skill name to pipeline phase
1232
+ skill_name = tool_input.get('skill', '')
1233
+ skill_args = tool_input.get('args', '')
1234
+ phase_name = SKILL_PHASE_MAP.get(skill_name, 'other')
1235
+ metrics['skill_invocations'].append({
1236
+ 'phase_name': phase_name,
1237
+ 'skill_name': skill_name,
1238
+ 'skill_args': str(skill_args)[:80], # truncate — no raw content
1239
+ })
1240
+ elif tool_name == 'Agent':
1241
+ # Extract description and model only — NOT prompt content (UNSAFE per #347)
1242
+ metrics['agent_spawns'].append({
1243
+ 'agent_desc': str(tool_input.get('description', ''))[:120],
1244
+ 'model': tool_input.get('model', ''),
1245
+ })
1246
+
1247
+ elif msg_type == 'system':
1248
+ subtype = msg.get('subtype', '')
1249
+ if subtype == 'turn_duration':
1250
+ dur = msg.get('durationMs')
1251
+ if isinstance(dur, (int, float)) and dur > 0:
1252
+ metrics['total_duration_ms'] += dur
1253
+ elif subtype == 'compact_boundary':
1254
+ metrics['compaction_count'] += 1
1255
+
1256
+ # All other message types: skip (content-bearing or irrelevant)
1257
+
1258
+ except (IOError, OSError):
1259
+ metrics['parse_errors'] += 1
1260
+
1261
+ return metrics
1262
+
1263
+ # Read SESSION_JSONL_FILES from Phase 2M
1264
+ session_records_raw = os.environ.get('SESSION_JSONL_FILES_RAW', '')
1265
+
1266
+ session_results = []
1267
+ total_sessions = 0
1268
+ print(f"\n=== Phase 2O: Per-Session Metric Extraction ===")
1269
+
1270
+ for record in session_records_raw.strip().split('\n'):
1271
+ if not record.strip():
1272
+ continue
1273
+ parts = record.split('\t')
1274
+ if len(parts) < 4:
1275
+ continue
1276
+ session_id = parts[0]
1277
+ command = parts[1]
1278
+ timestamp = parts[2]
1279
+ jsonl_path = os.path.expanduser(parts[3])
1280
+
1281
+ if not os.path.isfile(jsonl_path):
1282
+ continue
1283
+
1284
+ total_sessions += 1
1285
+ m = parse_session_jsonl(jsonl_path)
1286
+
1287
+ # Serialize to JSON-safe dict (convert defaultdicts)
1288
+ session_record = {
1289
+ 'session_id': session_id,
1290
+ 'command': command,
1291
+ 'timestamp': timestamp,
1292
+ 'total_duration_ms': m['total_duration_ms'],
1293
+ 'tool_call_counts': dict(m['tool_call_counts']),
1294
+ 'skill_invocations': m['skill_invocations'],
1295
+ 'agent_spawns': m['agent_spawns'],
1296
+ 'model_distribution': dict(m['model_distribution']),
1297
+ 'compaction_count': m['compaction_count'],
1298
+ 'parse_errors': m['parse_errors'],
1299
+ }
1300
+ session_results.append(session_record)
1301
+ # Emit as NDJSON line (consumed by downstream phases via PER_SESSION_METRICS_RAW)
1302
+ print(json.dumps(session_record), flush=True)
1303
+
1304
+ # Print summary table
1305
+ print(f"\nParsed {total_sessions} sessions.")
1306
+ print(f"{'Session':<20} {'Command':<16} {'DurationS':>9} {'Tools':>6} {'Skills':>7} {'Agents':>7} {'Compact':>8} {'Sonnet/Opus':>12}")
1307
+ print("-" * 92)
1308
+ for s in session_results:
1309
+ dur_s = s['total_duration_ms'] / 1000
1310
+ tools = sum(s['tool_call_counts'].values())
1311
+ skills = len(s['skill_invocations'])
1312
+ agents = len(s['agent_spawns'])
1313
+ compact = s['compaction_count']
1314
+ sonnet = sum(v for k, v in s['model_distribution'].items() if 'sonnet' in k.lower())
1315
+ opus = sum(v for k, v in s['model_distribution'].items() if 'opus' in k.lower())
1316
+ sid_short = s['session_id'][:18]
1317
+ cmd_short = s['command'][:14]
1318
+ print(f"{sid_short:<20} {cmd_short:<16} {dur_s:>9.1f} {tools:>6} {skills:>7} {agents:>7} {compact:>8} {sonnet:>5}/{opus:<5}")
1319
+ ```
1320
+
1321
+ To run this step:
1322
+
1323
+ ```bash
1324
+ if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" = "true" ]; then
1325
+ export SESSION_JSONL_FILES_RAW
1326
+ SESSION_JSONL_FILES_RAW=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}")
1327
+ PER_SESSION_METRICS_RAW=$(python3 - << 'PYEOF'
1328
+ # [paste the Python script above here at runtime]
1329
+ PYEOF
1330
+ )
1331
+ echo "$PER_SESSION_METRICS_RAW"
1332
+ export PER_SESSION_METRICS_RAW
1333
+ fi
1334
+ ```
1335
+
1336
+ **Metrics produced per session**:
1337
+ - **`total_duration_ms`**: sum of `system.turn_duration.durationMs` — active agent compute time, excludes human idle. Use this; NOT wall-clock timestamps.
1338
+ - **`tool_call_counts`**: dict of every tool name called and how many times. Bash:Edit ratio reveals investigator vs builder behaviour.
1339
+ - **`skill_invocations`**: ordered list of Skill calls with mapped phase names. Reveals which pipeline phases ran in this session and how many times each was invoked.
1340
+ - **`agent_spawns`**: list of Agent tool calls with model used. Reveals subagent spawning patterns and model choices.
1341
+ - **`model_distribution`**: sonnet vs opus request count. Proxy for API fallback pressure — a session with many opus requests may have experienced rate-limiting.
1342
+ - **`compaction_count`**: per-session context compaction events. >1 = session spanned multiple context windows.
1343
+
1344
+ **Output variable**: `PER_SESSION_METRICS_RAW` — NDJSON string (one JSON record per line), one record per session. Consumed by Phase 2O's print output and by downstream phases (#352 subagent flow, #353 privacy aggregation, #354 health report integration). Export it with `export PER_SESSION_METRICS_RAW` so downstream phases can read it.
1345
+
1346
+ **Skip condition**: If `TRANSCRIPT_DATA_AVAILABLE` is false, emit:
1347
+ > `Phase 2O: Per-session metrics unavailable — Phase 2N did not produce session data. SESSION_TOTAL must be > 0 for this phase to run.`
1348
+
1349
+ ---
1350
+
1351
+ ### 2P: Subagent flow analysis (role-mapped efficiency signal)
1352
+
1353
+ Parses subagent JSONL files for each pipeline session to extract per-role efficiency metrics: which pipeline role each subagent performed, how long each agent ran, how many tools it called, and whether the same role was retried multiple times within a session. This phase complements Phase 2N's aggregate-level flow metrics with role-level breakdowns — revealing which pipeline phases (investigate, build, review) are slowest, most retry-prone, or most model-diverse.
1354
+
1355
+ **Prerequisite**: Phase 2N must have run. If `TRANSCRIPT_DATA_AVAILABLE` is false, skip this phase.
1356
+
1357
+ **This phase reads local disk only — no GitHub API calls.**
1358
+
1359
+ **Role mapping rules** (applied to `agent_desc` from `agent_spawn` events):
1360
+
1361
+ | Pattern | Mapped Role |
1362
+ |---------|-------------|
1363
+ | `^(Investigate|investigate)\s+` | `investigate` |
1364
+ | `^(Work on|work on|Resume #\d+ build|Continue #\d+ build)\s+` | `build` |
1365
+ | `^(Review|review|Resume.*review|Continue.*review)\s*` | `review` |
1366
+ | `^Review\+Close\s+` | `review` |
1367
+ | `^(Resume #\d+ close|Continue #\d+ close)\s*` | `close` |
1368
+ | `^Quality gate\s*` | `quality-gate` |
1369
+ | `^(Orchestrate|orchestrate)\s+` | `orchestrate` |
1370
+ | domain agent names (e.g., `API bug hunter`, `Billing.*review`, `Security.*review`, `Frontend.*quality`, `General security`, `Auth.*review`, `Infra.*review`) | `review` |
1371
+ | No pattern matches | `other` |
1372
+
1373
+ **Retry detection**: Within a single session, if the same role is assigned to more than one subagent, subsequent agents are classified as retried (positional detection: second+ agent in same session+role = retry).
1374
+
1375
+ **Step 1 — Check prerequisite**:
1376
+
1377
+ ```bash
1378
+ if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" != "true" ]; then
1379
+ echo "Phase 2P: TRANSCRIPT_DATA_AVAILABLE=false — skipping subagent flow analysis"
1380
+ SUBAGENT_DATA_AVAILABLE=false
1381
+ else
1382
+ echo "Phase 2P: analyzing subagent flow for $SESSION_TOTAL sessions"
1383
+ SUBAGENT_DATA_AVAILABLE=true
1384
+ fi
1385
+ ```
1386
+
1387
+ **Step 2 — Parse agent_spawn events and subagent files (inline Python)**:
1388
+
1389
+ **IMPORTANT**: Run as a single Python script. Budget: 2 minutes total.
1390
+
1391
+ ```python
1392
+ import json, sys, os, re
1393
+ from collections import defaultdict
1394
+
1395
+ # Role mapping: ordered list of (regex_pattern, role_name) tuples
1396
+ # Evaluated in order; first match wins. Domain review agents match last (broad patterns).
1397
+ ROLE_PATTERNS = [
1398
+ (re.compile(r'^(Investigate|investigate)\s+', re.I), 'investigate'),
1399
+ (re.compile(r'^(Work on|work on)\s+', re.I), 'build'),
1400
+ (re.compile(r'^Resume #\d+ build', re.I), 'build'),
1401
+ (re.compile(r'^Continue #\d+ build', re.I), 'build'),
1402
+ (re.compile(r'^forge#\d+\b', re.I), 'build'),
1403
+ (re.compile(r'^Review\+Close\s+', re.I), 'review'),
1404
+ (re.compile(r'^Resume.*close', re.I), 'close'),
1405
+ (re.compile(r'^Continue.*close', re.I), 'close'),
1406
+ (re.compile(r'^Quality gate', re.I), 'quality-gate'),
1407
+ (re.compile(r'^(Orchestrate|orchestrate)\s+', re.I), 'orchestrate'),
1408
+ (re.compile(r'^(Review|review)\s+', re.I), 'review'),
1409
+ (re.compile(r'^Resume.*review', re.I), 'review'),
1410
+ (re.compile(r'^Continue.*review', re.I), 'review'),
1411
+ # Domain review agent patterns (broad — must come after specific patterns)
1412
+ (re.compile(r'(bug hunter|security|quality audit|quality review|billing.*review|auth.*review|infra.*review|frontend.*review|API.*review|API.*audit|DB.*review|concurr.*review)', re.I), 'review'),
1413
+ (re.compile(r'General security', re.I), 'review'),
1414
+ ]
1415
+
1416
+ def map_role(agent_desc):
1417
+ """Map agent_desc string to a pipeline role name."""
1418
+ for pattern, role in ROLE_PATTERNS:
1419
+ if pattern.search(agent_desc):
1420
+ return role
1421
+ return 'other'
1422
+
1423
+ # Read SESSION_JSONL_FILES_RAW from Phase 2M/2N environment
1424
+ session_records_raw = os.environ.get('SESSION_JSONL_FILES_RAW', '')
1425
+
1426
+ # Per-role accumulators
1427
+ # role → list of agent records: {session_id, desc, model, duration_ms, tool_count, compact_count, is_retry}
1428
+ role_agents = defaultdict(list)
1429
+
1430
+ def compute_subagent_metrics(subagent_path):
1431
+ """Return (duration_ms, tool_count, compact_count) for a subagent JSONL file."""
1432
+ duration_ms = 0
1433
+ tool_count = 0
1434
+ compact_count = 0
1435
+ try:
1436
+ with open(subagent_path, encoding='utf-8', errors='replace') as f:
1437
+ for line in f:
1438
+ line = line.strip()
1439
+ if not line:
1440
+ continue
1441
+ try:
1442
+ msg = json.loads(line)
1443
+ except json.JSONDecodeError:
1444
+ continue
1445
+ msg_type = msg.get('type', '')
1446
+ if msg_type == 'assistant':
1447
+ content = msg.get('message', {}).get('content', [])
1448
+ if isinstance(content, list):
1449
+ for block in content:
1450
+ if isinstance(block, dict) and block.get('type') == 'tool_use':
1451
+ tool_count += 1
1452
+ elif msg_type == 'system':
1453
+ subtype = msg.get('subtype', '')
1454
+ if subtype == 'turn_duration':
1455
+ dur = msg.get('durationMs')
1456
+ if isinstance(dur, (int, float)) and dur > 0:
1457
+ duration_ms += dur
1458
+ elif subtype == 'compact_boundary':
1459
+ compact_count += 1
1460
+ except (IOError, OSError):
1461
+ pass
1462
+ return duration_ms, tool_count, compact_count
1463
+
1464
+ # Process each parent session
1465
+ for record in session_records_raw.strip().split('\n'):
1466
+ if not record.strip():
1467
+ continue
1468
+ parts = record.split('\t')
1469
+ if len(parts) < 4:
1470
+ continue
1471
+ session_id, command, timestamp, jsonl_path = parts[0], parts[1], parts[2], parts[3]
1472
+ jsonl_path = os.path.expanduser(jsonl_path)
1473
+ if not os.path.isfile(jsonl_path):
1474
+ continue
1475
+
1476
+ # Collect agent_spawn events from the parent JSONL (structural only)
1477
+ spawned_agents = [] # list of {desc, model}
1478
+ try:
1479
+ with open(jsonl_path, encoding='utf-8', errors='replace') as f:
1480
+ for line in f:
1481
+ line = line.strip()
1482
+ if not line:
1483
+ continue
1484
+ try:
1485
+ msg = json.loads(line)
1486
+ except json.JSONDecodeError:
1487
+ continue
1488
+ if msg.get('type') != 'assistant':
1489
+ continue
1490
+ content = msg.get('message', {}).get('content', [])
1491
+ if not isinstance(content, list):
1492
+ continue
1493
+ for block in content:
1494
+ if not isinstance(block, dict):
1495
+ continue
1496
+ if block.get('type') == 'tool_use' and block.get('name') == 'Agent':
1497
+ inp = block.get('input', {}) or {}
1498
+ agent_desc = inp.get('description', '')
1499
+ agent_model = inp.get('model', 'unknown')
1500
+ spawned_agents.append({'desc': agent_desc, 'model': agent_model})
1501
+ except (IOError, OSError):
1502
+ continue
1503
+
1504
+ if not spawned_agents:
1505
+ continue
1506
+
1507
+ # Subagent JSONL directory: dirname(jsonl_path)/{session_id}/subagents/
1508
+ sub_dir = os.path.join(os.path.dirname(jsonl_path), session_id, 'subagents')
1509
+
1510
+ # Track roles seen in this session for retry detection (positional)
1511
+ session_role_counts = defaultdict(int) # role → spawn count in this session
1512
+ spawn_counter = 0 # overall counter across all roles (positional file index)
1513
+
1514
+ for spawn in spawned_agents:
1515
+ role = map_role(spawn['desc'])
1516
+ session_role_counts[role] += 1
1517
+ # Positional retry: second+ spawn of same role in same session = retry
1518
+ is_retry = (session_role_counts[role] > 1)
1519
+
1520
+ # Best-effort: associate this spawn with a subagent file by position (sorted order)
1521
+ # Cannot map tool_use id to agent file hash — take file at overall spawn position
1522
+ duration_ms = 0
1523
+ tool_count = 0
1524
+ compact_count = 0
1525
+
1526
+ if os.path.isdir(sub_dir):
1527
+ agent_files = sorted([
1528
+ f for f in os.listdir(sub_dir)
1529
+ if f.startswith('agent-') and f.endswith('.jsonl')
1530
+ ])
1531
+ # Use overall spawn position (across all roles) as index into sorted file list
1532
+ if spawn_counter < len(agent_files):
1533
+ fpath = os.path.join(sub_dir, agent_files[spawn_counter])
1534
+ duration_ms, tool_count, compact_count = compute_subagent_metrics(fpath)
1535
+
1536
+ spawn_counter += 1
1537
+ role_agents[role].append({
1538
+ 'session_id': session_id,
1539
+ 'desc': spawn['desc'],
1540
+ 'model': spawn['model'],
1541
+ 'duration_ms': duration_ms,
1542
+ 'tool_count': tool_count,
1543
+ 'compact_count': compact_count,
1544
+ 'is_retry': is_retry,
1545
+ })
1546
+
1547
+ # Aggregate metrics by role
1548
+ print("\n=== Phase 2P: Subagent Flow Analysis ===")
1549
+ print()
1550
+
1551
+ all_roles = sorted(role_agents.keys())
1552
+ model_distribution = defaultdict(int)
1553
+
1554
+ for role in all_roles:
1555
+ agents = role_agents[role]
1556
+ total = len(agents)
1557
+ retried = sum(1 for a in agents if a.get('is_retry', False))
1558
+ retry_rate = (retried / total * 100) if total > 0 else 0
1559
+
1560
+ durations = [a['duration_ms'] for a in agents if a['duration_ms'] > 0]
1561
+ avg_dur_s = (sum(durations) / len(durations) / 1000) if durations else 0
1562
+ max_dur_s = (max(durations) / 1000) if durations else 0
1563
+
1564
+ tool_counts = [a['tool_count'] for a in agents]
1565
+ avg_tools = (sum(tool_counts) / len(tool_counts)) if tool_counts else 0
1566
+
1567
+ compacts = sum(a['compact_count'] for a in agents)
1568
+
1569
+ for a in agents:
1570
+ model_distribution[a['model']] += 1
1571
+
1572
+ print(f"Role: {role} ({total} agents, {retried} retried — retry rate: {retry_rate:.0f}%)")
1573
+ print(f" Avg duration: {avg_dur_s:.1f}s Max: {max_dur_s:.1f}s")
1574
+ print(f" Avg tool calls: {avg_tools:.1f} Total compactions: {compacts}")
1575
+ print()
1576
+
1577
+ print("Model distribution (across all roles):")
1578
+ for model, count in sorted(model_distribution.items(), key=lambda x: -x[1]):
1579
+ print(f" {model}: {count} agents")
1580
+
1581
+ total_agents = sum(len(v) for v in role_agents.values())
1582
+ total_retried = sum(sum(1 for a in v if a.get('is_retry', False)) for v in role_agents.values())
1583
+ print()
1584
+ print(f"Total subagents analyzed: {total_agents}")
1585
+ print(f"Total retried agents: {total_retried} ({(total_retried/total_agents*100) if total_agents else 0:.1f}%)")
1586
+ ```
1587
+
1588
+ To run this step:
1589
+
1590
+ ```bash
1591
+ if [ "$SUBAGENT_DATA_AVAILABLE" = "true" ]; then
1592
+ export SESSION_JSONL_FILES_RAW
1593
+ python3 - << 'PYEOF'
1594
+ # [paste the Python script above here at runtime]
1595
+ PYEOF
1596
+ fi
1597
+ ```
1598
+
1599
+ **Metrics produced**:
1600
+ - **Per-role agent count**: how many subagents handled each pipeline role (investigate, build, review, quality-gate, close, orchestrate, other) in the window.
1601
+ - **Retry rate by role**: % of agents for that role that were retried (same role spawned more than once per session). High retry rates indicate stall or failure patterns for that phase.
1602
+ - **Avg duration by role (seconds)**: mean of all `turn_duration.durationMs` values summed per subagent. Uses `durationMs` — NOT timestamp arithmetic.
1603
+ - **Avg tool calls by role**: mean tool call count per subagent in that role. High counts may indicate agents doing excessive re-reads or exploration.
1604
+ - **Model distribution**: breakdown of `sonnet` vs `opus` (and other models) across all subagents. Opus appearances indicate rate-limit fallback or explicit override — they cost more and run slower.
1605
+ - **Total compactions**: count of `compact_boundary` events across all subagents in the role. High compaction indicates agents hitting context limits — prompt size or tool output volume issue.
1606
+
1607
+ **Interpretation guide**:
1608
+ - **Retry rate > 20% for a role** → that pipeline phase is fragile: agents stall, produce errors, or get externally resumed more than expected.
1609
+ - **Avg duration for build > 600s** → builder agents are slow — likely doing excessive re-reads, hitting rate limits, or working on high-complexity issues that may need decomposition.
1610
+ - **Opus count > 10% of total** → significant rate-limit fallback occurring — consider spreading pipeline sessions or reducing prompt sizes.
1611
+ - **High compactions in review role** → review agents are loading too much context (large diffs or too many files) and compacting mid-review, which degrades finding quality.
1612
+
1613
+ **Skip condition**: If `SUBAGENT_DATA_AVAILABLE` is false, emit:
1614
+ > `Phase 2P: Subagent flow analysis unavailable — TRANSCRIPT_DATA_AVAILABLE=false from Phase 2N. Requires Phase 2M session discovery to have found pipeline sessions on this machine.`
1615
+
1616
+ ---
1617
+
1618
+ ### 2Q: Privacy-safe aggregation and harmful-takeaway guardrails
1619
+
1620
+ Aggregates per-session metrics from Phase 2O's `PER_SESSION_METRICS_RAW` into a sanitized, report-ready summary. **No content fields pass through this phase** — only structural data (tool names, counts, durations, models). Session IDs are stripped. Output is context-framed with distribution ranges (median + P25/P75) to prevent misleading averages. Harmful-takeaway guardrail footnotes are emitted as a block for Phase 5A to embed directly in the health report.
1621
+
1622
+ **Prerequisite**: Phase 2O must have run. If `TRANSCRIPT_DATA_AVAILABLE` is false or `PER_SESSION_METRICS_RAW` is empty, skip and set `AGGREGATED_TRANSCRIPT_METRICS=""`.
1623
+
1624
+ **This phase reads only `PER_SESSION_METRICS_RAW` (in-memory) — no disk reads, no GitHub API calls.**
1625
+
1626
+ **Field whitelist** (deny by default — only these fields are read from each session record):
1627
+ - `command` — pipeline command name (safe: structural)
1628
+ - `total_duration_ms` — active compute time (safe: timing only)
1629
+ - `tool_call_counts` — dict of tool_name → count (safe: tool names are structural)
1630
+ - `skill_invocations[].skill_name` — skill name only (safe: structural); `skill_args` is NOT read
1631
+ - `model_distribution` — model_name → count dict (safe: model names only); authoritative source for sonnet/opus split
1632
+ - `compaction_count` — integer (safe: count only)
1633
+
1634
+ **Fields explicitly excluded** (even though present in Phase 2O output):
1635
+ - `session_id` — opaque UUID; not useful for pipeline improvement; excluded from all output
1636
+ - `skill_invocations[].skill_args` — may contain issue numbers (safe) but also file paths or branch names (contextual); excluded to keep the whitelist strict
1637
+ - `agent_spawns[].model` — excluded from model_freq to prevent double-counting with `model_distribution`; spawn intent ≠ response actuality; Phase 2P uses this field separately for spawn pattern analysis <!-- Added: forge#369 -->
1638
+ - `agent_spawns[].agent_desc` — may contain file paths or code context; excluded per #347 privacy classification
1639
+
1640
+ **Step 1 — Prerequisite guard**:
1641
+
1642
+ ```bash
1643
+ if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" != "true" ] || [ -z "${PER_SESSION_METRICS_RAW:-}" ]; then
1644
+ echo "Phase 2Q: skipping — TRANSCRIPT_DATA_AVAILABLE=false or PER_SESSION_METRICS_RAW empty"
1645
+ AGGREGATED_TRANSCRIPT_METRICS=""
1646
+ TRANSCRIPT_AGGREGATION_AVAILABLE=false
1647
+ else
1648
+ echo "Phase 2Q: aggregating privacy-safe metrics from PER_SESSION_METRICS_RAW"
1649
+ TRANSCRIPT_AGGREGATION_AVAILABLE=true
1650
+ fi
1651
+ ```
1652
+
1653
+ **Step 2 — Aggregate and sanitize (inline Python)**:
1654
+
1655
+ ```python
1656
+ import json, sys, os, statistics
1657
+ from collections import defaultdict
1658
+
1659
+ raw = os.environ.get('PER_SESSION_METRICS_RAW', '')
1660
+ if not raw.strip():
1661
+ print('Phase 2Q: PER_SESSION_METRICS_RAW is empty — nothing to aggregate')
1662
+ sys.exit(0)
1663
+
1664
+ # Accumulators (whitelisted fields only — no session_id, no skill_args, no agent_desc)
1665
+ durations_ms = [] # total_duration_ms per session
1666
+ tool_freq = defaultdict(int) # tool_name → total call count across all sessions
1667
+ skill_freq = defaultdict(int) # skill_name → invocation count across all sessions
1668
+ model_freq = defaultdict(int) # model_name → message count across all sessions
1669
+ compaction_counts = [] # compaction_count per session
1670
+ command_buckets = defaultdict(list) # command → list of duration_ms (for task-type normalization)
1671
+ sessions_parsed = 0
1672
+ sessions_skipped = 0
1673
+
1674
+ for line in raw.strip().split('\n'):
1675
+ line = line.strip()
1676
+ if not line:
1677
+ continue
1678
+ try:
1679
+ rec = json.loads(line)
1680
+ except json.JSONDecodeError:
1681
+ sessions_skipped += 1
1682
+ continue
1683
+
1684
+ sessions_parsed += 1
1685
+
1686
+ # session_id: intentionally NOT read — stripped by omission
1687
+ command = str(rec.get('command', 'unknown'))[:32] # safe: command name only
1688
+
1689
+ dur = rec.get('total_duration_ms', 0)
1690
+ if isinstance(dur, (int, float)) and dur >= 0:
1691
+ durations_ms.append(dur)
1692
+ command_buckets[command].append(dur)
1693
+
1694
+ # Tool call counts: tool names only (structural)
1695
+ for tool_name, count in (rec.get('tool_call_counts') or {}).items():
1696
+ if isinstance(count, int):
1697
+ tool_freq[str(tool_name)[:64]] += count
1698
+
1699
+ # Skill invocations: skill_name only — skill_args intentionally NOT read
1700
+ for inv in (rec.get('skill_invocations') or []):
1701
+ if isinstance(inv, dict):
1702
+ sk = inv.get('skill_name', '')
1703
+ if sk:
1704
+ skill_freq[str(sk)[:32]] += 1
1705
+
1706
+ # Model distribution: model name → count (authoritative source for sonnet/opus split)
1707
+ # agent_spawns[].model is intentionally NOT included here — it counts spawn intent, not
1708
+ # actual response messages, and overlaps with model_distribution in all realistic sessions.
1709
+ for m, cnt in (rec.get('model_distribution') or {}).items():
1710
+ if isinstance(cnt, int):
1711
+ model_freq[str(m)[:64]] += cnt
1712
+
1713
+ cc = rec.get('compaction_count', 0)
1714
+ if isinstance(cc, int):
1715
+ compaction_counts.append(cc)
1716
+
1717
+ # Distribution helpers (no numpy — pure stdlib)
1718
+ def _median(lst):
1719
+ if not lst: return 0
1720
+ s = sorted(lst)
1721
+ n = len(s)
1722
+ return s[n // 2] if n % 2 == 1 else (s[n//2 - 1] + s[n//2]) / 2
1723
+
1724
+ def _p25(lst):
1725
+ if not lst: return 0
1726
+ s = sorted(lst)
1727
+ return s[max(0, len(s)//4 - 1)]
1728
+
1729
+ def _p75(lst):
1730
+ if not lst: return 0
1731
+ s = sorted(lst)
1732
+ return s[min(len(s)-1, (3*len(s))//4)]
1733
+
1734
+ n = sessions_parsed
1735
+ dur_median_s = _median(durations_ms) / 1000 if durations_ms else 0
1736
+ dur_p25_s = _p25(durations_ms) / 1000 if durations_ms else 0
1737
+ dur_p75_s = _p75(durations_ms) / 1000 if durations_ms else 0
1738
+ compact_median = _median(compaction_counts) if compaction_counts else 0
1739
+ compact_p25 = _p25(compaction_counts) if compaction_counts else 0
1740
+ compact_p75 = _p75(compaction_counts) if compaction_counts else 0
1741
+
1742
+ # Top tools (top 8 by frequency)
1743
+ top_tools = sorted(tool_freq.items(), key=lambda x: -x[1])[:8]
1744
+ # Top skills
1745
+ top_skills = sorted(skill_freq.items(), key=lambda x: -x[1])[:6]
1746
+ # Model split
1747
+ total_model_msgs = sum(model_freq.values()) or 1
1748
+ sonnet_msgs = sum(v for k, v in model_freq.items() if 'sonnet' in k.lower())
1749
+ opus_msgs = sum(v for k, v in model_freq.items() if 'opus' in k.lower())
1750
+
1751
+ print(f"\n=== Phase 2Q: Privacy-Safe Aggregated Transcript Metrics ===")
1752
+ print(f"Sessions aggregated: {n} (skipped/malformed: {sessions_skipped})")
1753
+ print(f"Session IDs: NOT INCLUDED (stripped by whitelist)")
1754
+ print()
1755
+ print(f"Active compute duration (agent time, excludes human idle):")
1756
+ print(f" Median: {dur_median_s:.0f}s [P25: {dur_p25_s:.0f}s — P75: {dur_p75_s:.0f}s] (across {n} pipeline runs)")
1757
+ print()
1758
+ print(f"Context compaction events per session:")
1759
+ print(f" Median: {compact_median:.1f} [P25: {compact_p25:.1f} — P75: {compact_p75:.1f}]")
1760
+ print()
1761
+ print("Tool call distribution (top 8, all sessions combined):")
1762
+ for tool, cnt in top_tools:
1763
+ print(f" {tool}: {cnt}")
1764
+ print()
1765
+ print("Skill invocations (top 6):")
1766
+ for skill, cnt in top_skills:
1767
+ print(f" {skill}: {cnt}")
1768
+ print()
1769
+ print(f"Model distribution: sonnet={sonnet_msgs} ({100*sonnet_msgs//total_model_msgs}%) opus={opus_msgs} ({100*opus_msgs//total_model_msgs}%)")
1770
+ print()
1771
+ print("Command-type duration buckets (median active time per command):")
1772
+ for cmd, dlist in sorted(command_buckets.items()):
1773
+ med = _median(dlist)/1000
1774
+ p25 = _p25(dlist)/1000
1775
+ p75 = _p75(dlist)/1000
1776
+ print(f" {cmd}: {med:.0f}s median [P25: {p25:.0f}s — P75: {p75:.0f}s] (n={len(dlist)})")
1777
+ ```
1778
+
1779
+ To run this step:
1780
+
1781
+ ```bash
1782
+ if [ "${TRANSCRIPT_AGGREGATION_AVAILABLE:-false}" = "true" ]; then
1783
+ AGGREGATED_TRANSCRIPT_METRICS=$(python3 - << 'PYEOF'
1784
+ # [paste the Python script above here at runtime]
1785
+ PYEOF
1786
+ )
1787
+ echo "$AGGREGATED_TRANSCRIPT_METRICS"
1788
+ export AGGREGATED_TRANSCRIPT_METRICS
1789
+
1790
+ # Extract OPUS_PCT from Phase 2Q output for use in Phase 3C health score
1791
+ # "Model distribution: sonnet=N (X%) opus=M (Y%)"
1792
+ OPUS_PCT=$(echo "$AGGREGATED_TRANSCRIPT_METRICS" | grep -oP 'opus=\d+ \(\K\d+(?=%\))')
1793
+ OPUS_PCT=${OPUS_PCT:-0}
1794
+ export OPUS_PCT
1795
+ echo "Phase 2Q exports: OPUS_PCT=$OPUS_PCT"
1796
+ fi
1797
+ ```
1798
+
1799
+ **Output variable**: `AGGREGATED_TRANSCRIPT_METRICS` — plain text block (human-readable). Consumed by Phase 5A to populate the \"Aggregated Session Metrics\" section of the health report. Export with `export AGGREGATED_TRANSCRIPT_METRICS`.
1800
+
1801
+ **Guardrail footnotes** — embed these verbatim in the Phase 5A report section (select ≥3; all 5 apply when transcript data is available):
1802
+
1803
+ > ⚠️ **Harmful-takeaway guard [1/5]**: Session duration shown here is *agent compute time* (active tool-call time), not wall-clock. Wall-clock varies 2–10x due to human idle. \"Sessions are long\" does not mean prompts are bad — it means the agent was computing for a long time. Compare against the task-type median, not a global average.
1804
+
1805
+ > ⚠️ **Harmful-takeaway guard [2/5]**: Tool call count is normalized per task type. A 15-file feature legitimately requires 200+ tool calls; a 1-file bug fix should need 30–50. \"High tool count\" without task-type context is a false signal. Flag only if count per files-changed is ≥3× the command-type median.
1806
+
1807
+ > ⚠️ **Harmful-takeaway guard [3/5]**: Skill invocation count reflects *which pipeline commands ran*, not whether they ran correctly. A session that calls \`work-on\` 6 times may be a routing loop (bad) or a 6-phase investigation (expected). Do not treat high skill counts as pipeline bloat without checking the workflow:* label progression.
1808
+
1809
+ > ⚠️ **Harmful-takeaway guard [4/5]**: Model distribution (sonnet/opus split) is a rate-limit pressure proxy, not a quality signal. High opus% means rate limits forced fallback — it does not mean the session used better models by choice. Remedy: spread pipeline sessions across time or reduce prompt token usage.
1810
+
1811
+ > ⚠️ **Harmful-takeaway guard [5/5]**: Compaction events signal large context windows, not inefficiency. A review session scanning a 20-file diff legitimately compacts once. Flag only if compaction_count > 2 per session (median) and correlate with review-finding quality degradation — not with compaction count alone.
1812
+
1813
+ **Threshold/target values**: Use '—' on first run (no prior baseline), matching the existing Phase 1C pattern. On subsequent runs, compare median duration and compaction rate against prior report's values.
1814
+
1815
+ **Skip condition**: If `TRANSCRIPT_DATA_AVAILABLE` is false or `PER_SESSION_METRICS_RAW` is empty, emit:
1816
+ > `Phase 2Q: Aggregated transcript metrics unavailable — Phase 2O did not produce session data. Run /pipeline-health on the machine where Claude Code sessions are stored.`
1817
+
1818
+ ---
1819
+
1820
+ ## Phase 3: Analyze & Correlate
1821
+
1822
+ ### 3A: Defect category breakdown
1823
+
1824
+ **3A-I: Pre-merge defects** — Group review findings (from Phase 2B) by agent prefix.
1825
+
1826
+ First, count findings per category from the Phase 2B output:
1827
+
1828
+ ```bash
1829
+ # Count review findings per agent prefix (SEC, AUTH, BILL, DB, FE, INFRA, API, CONC, SCRP)
1830
+ # These are the current-period counts to compare against PRIOR_* vars from Phase 1C
1831
+ COUNT_SEC=$(echo "$FINDINGS_RAW" | grep -c '"SEC' || echo 0)
1832
+ COUNT_AUTH=$(echo "$FINDINGS_RAW" | grep -c '"AUTH' || echo 0)
1833
+ COUNT_BILL=$(echo "$FINDINGS_RAW" | grep -c '"BILL' || echo 0)
1834
+ COUNT_DB=$(echo "$FINDINGS_RAW" | grep -c '"DB' || echo 0)
1835
+ COUNT_FE=$(echo "$FINDINGS_RAW" | grep -c '"FE' || echo 0)
1836
+ COUNT_INFRA=$(echo "$FINDINGS_RAW" | grep -c '"INFRA' || echo 0)
1837
+ COUNT_API=$(echo "$FINDINGS_RAW" | grep -c '"API' || echo 0)
1838
+ COUNT_CONC=$(echo "$FINDINGS_RAW" | grep -c '"CONC' || echo 0)
1839
+ COUNT_SCRP=$(echo "$FINDINGS_RAW" | grep -c '"SCRP' || echo 0)
1840
+ # ISSUE_SPEC from Phase 2L audit findings
1841
+ COUNT_ISSUE_SPEC=$ISSUE_SPEC_COUNT
1842
+
1843
+ TOTAL_FINDINGS=$((COUNT_SEC + COUNT_AUTH + COUNT_BILL + COUNT_DB + COUNT_FE + COUNT_INFRA + COUNT_API + COUNT_CONC + COUNT_SCRP))
1844
+
1845
+ # Compute trend for each category using _trend helper from Phase 1C
1846
+ TREND_SEC=$( _trend "$COUNT_SEC" "$PRIOR_SEC")
1847
+ TREND_AUTH=$( _trend "$COUNT_AUTH" "$PRIOR_AUTH")
1848
+ TREND_BILL=$( _trend "$COUNT_BILL" "$PRIOR_BILL")
1849
+ TREND_DB=$( _trend "$COUNT_DB" "$PRIOR_DB")
1850
+ TREND_FE=$( _trend "$COUNT_FE" "$PRIOR_FE")
1851
+ TREND_INFRA=$( _trend "$COUNT_INFRA" "$PRIOR_INFRA")
1852
+ TREND_API=$( _trend "$COUNT_API" "$PRIOR_API")
1853
+ TREND_CONC=$( _trend "$COUNT_CONC" "$PRIOR_CONC")
1854
+ TREND_SCRP=$( _trend "$COUNT_SCRP" "$PRIOR_SCRP")
1855
+ TREND_ISSUE_SPEC=$(_trend "$COUNT_ISSUE_SPEC" "$PRIOR_ISSUE_SPEC")
1856
+
1857
+ _pct() { [ "$TOTAL_FINDINGS" -gt 0 ] && echo "scale=0; $1 * 100 / $TOTAL_FINDINGS" | bc || echo "0"; }
1858
+ ```
1859
+
1860
+ Then render the table using the computed values:
1861
+
1862
+ | Category | Count | % of Total | Trend vs Prior Period |
1863
+ |----------|-------|------------|---------------------|
1864
+ | SEC | $COUNT_SEC | $(_pct $COUNT_SEC)% | $TREND_SEC |
1865
+ | AUTH | $COUNT_AUTH | $(_pct $COUNT_AUTH)% | $TREND_AUTH |
1866
+ | BILL | $COUNT_BILL | $(_pct $COUNT_BILL)% | $TREND_BILL |
1867
+ | DB | $COUNT_DB | $(_pct $COUNT_DB)% | $TREND_DB |
1868
+ | FE | $COUNT_FE | $(_pct $COUNT_FE)% | $TREND_FE |
1869
+ | INFRA | $COUNT_INFRA | $(_pct $COUNT_INFRA)% | $TREND_INFRA |
1870
+ | API | $COUNT_API | $(_pct $COUNT_API)% | $TREND_API |
1871
+ | CONC | $COUNT_CONC | $(_pct $COUNT_CONC)% | $TREND_CONC |
1872
+ | SCRP | $COUNT_SCRP | $(_pct $COUNT_SCRP)% | $TREND_SCRP |
1873
+ | ISSUE_SPEC | $COUNT_ISSUE_SPEC | $(_pct $COUNT_ISSUE_SPEC)% | $TREND_ISSUE_SPEC |
1874
+
1875
+ **If PRIOR_* vars are all empty (baseline run)**: all trend cells show "—" (no prior period to compare against).
1876
+
1877
+ **Note**: ISSUE_SPEC findings come from `/audit` (`audit-finding` label + body contains "ISSUE_SPEC"), not from review agents. They represent upstream failures — bad issue specs that the pipeline followed correctly but that produced wrong or wasted work.
1878
+
1879
+ **3A-II: Post-deploy defects** — Group audit findings (from Phase 2L) by failure point:
1880
+
1881
+ | Failure Point | Count | % of Total | Meaning |
1882
+ |---------------|-------|------------|---------|
1883
+ | INVESTIGATION | ? | ?% | Wrong verdict or missed scope — root cause misdiagnosed |
1884
+ | IMPLEMENTATION | ? | ?% | Code written incorrectly or incompletely |
1885
+ | REVIEW | ? | ?% | Review agents missed the issue or wrong agents triggered |
1886
+ | REVIEW_FALSE_NEG | ? | ?% | Review agent saw area but approved a defect (escape signal) |
1887
+ | QUALITY_GATE | ? | ?% | Static check should have caught this pattern but didn't |
1888
+ | DEPLOY_GATE | ? | ?% | Deploy-info should have flagged risk but didn't |
1889
+ | CONTEXT | ? | ?% | Missed relevant prior work or related code paths |
1890
+ | ARCHITECT | ? | ?% | Missed code paths or wrong implementation plan |
1891
+ | ORCHESTRATION | ? | ?% | Wave ordering wrong or dependency missed |
1892
+ | ISSUE_SPEC | ? | ?% | Bad issue spec — pipeline followed it correctly, spec was wrong |
1893
+ | OTHER | ? | ?% | Uncategorized or compound failure |
1894
+
1895
+ **Cross-signal analysis**: Compare 3A-I (pre-merge review findings) with 3A-II (post-deploy audit findings) to identify where the pipeline gates are misaligned. High REVIEW_FALSE_NEG in 3A-II with low REVIEW findings per PR in 3A-I suggests review agents are approving too liberally rather than finding real defects.
1896
+
1897
+ **Identify the top 3 defect categories across both tables.** These are where the builder/review is weakest and where pipeline improvements would have the highest ROI.
1898
+
1899
+ ### 3B: Prompt change impact
1900
+
1901
+ For each Forge commit in the window:
1902
+ 1. What command was changed?
1903
+ 2. What was the intent? (read the commit message)
1904
+ 3. Did the target metric improve after this change?
1905
+ - If the change was to `review-pr-agents.md` → did false positive rate change?
1906
+ - If the change was to `work-on.md` Step 3G → did build failure rate change?
1907
+ - If the change was to `orchestrate.md` → did milestone merge success rate change?
1908
+
1909
+ **Caution**: Correlation ≠ causation. Note confounding factors (e.g., milestone size, new developers).
1910
+
1911
+ ### 3C: Compute health score
1912
+
1913
+ ```
1914
+ Health Score = weighted average of:
1915
+ Pre-merge quality:
1916
+ - Build pass rate (staging→main): weight 12% target 95%+
1917
+ - Review findings per PR: weight 9% target < 0.5
1918
+ - Manual fix-up rate: weight 8% target < 15%
1919
+ - False positive rate: weight 6% target < 10%
1920
+ - Pipeline self-correction rate: weight 7% target < 15%
1921
+
1922
+ Post-deploy quality:
1923
+ - Post-deploy failure rate (Phase 2L): weight 9% target < 5%
1924
+ - Review escape rate (Phase 2L): weight 9% target < 2%
1925
+
1926
+ Pipeline health signals:
1927
+ - Issue quality score (Phase 2J): weight 8% target > 80%
1928
+ - Investigation accuracy (Phase 2I): weight 6% target > 85%
1929
+ - Milestone merge success rate: weight 6% target 80%+
1930
+ - Orchestration efficiency (Phase 2K): weight 5% target score > 70
1931
+ - Original work ratio: weight 4% target > 70%
1932
+ - Issue close velocity (days): weight 1% target < 2 days
1933
+
1934
+ Transcript analytics (supplementary — Phase 2M–2Q):
1935
+ - Transcript health score (Phase 2N/2Q): weight 10% target > 70
1936
+ ```
1937
+
1938
+ **Total weight**: 12+9+8+6+7+9+9+8+6+6+5+4+1+10 = 100%.
1939
+
1940
+ **Post-deploy failure rate** (Phase 2L): scored as `max(0, 100 - (rate / 5%) * 100)` — i.e., 0% failure rate = 100 score, 5% failure rate = 0 score. If no audit findings exist in the window, score = 100.
1941
+
1942
+ **Review escape rate** (Phase 2L): scored as `max(0, 100 - (rate / 2%) * 100)` — i.e., 0% escape rate = 100 score, 2% escape rate = 0 score. If no REVIEW_FALSE_NEG findings exist, score = 100. This metric directly measures review agent effectiveness: a high escape rate with a low findings-per-PR rate indicates review agents are approving too liberally.
1943
+
1944
+ **Issue quality score** is computed from Phase 2J sub-metrics: invalidation rate (40%), ISSUE_SPEC findings (30%), re-investigation rate (20%), structure compliance (10%). A perfect score (100) requires: 0% invalidation, 0 ISSUE_SPEC audit findings, 0 re-investigations, 100% structure compliance.
1945
+
1946
+ **Investigation accuracy** comes from Phase 2I trajectory data: (CONFIRMED + PARTIAL) / total issues with trajectory comments. If no trajectory data is available in the window, omit this metric from the weighted average and redistribute its 7% proportionally across the other metrics.
1947
+
1948
+ **Orchestration efficiency score** is computed from Phase 2K metrics: avg idle% (40% weight — lower is better, 0% idle = 100 score), avg resumes per agent (35% weight — 0 resumes = 100, ≥2 resumes = 0), clean agent rate (25% weight — equals clean_rate%). If no persisted `FORGE:AUDIT-AGENTS` data is available in the window, omit this metric and redistribute its 5% proportionally across the remaining metrics.
1949
+
1950
+ **Transcript health score** (Phase 2N/2Q — supplementary signal, weight 10%): Computed from three Phase 2N/2Q output variables. If `TRANSCRIPT_DATA_AVAILABLE=false`, skip this metric and redistribute its 10% proportionally across the remaining metrics.
1951
+
1952
+ ```bash
1953
+ if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" = "true" ]; then
1954
+ # stall_rate_score: 0% stall rate = 100, 10%+ stall rate = 0
1955
+ # STALL_TURN_RATE is a percentage integer from Phase 2N (e.g., 5 for 5%)
1956
+ STALL_TURN_RATE=${STALL_TURN_RATE:-0}
1957
+ STALL_RATE_SCORE=$(echo "scale=0; r=$STALL_TURN_RATE; if(r>10) 0 else (10-r)*10" | bc 2>/dev/null || echo 0)
1958
+
1959
+ # compaction_score: 0 compactions = 100, 5+ compactions = 0
1960
+ # COMPACTION_TOTAL is the total compaction events across all sessions from Phase 2N
1961
+ COMPACTION_TOTAL=${COMPACTION_TOTAL:-0}
1962
+ COMPACTION_SCORE=$(echo "scale=0; c=$COMPACTION_TOTAL; if(c>5) 0 else (5-c)*20" | bc 2>/dev/null || echo 0)
1963
+
1964
+ # model_split_score: 0% opus = 100, 100% opus = 0
1965
+ # OPUS_PCT is derived from Phase 2Q's model_distribution output (integer 0-100)
1966
+ # If not set (Phase 2Q skipped), use 0 as conservative fallback
1967
+ OPUS_PCT=${OPUS_PCT:-0}
1968
+ MODEL_SPLIT_SCORE=$(echo "scale=0; 100 - $OPUS_PCT" | bc 2>/dev/null || echo 100)
1969
+ [ "$MODEL_SPLIT_SCORE" -lt 0 ] 2>/dev/null && MODEL_SPLIT_SCORE=0
1970
+
1971
+ # Weighted composite: stall 50% + compaction 30% + model split 20%
1972
+ TRANSCRIPT_HEALTH_SCORE=$(echo "scale=0; ($STALL_RATE_SCORE * 50 + $COMPACTION_SCORE * 30 + $MODEL_SPLIT_SCORE * 20) / 100" | bc 2>/dev/null || echo 0)
1973
+ echo "Transcript health score: $TRANSCRIPT_HEALTH_SCORE/100 (stall_rate_score=$STALL_RATE_SCORE compaction_score=$COMPACTION_SCORE model_split_score=$MODEL_SPLIT_SCORE)"
1974
+ TRANSCRIPT_SCORE_AVAILABLE=true
1975
+ else
1976
+ TRANSCRIPT_HEALTH_SCORE=""
1977
+ TRANSCRIPT_SCORE_AVAILABLE=false
1978
+ echo "Transcript health score: N/A (TRANSCRIPT_DATA_AVAILABLE=false — 10% weight redistributed proportionally)"
1979
+ fi
1980
+ ```
1981
+
1982
+ **Transcript flow qualitative flags** (supplementary, appear in report alongside the numeric score):
1983
+ - Stall turn rate > 10%: flag as ⚠️ `High stall rate — agents spending >10% of turns in long waits`
1984
+ - Compaction events > 5 in window: flag as ⚠️ `Frequent context compaction — consider reducing prompt or tool output size`
1985
+ - Stall turn rate ≤ 10% and compaction ≤ 5: flag as ✅ `Session flow healthy`
1986
+ - Data not available: flag as ℹ️ `Transcript flow metrics unavailable (requires Phase 2M session data)`
1987
+
1988
+ **Aggregated session metrics** (Phase 2Q): If `$TRANSCRIPT_AGGREGATION_AVAILABLE` is true, embed `$AGGREGATED_TRANSCRIPT_METRICS` verbatim into the \"Aggregated Session Metrics\" section of the Phase 5A report. The guardrail footnotes (5 harmful-takeaway warnings from #347) MUST be included in that section even when session count is low — they protect against misreading the metrics table. If `TRANSCRIPT_AGGREGATION_AVAILABLE` is false, emit the unavailability note from Phase 2Q.
1989
+
1990
+ **Anomaly signal**: If Phase 2I anomaly rate exceeds 10% of issues, flag it as a warning in the health report even though it does not feed the score directly — high anomaly rates indicate pipeline instability not captured by outcome metrics.
1991
+
1992
+ Score 0-100. Categorize:
1993
+ - 90-100: Excellent — pipeline is shipping clean code
1994
+ - 70-89: Good — some defect categories need attention
1995
+ - 50-69: Needs work — specific pipeline links are underperforming
1996
+ - < 50: Critical — systemic issues in the pipeline
1997
+
1998
+ **Health score trend**: Compare the computed score against `$PRIOR_HEALTH_SCORE` from Phase 1C:
1999
+
2000
+ ```bash
2001
+ # Compute score trend string for report header
2002
+ if [ -z "$PRIOR_HEALTH_SCORE" ]; then
2003
+ SCORE_TREND="(baseline — no prior period)"
2004
+ elif [ "$HEALTH_SCORE" -gt "$PRIOR_HEALTH_SCORE" ] 2>/dev/null; then
2005
+ SCORE_TREND="(↑ from $PRIOR_HEALTH_SCORE — +$(( HEALTH_SCORE - PRIOR_HEALTH_SCORE )) pts)"
2006
+ elif [ "$HEALTH_SCORE" -lt "$PRIOR_HEALTH_SCORE" ] 2>/dev/null; then
2007
+ SCORE_TREND="(↓ from $PRIOR_HEALTH_SCORE — -$(( PRIOR_HEALTH_SCORE - HEALTH_SCORE )) pts)"
2008
+ else
2009
+ SCORE_TREND="(= unchanged from $PRIOR_HEALTH_SCORE)"
2010
+ fi
2011
+ echo "Health Score: $HEALTH_SCORE/100 $SCORE_TREND"
2012
+ ```
2013
+
2014
+ ---
2015
+
2016
+ ## Phase 4: Generate Improvement Proposals
2017
+
2018
+ For each of the top 3 defect categories:
2019
+
2020
+ 1. **Diagnose**: Why is the builder producing these defects? Read the relevant section of `work-on.md` — is there a check for this category? Is it too vague? Missing entirely?
2021
+
2022
+ 2. **Prescribe**: What specific change to which command file would reduce these defects?
2023
+ - If the builder lacks a check → propose adding one to `work-on.md` or `quality-gate.md`
2024
+ - If the review agent misses things → propose tightening the agent template in `review-pr-agents.md`
2025
+ - If the orchestrator doesn't catch integration failures → propose a new inter-wave check
2026
+
2027
+ 3. **Scope**: Estimate the change size (lines added/modified) and risk level.
2028
+
2029
+ ---
2030
+
2031
+ ## Phase 5: Report & Track
2032
+
2033
+ ### 5A: Post health report
2034
+
2035
+ Create a detailed health report as a GitHub issue in the Forge repo:
2036
+
2037
+ ```bash
2038
+ gh issue create -R RapierCraftStudios/forge \
2039
+ --title "Pipeline Health: $REPO — $(date +%Y-%m-%d) — Score: [SCORE]/100" \
2040
+ --label "health-report" \
2041
+ --body "$(cat <<'EOF'
2042
+ ## Pipeline Health Report
2043
+
2044
+ **Project**: $REPO
2045
+ **Period**: $SINCE → $(date +%Y-%m-%dT%H:%M:%SZ)
2046
+ **Health Score**: [SCORE]/100 ([CATEGORY]) $SCORE_TREND
2047
+
2048
+ ## Metrics
2049
+
2050
+ ### Pre-Merge Quality
2051
+
2052
+ | Metric | Value | Target | Status |
2053
+ |--------|-------|--------|--------|
2054
+ | Build pass rate (staging→main) | ?% | 95%+ | ✅/⚠️/❌ |
2055
+ | Review findings per PR | ? | < 0.5 | ✅/⚠️/❌ |
2056
+ | Manual fix-up rate | ?% | < 15% | ✅/⚠️/❌ |
2057
+ | False positive rate | ?% | < 10% | ✅/⚠️/❌ |
2058
+ | Pipeline self-correction rate | ?% | < 15% | ✅/⚠️/❌ |
2059
+
2060
+ ### Post-Deploy Quality
2061
+
2062
+ | Metric | Value | Target | Status |
2063
+ |--------|-------|--------|--------|
2064
+ | Post-deploy failure rate | ?% | < 5% | ✅/⚠️/❌ |
2065
+ | Review escape rate (REVIEW_FALSE_NEG) | ?% | < 2% | ✅/⚠️/❌ |
2066
+ | Mean time to detect | ? days | < 7 days | ✅/⚠️/❌ |
2067
+ | Audit findings (total in window) | ? | — | ℹ️ |
2068
+ | — by failure point | see breakdown | — | ℹ️ |
2069
+
2070
+ ### Pipeline Health Signals
2071
+
2072
+ | Metric | Value | Target | Status |
2073
+ |--------|-------|--------|--------|
2074
+ | Issue quality score | ?/100 | > 80 | ✅/⚠️/❌ |
2075
+ | — Investigation invalidation rate | ?% | < 15% | ✅/⚠️/❌ |
2076
+ | — ISSUE_SPEC audit findings | ? | 0 | ✅/⚠️/❌ |
2077
+ | — Re-investigation rate | ? | 0 | ✅/⚠️/❌ |
2078
+ | — Issue structure compliance | ?% | > 80% | ✅/⚠️/❌ |
2079
+ | Investigation accuracy (trajectory) | ?% | > 85% | ✅/⚠️/❌ |
2080
+ | Orchestration efficiency score | ?/100 | > 70 | ✅/⚠️/❌ |
2081
+ | — Avg agent idle% | ?% | < 30% | ✅/⚠️/❌ |
2082
+ | — Avg resume cycles per agent | ? | < 1 | ✅/⚠️/❌ |
2083
+ | — Clean agent rate | ?% | > 60% | ✅/⚠️/❌ |
2084
+ | Original work ratio | ?% | > 70% | ✅/⚠️/❌ |
2085
+ | Milestone merge success | ?% | 80%+ | ✅/⚠️/❌ |
2086
+ | Issue close velocity | ? days | < 2 days | ✅/⚠️/❌ |
2087
+ | Transcript health score (Phase 2N/2Q) | ?/100 $(_trend $TRANSCRIPT_HEALTH_SCORE $PRIOR_TRANSCRIPT_SCORE) | > 70 | ✅/⚠️/❌/ℹ️ |
2088
+ | — Session flow flag | [✅ Healthy / ⚠️ flag / ℹ️ unavailable] | — | ℹ️ |
2089
+ | — Aggregated session metrics (Phase 2Q) | [✅ Available / ℹ️ unavailable] | — | ℹ️ |
2090
+
2091
+ ## Trajectory Analytics
2092
+
2093
+ _From Phase 2I — FORGE:TRAJECTORY comments on [N] closed issues in window_
2094
+
2095
+ | Metric | Value |
2096
+ |--------|-------|
2097
+ | Issues with trajectory data | ? / ? closed |
2098
+ | Investigation accuracy | ?% (CONFIRMED: ?, PARTIAL: ?, INVALID: ?) |
2099
+ | Task type distribution | Bug Fix: ?, Feature: ?, Refactor: ?, Maintenance: ? |
2100
+ | Anomalies flagged | ? issues |
2101
+ | Notable anomalies | [list top 3 or "None"] |
2102
+
2103
+ _If no trajectory data available in window: "No FORGE:TRAJECTORY comments found — pipeline may be running pre-trajectory version or window is too narrow."_
2104
+
2105
+ ## Orchestration Efficiency
2106
+
2107
+ _From Phase 2K — persisted FORGE:AUDIT-AGENTS summaries ([N] sessions, [M] agents total)_
2108
+
2109
+ | Metric | Value | Target | Status |
2110
+ |--------|-------|--------|--------|
2111
+ | Avg agent idle% | ?% | < 30% | ✅/⚠️/❌ |
2112
+ | Avg resume cycles per agent | ? | < 1 | ✅/⚠️/❌ |
2113
+ | Clean agent rate | ?% | > 60% | ✅/⚠️/❌ |
2114
+
2115
+ **Top stall boundaries** (phase transitions causing the most stalls):
2116
+ | Boundary | Occurrences |
2117
+ |----------|-------------|
2118
+ | [boundary_1] | [N] |
2119
+ | [boundary_2] | [N] |
2120
+ | [boundary_3] | [N] |
2121
+
2122
+ _If no FORGE:AUDIT-AGENTS data available: "No persisted audit-agents summaries found in window. Run \`audit-agents --persist\` after orchestration runs to enable this metric. Proxy: [N] issues had resume/stall anomalies in trajectory data."_
2123
+
2124
+ ## Transcript Flow Metrics
2125
+
2126
+ _From Phase 2N — JSONL transcript analytics ([N] pipeline sessions, [M] subagent sessions in window)_
2127
+
2128
+ | Metric | Value | Signal |
2129
+ |--------|-------|--------|
2130
+ | Pipeline sessions parsed | ? parent, ? subagent | ℹ️ |
2131
+ | Total turns with timing | ? | ℹ️ |
2132
+ | Avg turn duration | ?s | ℹ️ |
2133
+ | Max turn duration | ?s | ℹ️ |
2134
+ | Stall turns (>120s) | ? (?%) | ✅/⚠️ |
2135
+ | Rate-limit indicator turns (60–120s) | ? | ℹ️ |
2136
+ | Context compaction events | ? | ✅/⚠️ |
2137
+ | Session flow flag | [✅ Healthy / ⚠️ High stall rate / ⚠️ Frequent compaction] | — |
2138
+
2139
+ **Top tools by call frequency**:
2140
+ | Tool | Calls |
2141
+ |------|-------|
2142
+ | [tool_1] | [N] |
2143
+ | [tool_2] | [N] |
2144
+ | [tool_3] | [N] |
2145
+
2146
+ _If transcript data unavailable: "Transcript flow metrics unavailable — Phase 2M found no pipeline sessions in window (SESSION_TOTAL=0). Run `/pipeline-health` on the same machine where Claude Code sessions are stored."_
2147
+
2148
+ ## Aggregated Session Metrics
2149
+
2150
+ _From Phase 2Q — privacy-safe aggregation of [N] pipeline sessions (session IDs excluded; structural data only)_
2151
+
2152
+ | Metric | Value (median) | Range [P25–P75] | Context |
2153
+ |--------|---------------|-----------------|---------|
2154
+ | Active compute time | ?s | [?s–?s] | Agent tool-call time only — excludes human idle. Target: — (baseline) |
2155
+ | Context compaction events | ? | [?–?] | Per session. >2 median = prompts/diffs may be oversized |
2156
+ | Sonnet / Opus split | ?% / ?% | — | Opus% = rate-limit fallback pressure |
2157
+
2158
+ **Tool call distribution** (top 5 tools, all sessions combined):
2159
+ | Tool | Total Calls |
2160
+ |------|-------------|
2161
+ | [tool_1] | [N] |
2162
+ | [tool_2] | [N] |
2163
+ | [tool_3] | [N] |
2164
+ | [tool_4] | [N] |
2165
+ | [tool_5] | [N] |
2166
+
2167
+ **Active time by command type** (median):
2168
+ | Command | Median duration | Range [P25–P75] | Sessions |
2169
+ |---------|-----------------|-----------------|----------|
2170
+ | [command_1] | ?s | [?s–?s] | ? |
2171
+ | [command_2] | ?s | [?s–?s] | ? |
2172
+
2173
+ **Interpretation guardrails** (sourced from investigation #347):
2174
+
2175
+ > ⚠️ **Guard 1**: Duration shown is *agent compute time*, not wall-clock. Wall-clock varies 2–10x due to human idle. "Sessions are long" ≠ prompts are bad. Compare against the command-type median, not a global average.
2176
+
2177
+ > ⚠️ **Guard 2**: Tool call count is normalized per task type. A 15-file feature legitimately requires 200+ calls; a 1-file fix needs 30–50. Flag only if count per files-changed is ≥3× the command-type median.
2178
+
2179
+ > ⚠️ **Guard 3**: Skill invocation count reflects which pipeline commands ran — not whether they ran correctly. High skill count without checking workflow:* label progression is a false signal.
2180
+
2181
+ > ⚠️ **Guard 4**: Opus% is rate-limit pressure proxy — high opus% means fallback occurred, not that better models were chosen. Remedy: spread sessions across time or reduce prompt token usage.
2182
+
2183
+ > ⚠️ **Guard 5**: Compaction events indicate large context windows, not inefficiency. Flag only if median compaction_count > 2 AND correlated with review-finding quality degradation.
2184
+
2185
+ _If aggregated metrics unavailable: "Phase 2Q did not produce data — TRANSCRIPT_DATA_AVAILABLE=false or PER_SESSION_METRICS_RAW was empty. Run `/pipeline-health` on the machine where Claude Code sessions are stored."_
2186
+
2187
+ ## Post-Deploy Failure Analysis
2188
+
2189
+ _From Phase 2L — \`audit-finding\` issues in the Forge repo ([N] findings in window)_
2190
+
2191
+ | Metric | Value | Target |
2192
+ |--------|-------|--------|
2193
+ | Post-deploy failure rate | ?% | < 5% |
2194
+ | Review escape rate | ?% | < 2% |
2195
+ | Mean time to detect | ? days | < 7 days |
2196
+
2197
+ **Failure point distribution** ([N] total audit findings):
2198
+ | Failure Point | Count | % | Meaning |
2199
+ |---------------|-------|---|---------|
2200
+ | REVIEW_FALSE_NEG | ? | ?% | Review agent saw area but approved defect |
2201
+ | IMPLEMENTATION | ? | ?% | Code written incorrectly |
2202
+ | INVESTIGATION | ? | ?% | Wrong verdict or missed scope |
2203
+ | DEPLOY_GATE | ? | ?% | Deploy-info missed risk signal |
2204
+ | [other] | ? | ?% | — |
2205
+
2206
+ _If no audit findings in window: "No \`audit-finding\` issues in window — post-deploy failure rate = 0%."_
2207
+
2208
+ ## Defect Category Breakdown
2209
+
2210
+ _Pre-merge review findings by agent prefix (from Phase 3A-I). Trends computed vs prior report #$PRIOR_NUMBER (or "—" if baseline run)._
2211
+
2212
+ | Category | Count | % of Total | Trend vs Prior Period |
2213
+ |----------|-------|------------|---------------------|
2214
+ | SEC | $COUNT_SEC | $(_pct $COUNT_SEC)% | $TREND_SEC |
2215
+ | AUTH | $COUNT_AUTH | $(_pct $COUNT_AUTH)% | $TREND_AUTH |
2216
+ | BILL | $COUNT_BILL | $(_pct $COUNT_BILL)% | $TREND_BILL |
2217
+ | DB | $COUNT_DB | $(_pct $COUNT_DB)% | $TREND_DB |
2218
+ | FE | $COUNT_FE | $(_pct $COUNT_FE)% | $TREND_FE |
2219
+ | INFRA | $COUNT_INFRA | $(_pct $COUNT_INFRA)% | $TREND_INFRA |
2220
+ | API | $COUNT_API | $(_pct $COUNT_API)% | $TREND_API |
2221
+ | CONC | $COUNT_CONC | $(_pct $COUNT_CONC)% | $TREND_CONC |
2222
+ | SCRP | $COUNT_SCRP | $(_pct $COUNT_SCRP)% | $TREND_SCRP |
2223
+ | ISSUE_SPEC | $COUNT_ISSUE_SPEC | $(_pct $COUNT_ISSUE_SPEC)% | $TREND_ISSUE_SPEC |
2224
+
2225
+ _Post-deploy audit findings by failure point — see Phase 3A-II table above._
2226
+
2227
+ ## Prompt Change Impact
2228
+
2229
+ [Analysis from Phase 3B]
2230
+
2231
+ ## Top 3 Improvement Proposals
2232
+
2233
+ ### 1. [Category] — [Proposal title]
2234
+ **File**: `commands/[file].md`
2235
+ **Change**: [Description]
2236
+ **Expected impact**: [Metric] should improve by ~[X]%
2237
+ **Risk**: [Low/Medium/High]
2238
+
2239
+ ### 2. ...
2240
+
2241
+ ### 3. ...
2242
+
2243
+ ## Forge Commits in Window
2244
+
2245
+ [Git log from Phase 2A]
2246
+ EOF
2247
+ )"
2248
+ ```
2249
+
2250
+ ### 5B: Create improvement issues in Forge
2251
+
2252
+ For each improvement proposal, create a trackable issue using the `/issue` structure.
2253
+
2254
+ **Before creating**: Read the target file (`commands/[file].md`) and verify that the section reference in the issue body is accurate — confirm the section heading and line range exist.
2255
+
2256
+ **Title format**: Use conventional commit prefix based on proposal type:
2257
+ - Proposed fix for a broken behaviour → `fix([command]): [description]`
2258
+ - New capability or enhancement → `feat([command]): [description]`
2259
+ - Restructuring without behaviour change → `refactor([command]): [description]`
2260
+
2261
+ **Priority label**: Assign based on how far the metric deviates from its target:
2262
+ - Metric ≥ 20% below target → `P1`
2263
+ - Metric 10–20% below target → `P2`
2264
+ - Metric < 10% below target → `P3`
2265
+
2266
+ ```bash
2267
+ gh issue create -R RapierCraftStudios/forge \
2268
+ --title "fix([command]): [description]" \
2269
+ --label "[bug|enhancement|feature],[P1|P2|P3]" \
2270
+ --body "## Problem
2271
+
2272
+ [1–3 sentences describing what the pipeline health metric reveals. Include the current value and target value.]
2273
+
2274
+ **Source**: Health report #[REPORT_NUMBER] — [metric]: [current value] (target: [target value])
2275
+
2276
+ ## Root Cause (if known)
2277
+
2278
+ \`commands/[file].md\` [section] — [what the section currently does that causes the metric to suffer]
2279
+
2280
+ ## Affected Files
2281
+
2282
+ Files that need changes:
2283
+
2284
+ 1. \`commands/[file].md\` — [what needs to change in this file]
2285
+
2286
+ ## Expected Behavior
2287
+
2288
+ After the fix, [metric] should reach [target value]. [Describe the concrete behaviour change in the command.]
2289
+
2290
+ ## Acceptance Criteria
2291
+
2292
+ - [ ] [Specific, testable criterion tied to the metric]
2293
+ - [ ] [Specific, testable criterion tied to the command behaviour]
2294
+ - [ ] No regression in other pipeline-health metrics
2295
+ "
2296
+ ```
2297
+
2298
+ ---
2299
+
2300
+ ## Phase 6: Summary
2301
+
2302
+ Print to the user:
2303
+
2304
+ ```
2305
+ Forge Pipeline Health — [REPO]
2306
+ Score: [SCORE]/100 ([CATEGORY]) $SCORE_TREND
2307
+
2308
+ Pre-merge quality:
2309
+ Build pass rate: [X]% (target 95%)
2310
+ Findings per PR: [X] (target < 0.5)
2311
+ False positive rate: [X]% (target < 10%)
2312
+ Manual fix-up rate: [X]% (target < 15%)
2313
+ Self-correction rate: [X]% (target < 15%)
2314
+
2315
+ Post-deploy quality:
2316
+ Post-deploy failure rate: [X]% (target < 5%) — [N] audit findings
2317
+ Review escape rate: [X]% (target < 2%) — [N] REVIEW_FALSE_NEG
2318
+ Mean time to detect: [X] days (target < 7 days)
2319
+ Top failure points: [FP1] ([N]), [FP2] ([N]), [FP3] ([N])
2320
+
2321
+ Pipeline health signals:
2322
+ Issue quality score: [X]/100 (target > 80)
2323
+ Invalidation rate: [X]% (target < 15%)
2324
+ ISSUE_SPEC findings: [X] (target 0)
2325
+ Re-investigations: [X] (target 0)
2326
+ Structure compliance: [X]% (target > 80%)
2327
+ Investigation accuracy: [X]% (target > 85%) — [N] issues with trajectory data
2328
+ CONFIRMED: [N] | PARTIAL: [N] | INVALID: [N]
2329
+ Anomalies flagged: [N] issues
2330
+ Task type distribution: Bug Fix: [N], Feature: [N], Refactor: [N], Maintenance: [N]
2331
+ Orchestration efficiency: [X]/100 (target > 70) — [N] sessions, [M] agents
2332
+ Avg idle%: [X]% (target < 30%)
2333
+ Avg resume cycles: [X] (target < 1)
2334
+ Clean agent rate: [X]% (target > 60%)
2335
+ Top stall boundary: [boundary] ([N] occurrences)
2336
+ Original work ratio: [X]% (target > 70%)
2337
+ Milestone merge rate: [X]% (target 80%)
2338
+
2339
+ Top defect categories: [CAT1] ([N]), [CAT2] ([N]), [CAT3] ([N])
2340
+
2341
+ Proposed improvements: [N] issues created in RapierCraftStudios/forge
2342
+ Health report: [ISSUE_URL]
2343
+ ```