forgedock 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -8
- package/bin/batch-runner.mjs +339 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +20 -0
- package/bin/engine/state.mjs +8 -3
- package/bin/engine-cli.mjs +177 -5
- package/bin/forgedock.mjs +409 -26
- package/bin/hooks/interactive-engine.mjs +509 -0
- package/bin/hooks/pre-tool-use.mjs +498 -0
- package/bin/hooks/subagent-stop-enforce.mjs +197 -0
- package/bin/journey.mjs +72 -14
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +68 -19
- package/bin/settings-hook.mjs +235 -1
- package/commands/adopt.md +4 -1
- package/commands/analytics.md +4 -1
- package/commands/audit-agents.md +110 -27
- package/commands/audit.md +20 -1
- package/commands/autopilot.md +80 -8
- package/commands/changelog.md +2 -1
- package/commands/ci-audit.md +2 -1
- package/commands/cleanup.md +161 -1
- package/commands/compat-audit.md +2 -1
- package/commands/deploy-info.md +58 -1
- package/commands/design-bench.md +5 -1
- package/commands/design-render-critique-loop.md +1 -1
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/forge-stats.md +121 -2
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +3 -0
- package/commands/incident-response.md +2 -1
- package/commands/issue.md +23 -2
- package/commands/milestone.md +77 -12
- package/commands/optimize.md +2 -1
- package/commands/orchestrate/config.md +67 -0
- package/commands/orchestrate/phase-1-resolve.md +159 -0
- package/commands/orchestrate/phase-2-triage.md +126 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
- package/commands/orchestrate/phase-3-dependency.md +591 -0
- package/commands/orchestrate/phase-4-execution.md +962 -0
- package/commands/orchestrate/phase-5-cleanup.md +47 -0
- package/commands/orchestrate/phase-6-report.md +198 -0
- package/commands/orchestrate/safety.md +62 -0
- package/commands/orchestrate.md +25 -2045
- package/commands/pipeline-health/config.md +34 -0
- package/commands/pipeline-health/phase-1-context.md +180 -0
- package/commands/pipeline-health/phase-2-metrics.md +1729 -0
- package/commands/pipeline-health/phase-3-analyze.md +204 -0
- package/commands/pipeline-health/phase-4-proposals.md +117 -0
- package/commands/pipeline-health/phase-5-report.md +284 -0
- package/commands/pipeline-health/phase-6-summary.md +52 -0
- package/commands/pipeline-health.md +25 -2401
- package/commands/pipeline-resume.md +24 -1
- package/commands/pipeline-status.md +65 -0
- package/commands/qa-sweep.md +4 -1
- package/commands/quality-gate.md +248 -10
- package/commands/replay.md +2 -1
- package/commands/review-pr-agents/api.md +135 -0
- package/commands/review-pr-agents/auth.md +137 -0
- package/commands/review-pr-agents/billing.md +101 -0
- package/commands/review-pr-agents/concurrency.md +145 -0
- package/commands/review-pr-agents/database.md +103 -0
- package/commands/review-pr-agents/frontend.md +89 -0
- package/commands/review-pr-agents/infra.md +385 -0
- package/commands/review-pr-agents/protocols.md +185 -0
- package/commands/review-pr-agents/scraper.md +185 -0
- package/commands/review-pr-agents/security.md +406 -0
- package/commands/review-pr-agents.md +45 -1722
- package/commands/review-pr-staging.md +196 -40
- package/commands/review-pr.md +591 -52
- package/commands/rollback.md +2 -1
- package/commands/scope.md +2 -1
- package/commands/security-audit.md +2 -1
- package/commands/signal-planner.md +623 -0
- package/commands/sync-ecosystem.md +1 -0
- package/commands/test-gate.md +17 -1
- package/commands/validate.md +2 -1
- package/commands/work-on/build/architect.md +28 -0
- package/commands/work-on/build/context.md +2 -0
- package/commands/work-on/build/implement.md +3 -1
- package/commands/work-on/build/validate.md +26 -7
- package/commands/work-on/build.md +163 -24
- package/commands/work-on/close.md +130 -3
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +24 -4
- package/commands/work-on-monolithic.md +7 -2
- package/commands/work-on.md +202 -4
- package/docs/BRAND.md +14 -7
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +5 -8
- package/docs/FORGE-PROTOCOL.md +15 -1064
- package/docs/anthropic-devrel-outreach.md +2 -2
- package/docs/articles/command-decomposition-token-savings.md +214 -0
- package/docs/articles/model-release-playbook.md +131 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
- package/docs/claude-breakpoints.json +75 -5
- package/docs/design/abc-benchmark.md +1 -1
- package/docs/design/design-spec-schema.md +1 -1
- package/docs/design/divergent-generation.md +1 -1
- package/docs/design/eval-gate-runbook.md +178 -0
- package/docs/design/show-hn-draft.md +26 -1
- package/docs/design/token-cost-baseline-1298.md +59 -0
- package/docs/eval/README.md +117 -0
- package/docs/eval/corpus/seed-v1.json +6 -0
- package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
- package/docs/site/annotations-explained.md +24 -0
- package/docs/site/command-learning-path.md +51 -14
- package/docs/site/command-reference.md +339 -113
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +43 -4
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +9 -1
- package/docs/spec/eval-run-result.md +147 -0
- package/docs/spec/forge-protocol-v1.md +441 -18
- package/docs/spec/label-state-machine.md +55 -0
- package/docs/spec/review-protocol.md +158 -0
- package/docs/spec-graph-schema.md +28 -0
- package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
- package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
- package/examples/forgedock-demo/README.md +41 -22
- package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
- package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
- package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
- package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
- package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
- package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
- package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
- package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
- package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
- package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
- package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
- package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
- package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
- package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
- package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
- package/examples/forgedock-demo/scripts/smoke.js +18 -1
- package/examples/forgedock-demo/src/db.js +36 -11
- package/examples/forgedock-demo/src/routes/notes.js +106 -9
- package/examples/forgedock-demo/src/server.js +4 -1
- package/package.json +3 -1
- package/packages/protocol/LICENSE +21 -0
- package/packages/protocol/src/cli.js +135 -0
- package/packages/protocol/src/emit.js +146 -0
- package/packages/protocol/src/index.js +43 -0
- package/packages/protocol/src/parse.js +229 -0
- package/packages/protocol/src/types.js +171 -0
- package/packages/protocol/src/validate.js +122 -0
- package/scripts/bench-topology-cost.mjs +372 -0
- package/scripts/check-command-docs-drift.sh +198 -0
- package/scripts/check-protocol-restatements.sh +176 -0
- package/scripts/check-registry/README.md +72 -0
- package/scripts/check-registry/manifest.json +6 -0
- package/scripts/code-index.sh +454 -0
- package/scripts/conformance-check.mjs +168 -0
- package/scripts/doctor-pipeline-state.sh +484 -0
- package/scripts/eval-gate-baseline.json +17 -0
- package/scripts/eval-gate-scorecard.mjs +242 -0
- package/scripts/eval-scorecard-aggregator.mjs +252 -0
- package/scripts/flaky-quarantine.sh +241 -0
- package/scripts/forge-annotation.sh +567 -0
- package/scripts/issue-dedup.sh +236 -0
- package/scripts/validate-annotation-node.mjs +233 -0
- package/scripts/worktree-lifecycle.sh +3 -3
- package/templates/devdocs/agent/using-forgedock.md +14 -0
- package/docs/site/.vitepress/config.mjs +0 -85
|
@@ -0,0 +1,1729 @@
|
|
|
1
|
+
---
|
|
2
|
+
install: extras
|
|
3
|
+
---
|
|
4
|
+
<!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
|
|
5
|
+
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
|
6
|
+
|
|
7
|
+
# /pipeline-health — Phase 2: Collect Pipeline Metrics
|
|
8
|
+
|
|
9
|
+
## Phase 2: Collect Pipeline Metrics
|
|
10
|
+
|
|
11
|
+
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`):
|
|
12
|
+
|
|
13
|
+
### 2A: Forge changelog (what changed in the pipeline itself)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd "$FORGE_HOME"
|
|
17
|
+
git log --oneline --since="$SINCE" -- commands/ docs/
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Record each commit — these are the independent variables. Each prompt change is a hypothesis: "this change should improve X."
|
|
21
|
+
|
|
22
|
+
### 2B: Review finding metrics (the primary quality signal)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Total review findings created in window
|
|
26
|
+
gh issue list -R $REPO --state all --label "review-finding" --json number,createdAt,labels,title,state \
|
|
27
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")] | length"
|
|
28
|
+
|
|
29
|
+
# Breakdown by severity (from title/labels)
|
|
30
|
+
gh issue list -R $REPO --state all --label "review-finding" --limit 500 --json number,title,labels,state,createdAt \
|
|
31
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")]"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Categorize each finding** by its agent prefix (SEC, AUTH, BILL, CONC, SCRP, FE, API, DB, INFRA) extracted from the title or finding tags.
|
|
35
|
+
|
|
36
|
+
### 2C: Invalidation rate (false positive signal)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Review findings that were closed as invalid
|
|
40
|
+
gh issue list -R $REPO --state closed --label "review-finding,workflow:invalid" --json number,createdAt \
|
|
41
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")] | length"
|
|
42
|
+
|
|
43
|
+
# Total review findings closed in window
|
|
44
|
+
gh issue list -R $REPO --state closed --label "review-finding" --json number,createdAt \
|
|
45
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")] | length"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**False positive rate** = invalid / total closed. Target: < 10%.
|
|
49
|
+
|
|
50
|
+
### 2D: Build failure rate
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# staging→main PRs and their outcomes
|
|
54
|
+
gh pr list -R $REPO --state all --base main --json number,state,mergedAt,createdAt \
|
|
55
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")] | {total: length, merged: [.[] | select(.mergedAt != null)] | length, failed: [.[] | select(.state == \"CLOSED\" and .mergedAt == null)] | length}"
|
|
56
|
+
|
|
57
|
+
# milestone→staging PRs and their outcomes
|
|
58
|
+
gh pr list -R $REPO --state all --limit 200 --json number,state,mergedAt,createdAt,baseRefName,headRefName \
|
|
59
|
+
--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}"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 2E: Review findings per PR (density metric)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# PRs merged in window
|
|
66
|
+
MERGED_COUNT=$(gh pr list -R $REPO --state closed --json mergedAt,createdAt \
|
|
67
|
+
--jq "[.[] | select(.mergedAt != null and .createdAt > \"$SINCE\")] | length")
|
|
68
|
+
|
|
69
|
+
# Review findings created in window
|
|
70
|
+
FINDING_COUNT=$(gh issue list -R $REPO --state all --label "review-finding" --json createdAt \
|
|
71
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")] | length")
|
|
72
|
+
|
|
73
|
+
echo "Findings per PR: $(echo "scale=2; $FINDING_COUNT / $MERGED_COUNT" | bc 2>/dev/null || echo 'N/A')"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Target: < 0.5 findings per PR (meaning most PRs pass clean).
|
|
77
|
+
|
|
78
|
+
### 2F: Issue lifecycle velocity
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Average time from issue creation to close (in days) for non-review-findings
|
|
82
|
+
gh issue list -R $REPO --state closed --json createdAt,closedAt,labels \
|
|
83
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | index(\"review-finding\") | not))] | .[].createdAt" | head -20
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 2G: Pipeline command usage (which commands are exercised most)
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Count PR titles and issue comments that reference pipeline actions
|
|
90
|
+
gh pr list -R $REPO --state all --limit 200 --json title,createdAt \
|
|
91
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")] | [.[].title] | group_by(. | split(\":\" )[0]) | map({prefix: .[0] | split(\":\")[0], count: length}) | sort_by(-.count)"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 2H: Manual fix-up commits (builder completeness signal)
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
**Detection strategy**:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# For each PR merged in the window, get the full commit list with authors
|
|
102
|
+
MERGED_PRS=$(gh pr list -R $REPO --state closed --limit 100 --json number,headRefName,mergedAt,createdAt \
|
|
103
|
+
--jq "[.[] | select(.mergedAt != null and .createdAt > \"$SINCE\")] | .[].number")
|
|
104
|
+
|
|
105
|
+
for PR in $MERGED_PRS; do
|
|
106
|
+
# Get all commits on this PR
|
|
107
|
+
gh api /repos/$REPO/pulls/$PR/commits \
|
|
108
|
+
--jq '.[] | {sha: .sha[0:7], author: .author.login, message: .commit.message | split("\n")[0], date: .commit.author.date}'
|
|
109
|
+
done
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Classification rules** — for each PR's commit list:
|
|
113
|
+
|
|
114
|
+
1. **Pipeline commit**: author is the project bot account, or commit message matches Forge conventional-commit pattern (`Fix:`, `Feat:`, `Refactor:` with issue reference).
|
|
115
|
+
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).
|
|
116
|
+
3. **Intentional human commit**: human commit that is clearly additive scope (new feature, unrelated change) — exclude from fix-up count.
|
|
117
|
+
|
|
118
|
+
When ambiguous, read the commit message and diff summary to determine if the human was correcting a pipeline gap or adding new scope.
|
|
119
|
+
|
|
120
|
+
**Also classify PRs by origin** — for each merged PR, determine if it is:
|
|
121
|
+
- **Original work**: primary feature/bug PR from an issue
|
|
122
|
+
- **Review-finding fix**: PR that fixes a review-finding issue (references a `review-finding` labeled issue)
|
|
123
|
+
- **Squash regression fix**: PR whose title/body indicates restoring code lost during a milestone squash merge
|
|
124
|
+
|
|
125
|
+
**Metrics to compute**:
|
|
126
|
+
- **Fix-up rate**: PRs with ≥1 manual fix-up commit / total pipeline-built PRs. Target: < 15%.
|
|
127
|
+
- **Fix-up density**: total manual fix-up commits / total pipeline-built PRs. Target: < 0.3.
|
|
128
|
+
- **Pipeline self-correction rate**: review-finding fix PRs / original work PRs. Target: < 15%.
|
|
129
|
+
- **Squash regression rate**: squash regression fix PRs / total PRs. Target: 0%.
|
|
130
|
+
- **Original work ratio**: original work PRs / total PRs. Target: > 70%.
|
|
131
|
+
- **Fix-up categories**: group by what was missing (config sync, edge case, test gap, incomplete refactor, wrong API usage, UI polish).
|
|
132
|
+
|
|
133
|
+
**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.
|
|
134
|
+
|
|
135
|
+
### 2I: Trajectory analytics (pipeline completeness signal)
|
|
136
|
+
|
|
137
|
+
`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.
|
|
138
|
+
|
|
139
|
+
**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.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# Collect all issues closed in the window
|
|
143
|
+
CLOSED_ISSUES=$(gh issue list -R $REPO --state closed --limit 100 --json number,closedAt \
|
|
144
|
+
--jq "[.[] | select(.closedAt > \"$SINCE\")] | .[].number")
|
|
145
|
+
|
|
146
|
+
# Accumulators
|
|
147
|
+
TRAJ_TOTAL=0
|
|
148
|
+
TRAJ_CONFIRMED=0
|
|
149
|
+
TRAJ_PARTIAL=0
|
|
150
|
+
TRAJ_INVALID=0
|
|
151
|
+
declare -A TASK_TYPE_COUNTS
|
|
152
|
+
ANOMALY_STRINGS=()
|
|
153
|
+
|
|
154
|
+
for NUM in $CLOSED_ISSUES; do
|
|
155
|
+
TRAJ=$(gh api repos/$REPO/issues/$NUM/comments \
|
|
156
|
+
--jq '.[] | select(.body | contains("FORGE:TRAJECTORY")) | .body' 2>/dev/null)
|
|
157
|
+
|
|
158
|
+
# Skip issues with no trajectory comment (pre-pipeline or manually closed)
|
|
159
|
+
[ -z "$TRAJ" ] && continue
|
|
160
|
+
|
|
161
|
+
TRAJ_TOTAL=$((TRAJ_TOTAL + 1))
|
|
162
|
+
|
|
163
|
+
# Parse investigation verdict from the phase table row
|
|
164
|
+
# Matches: "| Phase 1: Investigation | ✅ CONFIRMED (HIGH) |" or "✅ PARTIAL (MEDIUM) |" etc.
|
|
165
|
+
VERDICT=$(echo "$TRAJ" | grep -oP '(?<=Investigation \| ✅ )(CONFIRMED|PARTIAL|INVALID)' | head -1)
|
|
166
|
+
case "$VERDICT" in
|
|
167
|
+
CONFIRMED) TRAJ_CONFIRMED=$((TRAJ_CONFIRMED + 1)) ;;
|
|
168
|
+
PARTIAL) TRAJ_PARTIAL=$((TRAJ_PARTIAL + 1)) ;;
|
|
169
|
+
INVALID) TRAJ_INVALID=$((TRAJ_INVALID + 1)) ;;
|
|
170
|
+
esac
|
|
171
|
+
|
|
172
|
+
# Parse task type from "Task type: Feature" or "Task type: Bug Fix"
|
|
173
|
+
TASK_TYPE=$(echo "$TRAJ" | grep -oP '(?<=Task type: )[^\|]+' | tr -d ' ' | head -1)
|
|
174
|
+
if [ -n "$TASK_TYPE" ]; then
|
|
175
|
+
TASK_TYPE_COUNTS[$TASK_TYPE]=$(( ${TASK_TYPE_COUNTS[$TASK_TYPE]:-0} + 1 ))
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
# Collect non-None anomaly strings
|
|
179
|
+
ANOMALY=$(echo "$TRAJ" | grep -oP '(?<=\*\*Anomalies\*\*: )(?!None)[^\n]+' | head -1)
|
|
180
|
+
[ -n "$ANOMALY" ] && ANOMALY_STRINGS+=("$NUM: $ANOMALY")
|
|
181
|
+
done
|
|
182
|
+
|
|
183
|
+
# Compute investigation accuracy rate (CONFIRMED + PARTIAL) / total
|
|
184
|
+
TRAJ_ACCURATE=$((TRAJ_CONFIRMED + TRAJ_PARTIAL))
|
|
185
|
+
if [ "$TRAJ_TOTAL" -gt 0 ]; then
|
|
186
|
+
echo "Trajectory coverage: $TRAJ_TOTAL / $(echo "$CLOSED_ISSUES" | wc -w) closed issues"
|
|
187
|
+
echo "Investigation accuracy: $TRAJ_ACCURATE / $TRAJ_TOTAL ($(echo "scale=0; $TRAJ_ACCURATE * 100 / $TRAJ_TOTAL" | bc)%)"
|
|
188
|
+
echo " CONFIRMED: $TRAJ_CONFIRMED | PARTIAL: $TRAJ_PARTIAL | INVALID: $TRAJ_INVALID"
|
|
189
|
+
echo ""
|
|
190
|
+
echo "Task type distribution:"
|
|
191
|
+
for TT in "${!TASK_TYPE_COUNTS[@]}"; do
|
|
192
|
+
echo " $TT: ${TASK_TYPE_COUNTS[$TT]}"
|
|
193
|
+
done
|
|
194
|
+
echo ""
|
|
195
|
+
echo "Anomalies flagged: ${#ANOMALY_STRINGS[@]}"
|
|
196
|
+
for A in "${ANOMALY_STRINGS[@]}"; do echo " - Issue $A"; done
|
|
197
|
+
else
|
|
198
|
+
echo "No FORGE:TRAJECTORY comments found in window — no trajectory data available"
|
|
199
|
+
fi
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Metrics to compute**:
|
|
203
|
+
- **Investigation accuracy rate**: (CONFIRMED + PARTIAL) / total issues with trajectory. Target: > 85%.
|
|
204
|
+
- **INVALID rate**: INVALID / total. Target: < 15% (same as Phase 2J invalidation rate — these should converge).
|
|
205
|
+
- **Task type distribution**: counts of Bug Fix / Feature / Refactor / Maintenance. Reveals whether the pipeline is doing forward-progress work or mostly self-correction.
|
|
206
|
+
- **Anomaly count**: total issues with non-None anomaly strings. Target: < 10% of window. High anomaly rates indicate pipeline instability.
|
|
207
|
+
|
|
208
|
+
**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).
|
|
209
|
+
|
|
210
|
+
### 2J: Issue quality metrics (pipeline entry-point signal)
|
|
211
|
+
|
|
212
|
+
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.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
# 1. Investigation invalidation rate
|
|
216
|
+
# Issues closed as workflow:invalid (excluding review-findings — those are tracked in 2C)
|
|
217
|
+
INVALID_ISSUES=$(gh issue list -R $REPO --state closed --label "workflow:invalid" --limit 500 --json number,createdAt,labels \
|
|
218
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | index(\"review-finding\") | not))] | length")
|
|
219
|
+
|
|
220
|
+
TOTAL_INVESTIGATED=$(gh issue list -R $REPO --state all --limit 500 --json number,createdAt,labels \
|
|
221
|
+
--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")
|
|
222
|
+
|
|
223
|
+
echo "Investigation invalidation rate: $INVALID_ISSUES / $TOTAL_INVESTIGATED"
|
|
224
|
+
# Target: < 15%
|
|
225
|
+
|
|
226
|
+
# 2. ISSUE_SPEC audit findings
|
|
227
|
+
# Count of /audit findings classified as ISSUE_SPEC in the window
|
|
228
|
+
ISSUE_SPEC_COUNT=$(gh issue list -R $FORGE_REPO --state all --label "audit-finding" --limit 200 \
|
|
229
|
+
--json number,title,body,createdAt \
|
|
230
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\" and (.body | contains(\"ISSUE_SPEC\")))] | length")
|
|
231
|
+
|
|
232
|
+
echo "ISSUE_SPEC audit findings: $ISSUE_SPEC_COUNT"
|
|
233
|
+
# Target: 0. Any ISSUE_SPEC finding means /issue adoption is insufficient or issue quality is degraded.
|
|
234
|
+
|
|
235
|
+
# 3. Re-investigation rate
|
|
236
|
+
# Issues where FORGE:INVESTIGATOR comment was deleted and recreated (interrupted investigation → restart)
|
|
237
|
+
# Proxy: issues that were labeled workflow:investigating more than once (check via timeline events)
|
|
238
|
+
REINVESTIGATED=$(gh issue list -R $REPO --state all --limit 500 --json number,createdAt \
|
|
239
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")] | .[].number" | while read NUM; do
|
|
240
|
+
# Check if issue has timeline events showing workflow:investigating label added 2+ times
|
|
241
|
+
INVEST_EVENTS=$(gh api repos/$REPO/issues/$NUM/timeline \
|
|
242
|
+
--jq '[.[] | select(.event == "labeled" and .label.name == "workflow:investigating")] | length' 2>/dev/null || echo 0)
|
|
243
|
+
if [ "$INVEST_EVENTS" -gt 1 ] 2>/dev/null; then echo "$NUM"; fi
|
|
244
|
+
done | wc -l)
|
|
245
|
+
|
|
246
|
+
echo "Re-investigation count: $REINVESTIGATED"
|
|
247
|
+
# Target: 0. Each re-investigation signals the original issue spec was unclear or wrong.
|
|
248
|
+
|
|
249
|
+
# 4. Issue structure compliance (/issue adoption proxy)
|
|
250
|
+
# Issues created in window that have an "Affected Files" section in their body
|
|
251
|
+
TOTAL_ISSUES=$(gh issue list -R $REPO --state all --limit 500 --json number,createdAt,labels \
|
|
252
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | index(\"review-finding\") | not))] | length")
|
|
253
|
+
|
|
254
|
+
STRUCTURED_ISSUES=$(gh issue list -R $REPO --state all --limit 500 --json number,createdAt,body,labels \
|
|
255
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\" and (.labels | map(.name) | index(\"review-finding\") | not) and (.body | contains(\"Affected Files\")))] | length")
|
|
256
|
+
|
|
257
|
+
echo "Issue structure compliance: $STRUCTURED_ISSUES / $TOTAL_ISSUES"
|
|
258
|
+
# Target: > 80%. Issues without Affected Files are harder to investigate and more likely to produce ISSUE_SPEC failures.
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Issue quality score** = weighted average of sub-metrics:
|
|
262
|
+
- Invalidation rate: 40% weight (direct signal — these issues failed)
|
|
263
|
+
- ISSUE_SPEC audit findings: 30% weight (downstream cost signal)
|
|
264
|
+
- Re-investigation rate: 20% weight (process cost signal)
|
|
265
|
+
- Structure compliance: 10% weight (adoption/leading indicator)
|
|
266
|
+
|
|
267
|
+
**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.
|
|
268
|
+
|
|
269
|
+
### 2K: Orchestration efficiency (agent throughput signal)
|
|
270
|
+
|
|
271
|
+
`/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.
|
|
272
|
+
|
|
273
|
+
**Step 1 — Query persisted audit-agents summaries**:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
# Persisted audit-agents summaries are posted to a designated Forge tracking issue
|
|
277
|
+
# tagged with label "orchestration-metrics". Find it:
|
|
278
|
+
TRACKING_ISSUE=$(gh issue list -R $FORGE_REPO \
|
|
279
|
+
--state open --label "orchestration-metrics" --limit 1 \
|
|
280
|
+
--json number --jq '.[0].number' 2>/dev/null)
|
|
281
|
+
|
|
282
|
+
if [ -n "$TRACKING_ISSUE" ]; then
|
|
283
|
+
# Fetch all FORGE:AUDIT-AGENTS comments posted since $SINCE
|
|
284
|
+
AUDIT_COMMENTS=$(gh api repos/$FORGE_REPO/issues/$TRACKING_ISSUE/comments \
|
|
285
|
+
--jq "[.[] | select(.body | contains(\"FORGE:AUDIT-AGENTS\")) | select(.created_at > \"$SINCE\")]")
|
|
286
|
+
echo "Found $(echo "$AUDIT_COMMENTS" | jq 'length') persisted audit-agents summaries in window"
|
|
287
|
+
else
|
|
288
|
+
echo "No orchestration-metrics tracking issue found — skipping persisted data query"
|
|
289
|
+
AUDIT_COMMENTS="[]"
|
|
290
|
+
fi
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Step 2 — Extract metrics from persisted summaries**:
|
|
294
|
+
|
|
295
|
+
Each `<!-- FORGE:AUDIT-AGENTS -->` comment contains a structured block (posted by `audit-agents --persist`) with these fields:
|
|
296
|
+
|
|
297
|
+
```
|
|
298
|
+
Session: {SESSION_ID}
|
|
299
|
+
Date: {ISO_TIMESTAMP}
|
|
300
|
+
Agents: {TOTAL_AGENT_COUNT}
|
|
301
|
+
Avg idle%: {VALUE}
|
|
302
|
+
Avg resumes: {VALUE}
|
|
303
|
+
Clean agents: {N}/{TOTAL}
|
|
304
|
+
Stall boundaries: {boundary_1}({count}), {boundary_2}({count}), ...
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
# Parse each comment and accumulate metrics
|
|
309
|
+
ORCH_SESSIONS=0
|
|
310
|
+
ORCH_TOTAL_IDLE=0
|
|
311
|
+
ORCH_TOTAL_RESUMES=0
|
|
312
|
+
ORCH_CLEAN_AGENTS=0
|
|
313
|
+
ORCH_TOTAL_AGENTS=0
|
|
314
|
+
declare -A STALL_BOUNDARY_COUNTS
|
|
315
|
+
|
|
316
|
+
echo "$AUDIT_COMMENTS" | jq -r '.[] | .body + "\u0000"' | while IFS= read -r -d $'\0' COMMENT; do
|
|
317
|
+
ORCH_SESSIONS=$((ORCH_SESSIONS + 1))
|
|
318
|
+
|
|
319
|
+
AVG_IDLE=$(echo "$COMMENT" | grep -oP '(?<=Avg idle%: )[0-9.]+' | head -1)
|
|
320
|
+
AVG_RESUMES=$(echo "$COMMENT" | grep -oP '(?<=Avg resumes: )[0-9.]+' | head -1)
|
|
321
|
+
CLEAN=$(echo "$COMMENT" | grep -oP '(?<=Clean agents: )\d+' | head -1)
|
|
322
|
+
TOTAL=$(echo "$COMMENT" | grep -oP '(?<=Clean agents: \d{1,3}\/)\d+' | head -1)
|
|
323
|
+
|
|
324
|
+
[ -n "$AVG_IDLE" ] && ORCH_TOTAL_IDLE=$(echo "$ORCH_TOTAL_IDLE + $AVG_IDLE" | bc)
|
|
325
|
+
[ -n "$AVG_RESUMES" ] && ORCH_TOTAL_RESUMES=$(echo "$ORCH_TOTAL_RESUMES + $AVG_RESUMES" | bc)
|
|
326
|
+
[ -n "$CLEAN" ] && ORCH_CLEAN_AGENTS=$((ORCH_CLEAN_AGENTS + CLEAN))
|
|
327
|
+
[ -n "$TOTAL" ] && ORCH_TOTAL_AGENTS=$((ORCH_TOTAL_AGENTS + TOTAL))
|
|
328
|
+
|
|
329
|
+
# Accumulate stall boundary counts
|
|
330
|
+
BOUNDARIES=$(echo "$COMMENT" | grep -oP '(?<=Stall boundaries: )[^\n]+' | head -1)
|
|
331
|
+
# Format: "investigate→build(4), context→architect(3)"
|
|
332
|
+
echo "$BOUNDARIES" | grep -oP '[a-z→]+\(\d+\)' | while IFS= read -r ENTRY; do
|
|
333
|
+
BOUNDARY=$(echo "$ENTRY" | grep -oP '^[a-z→]+')
|
|
334
|
+
COUNT=$(echo "$ENTRY" | grep -oP '\d+')
|
|
335
|
+
STALL_BOUNDARY_COUNTS[$BOUNDARY]=$(( ${STALL_BOUNDARY_COUNTS[$BOUNDARY]:-0} + COUNT ))
|
|
336
|
+
done
|
|
337
|
+
done
|
|
338
|
+
|
|
339
|
+
# Compute aggregate metrics
|
|
340
|
+
if [ "$ORCH_SESSIONS" -gt 0 ]; then
|
|
341
|
+
AVG_IDLE_OVERALL=$(echo "scale=1; $ORCH_TOTAL_IDLE / $ORCH_SESSIONS" | bc)
|
|
342
|
+
AVG_RESUMES_OVERALL=$(echo "scale=2; $ORCH_TOTAL_RESUMES / $ORCH_SESSIONS" | bc)
|
|
343
|
+
CLEAN_RATE=$(echo "scale=0; $ORCH_CLEAN_AGENTS * 100 / $ORCH_TOTAL_AGENTS" | bc 2>/dev/null || echo "N/A")
|
|
344
|
+
echo "Orchestration efficiency ($ORCH_SESSIONS sessions, $ORCH_TOTAL_AGENTS agents):"
|
|
345
|
+
echo " Avg idle%: $AVG_IDLE_OVERALL% (target: < 30%)"
|
|
346
|
+
echo " Avg resumes per agent: $AVG_RESUMES_OVERALL (target: < 1)"
|
|
347
|
+
echo " Clean agent rate: $CLEAN_RATE% (target: > 60%)"
|
|
348
|
+
echo " Top stall boundaries:"
|
|
349
|
+
for B in "${!STALL_BOUNDARY_COUNTS[@]}"; do
|
|
350
|
+
echo " $B: ${STALL_BOUNDARY_COUNTS[$B]} occurrences"
|
|
351
|
+
done | sort -t: -k2 -rn | head -5
|
|
352
|
+
ORCH_DATA_AVAILABLE=true
|
|
353
|
+
else
|
|
354
|
+
echo "No persisted audit-agents data in window"
|
|
355
|
+
ORCH_DATA_AVAILABLE=false
|
|
356
|
+
fi
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Step 3 — Fallback: mine FORGE:TRAJECTORY for resume-cycle signals**:
|
|
360
|
+
|
|
361
|
+
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.
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
if [ "$ORCH_DATA_AVAILABLE" = "false" ]; then
|
|
365
|
+
echo "Falling back to FORGE:TRAJECTORY anomaly mining for orchestration proxy signals"
|
|
366
|
+
# Count how many trajectory issues had anomalies mentioning "resume" or "stall"
|
|
367
|
+
RESUME_ANOMALIES=0
|
|
368
|
+
for A in "${ANOMALY_STRINGS[@]}"; do
|
|
369
|
+
echo "$A" | grep -qi 'resume\|stall\|end.turn\|routing' && RESUME_ANOMALIES=$((RESUME_ANOMALIES + 1))
|
|
370
|
+
done
|
|
371
|
+
echo "Issues with resume/stall anomalies in trajectory: $RESUME_ANOMALIES / $TRAJ_TOTAL"
|
|
372
|
+
echo "(Proxy only — run 'audit-agents --persist' after orchestration runs for full efficiency data)"
|
|
373
|
+
fi
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Metrics to compute**:
|
|
377
|
+
- **Avg agent idle%**: average idle time percentage across all agents in all sessions. Target: < 30%.
|
|
378
|
+
- **Avg resume cycles per agent**: how often orchestrator must resume stalled agents. Target: < 1.
|
|
379
|
+
- **Clean agent rate**: % of agents that complete without any stalls. Target: > 60%.
|
|
380
|
+
- **Top stall boundaries**: which phase transitions cause the most stalls (e.g., investigate→build, context→architect). No target — use for diagnosis.
|
|
381
|
+
|
|
382
|
+
**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.
|
|
383
|
+
|
|
384
|
+
**Step 4 — Batch stall rate (from FORGE:STALL_DETECTED annotations)**:
|
|
385
|
+
|
|
386
|
+
Query issues that received a `<!-- FORGE:STALL_DETECTED -->` comment within the analysis window. This measures how often the orchestrator's time-based stall detector fired — distinct from the resume-cycle count (which comes from completion-event stalls).
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
# Denominator: issues that entered the pipeline in the window (have any workflow:* label)
|
|
390
|
+
# Using pipeline-active issues avoids inflating the denominator with all 500 issues in window
|
|
391
|
+
BATCH_TOTAL=$(gh issue list -R $GH_REPO \
|
|
392
|
+
--state all --limit 500 \
|
|
393
|
+
--json number,createdAt,labels \
|
|
394
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\") | select(.labels | map(.name) | any(startswith(\"workflow:\")))] | length" \
|
|
395
|
+
2>/dev/null || echo 0)
|
|
396
|
+
|
|
397
|
+
# Enumerate pipeline-active issue numbers for per-issue comment queries
|
|
398
|
+
PIPELINE_ISSUE_NUMS=$(gh issue list -R $GH_REPO \
|
|
399
|
+
--state all --limit 500 \
|
|
400
|
+
--json number,createdAt,labels \
|
|
401
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\") | select(.labels | map(.name) | any(startswith(\"workflow:\"))) | .number]" \
|
|
402
|
+
2>/dev/null || echo "[]")
|
|
403
|
+
|
|
404
|
+
# Count issues with at least one FORGE:STALL_DETECTED comment
|
|
405
|
+
# Use wc -l pattern (not variable accumulation) to avoid bash subshell scope loss
|
|
406
|
+
STALL_COUNT=$(echo "$PIPELINE_ISSUE_NUMS" | jq -r '.[]' | while read -r NUM; do
|
|
407
|
+
STALL_COMMENTS=$(gh api repos/$GH_REPO/issues/${NUM}/comments \
|
|
408
|
+
--jq '[.[] | select(.body | contains("FORGE:STALL_DETECTED"))] | length' 2>/dev/null || echo 0)
|
|
409
|
+
[ "$STALL_COMMENTS" -gt 0 ] && echo "$NUM"
|
|
410
|
+
done | wc -l)
|
|
411
|
+
|
|
412
|
+
# Count issues that escalated (2+ stall comments = auto-resume exhausted)
|
|
413
|
+
ESCALATED_COUNT=$(echo "$PIPELINE_ISSUE_NUMS" | jq -r '.[]' | while read -r NUM; do
|
|
414
|
+
STALL_COMMENTS=$(gh api repos/$GH_REPO/issues/${NUM}/comments \
|
|
415
|
+
--jq '[.[] | select(.body | contains("FORGE:STALL_DETECTED"))] | length' 2>/dev/null || echo 0)
|
|
416
|
+
[ "$STALL_COMMENTS" -ge 2 ] && echo "$NUM"
|
|
417
|
+
done | wc -l)
|
|
418
|
+
|
|
419
|
+
# Compute batch stall rate
|
|
420
|
+
if [ "$BATCH_TOTAL" -gt 0 ]; then
|
|
421
|
+
BATCH_STALL_RATE=$(echo "scale=1; $STALL_COUNT * 100 / $BATCH_TOTAL" | bc 2>/dev/null || echo "N/A")
|
|
422
|
+
echo "Batch stall rate: ${BATCH_STALL_RATE}% (${STALL_COUNT}/${BATCH_TOTAL} pipeline issues stalled) — target: < 10%"
|
|
423
|
+
echo "Escalated to needs-human: ${ESCALATED_COUNT} issues"
|
|
424
|
+
else
|
|
425
|
+
BATCH_STALL_RATE="N/A"
|
|
426
|
+
echo "Batch stall rate: N/A (no pipeline issues in window or FORGE:STALL_DETECTED not yet deployed)"
|
|
427
|
+
fi
|
|
428
|
+
export BATCH_STALL_RATE STALL_COUNT BATCH_TOTAL ESCALATED_COUNT
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
**Target**: `BATCH_STALL_RATE` < 10% (stalled issues / total issues in window). A rate above 10% indicates systemic pipeline unreliability — agents stopping without reaching terminal state frequently enough to require orchestrator intervention.
|
|
432
|
+
|
|
433
|
+
**Note**: `BATCH_STALL_RATE` requires `FORGE:STALL_DETECTED` comments, which are written by the stall detector in `/orchestrate` Step 4B.5. If the stall detector is not deployed, this metric returns N/A and falls back to the resume-cycle proxy from Step 3.
|
|
434
|
+
|
|
435
|
+
### 2L: Post-deploy failure metrics (pipeline escape signal)
|
|
436
|
+
|
|
437
|
+
`/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.
|
|
438
|
+
|
|
439
|
+
**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.
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
# All audit-finding issues created in the window
|
|
443
|
+
AUDIT_FINDINGS=$(gh issue list -R $FORGE_REPO --state all --label "audit-finding" \
|
|
444
|
+
--limit 200 --json number,title,body,createdAt \
|
|
445
|
+
--jq "[.[] | select(.createdAt > \"$SINCE\")]")
|
|
446
|
+
|
|
447
|
+
AUDIT_TOTAL=$(echo "$AUDIT_FINDINGS" | jq 'length')
|
|
448
|
+
echo "Audit findings in window: $AUDIT_TOTAL"
|
|
449
|
+
|
|
450
|
+
# Post-deploy failure rate = audit findings / PRs merged in window
|
|
451
|
+
# (MERGED_COUNT computed in Phase 2E)
|
|
452
|
+
if [ "$MERGED_COUNT" -gt 0 ] && [ "$AUDIT_TOTAL" -gt 0 ]; then
|
|
453
|
+
echo "Post-deploy failure rate: $(echo "scale=1; $AUDIT_TOTAL * 100 / $MERGED_COUNT" | bc)% (target: < 5%)"
|
|
454
|
+
else
|
|
455
|
+
echo "Post-deploy failure rate: 0% (no audit findings in window)"
|
|
456
|
+
fi
|
|
457
|
+
|
|
458
|
+
# Review escape rate = REVIEW_FALSE_NEG findings / PRs merged in window
|
|
459
|
+
REVIEW_ESCAPE_COUNT=$(echo "$AUDIT_FINDINGS" | \
|
|
460
|
+
jq '[.[] | select(.body | test("\\*\\*Failure point\\*\\*:.*REVIEW_FALSE_NEG"))] | length')
|
|
461
|
+
echo "Review escape count: $REVIEW_ESCAPE_COUNT"
|
|
462
|
+
if [ "$MERGED_COUNT" -gt 0 ]; then
|
|
463
|
+
echo "Review escape rate: $(echo "scale=1; $REVIEW_ESCAPE_COUNT * 100 / $MERGED_COUNT" | bc)% (target: < 2%)"
|
|
464
|
+
fi
|
|
465
|
+
|
|
466
|
+
# Failure category distribution (by Failure point field)
|
|
467
|
+
echo ""
|
|
468
|
+
echo "Failure point distribution:"
|
|
469
|
+
echo "$AUDIT_FINDINGS" | jq -r \
|
|
470
|
+
'.[] | .body | capture("\\*\\*Failure point\\*\\*: *(?<fp>[^\\n]+)").fp // "UNKNOWN"' \
|
|
471
|
+
| sort | uniq -c | sort -rn
|
|
472
|
+
|
|
473
|
+
# Mean time to detect (days between issue createdAt and linked PR's mergedAt)
|
|
474
|
+
# Simplified: use audit finding createdAt as proxy for detection time relative to window start
|
|
475
|
+
# Full MTTD requires joining audit-finding body for the source PR number and querying its mergedAt
|
|
476
|
+
echo ""
|
|
477
|
+
echo "Mean time to detect (proxy — days from window open to finding creation):"
|
|
478
|
+
echo "$AUDIT_FINDINGS" | jq -r \
|
|
479
|
+
--arg since "$SINCE" \
|
|
480
|
+
'[.[] | {days: ((.createdAt | fromdateiso8601) - ($since | fromdateiso8601)) / 86400}] | if length > 0 then (map(.days) | add / length) else 0 end' \
|
|
481
|
+
| awk '{printf "%.1f days\n", $1}'
|
|
482
|
+
|
|
483
|
+
# Severity breakdown (P1 vs P2 vs P3 from labels)
|
|
484
|
+
echo ""
|
|
485
|
+
echo "Severity breakdown:"
|
|
486
|
+
gh issue list -R $FORGE_REPO --state all --label "audit-finding" \
|
|
487
|
+
--limit 200 --json number,createdAt,labels \
|
|
488
|
+
--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})"
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**Metrics to compute**:
|
|
492
|
+
- **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.
|
|
493
|
+
- **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.
|
|
494
|
+
- **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.
|
|
495
|
+
- **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.
|
|
496
|
+
|
|
497
|
+
### 2M: Session discovery and date-gated filtering (conversation transcript analytics)
|
|
498
|
+
|
|
499
|
+
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).
|
|
500
|
+
|
|
501
|
+
**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.
|
|
502
|
+
|
|
503
|
+
**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.
|
|
504
|
+
|
|
505
|
+
**Path construction algorithm** (empirically verified from investigation #348):
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
# Convert a Claude Code project path to its ~/.claude/projects/ directory name
|
|
509
|
+
# Rule: both '/' and '.' are replaced with '-'
|
|
510
|
+
# Examples:
|
|
511
|
+
# /home/user/projects/myproject -> -home-user-projects-myproject
|
|
512
|
+
# /home/user/projects/myproject/.claude/worktrees/fix-branch
|
|
513
|
+
# -> -home-user-projects-myproject--claude-worktrees-fix-branch
|
|
514
|
+
# (note: .claude -> --claude because '.' becomes '-' and surrounding '/' also become '-')
|
|
515
|
+
_session_project_dir() {
|
|
516
|
+
echo "$1" | sed 's|[/.]|-|g'
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
**Step 1 — Parse history.jsonl and filter to pipeline sessions in window**:
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
HISTORY_FILE="$HOME/.claude/history.jsonl"
|
|
524
|
+
PIPELINE_COMMANDS='work-on|orchestrate|review-pr|quality-gate|review-pr-staging'
|
|
525
|
+
|
|
526
|
+
if [ ! -f "$HISTORY_FILE" ]; then
|
|
527
|
+
echo "Phase 2M: $HISTORY_FILE not found — skipping session discovery"
|
|
528
|
+
SESSION_JSONL_FILES=()
|
|
529
|
+
SESSION_TOTAL=0
|
|
530
|
+
else
|
|
531
|
+
echo "Phase 2M: parsing $HISTORY_FILE for pipeline sessions since $SINCE"
|
|
532
|
+
|
|
533
|
+
# Extract pipeline sessions: entries where display starts with a pipeline command
|
|
534
|
+
# and timestamp is after $SINCE. One JSON object per line.
|
|
535
|
+
# Output: tab-separated sessionId, project, timestamp, display
|
|
536
|
+
SESSION_CANDIDATES=$(jq -r --arg since "$SINCE" --arg cmds "$PIPELINE_COMMANDS" '
|
|
537
|
+
select(
|
|
538
|
+
.timestamp != null and
|
|
539
|
+
.timestamp > $since and
|
|
540
|
+
.sessionId != null and
|
|
541
|
+
.project != null and
|
|
542
|
+
.display != null and
|
|
543
|
+
(.display | test("^/(" + $cmds + ")( |$)"))
|
|
544
|
+
) |
|
|
545
|
+
[.sessionId, .project, .timestamp, (.display | split(" ") | .[0])] | @tsv
|
|
546
|
+
' "$HISTORY_FILE" 2>/dev/null)
|
|
547
|
+
|
|
548
|
+
# Deduplicate by sessionId — keep only the earliest entry per session
|
|
549
|
+
# (earliest timestamp = session start time, most accurate for date filtering)
|
|
550
|
+
DEDUPED_SESSIONS=$(echo "$SESSION_CANDIDATES" | sort -t$'\t' -k3,3 | sort -t$'\t' -k1,1 -u)
|
|
551
|
+
|
|
552
|
+
SESSION_COUNT=$(echo "$DEDUPED_SESSIONS" | grep -c . 2>/dev/null || echo 0)
|
|
553
|
+
echo "Phase 2M: found $SESSION_COUNT unique pipeline sessions in window (pre-disk verification)"
|
|
554
|
+
fi
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
**Step 2 — Map session IDs to JSONL files on disk**:
|
|
558
|
+
|
|
559
|
+
```bash
|
|
560
|
+
SESSION_JSONL_FILES=()
|
|
561
|
+
SESSION_MISSING_COUNT=0
|
|
562
|
+
SESSION_VERIFIED_COUNT=0
|
|
563
|
+
SESSION_FP_COUNT=0
|
|
564
|
+
|
|
565
|
+
CLAUDE_PROJECTS_DIR="$HOME/.claude/projects"
|
|
566
|
+
|
|
567
|
+
while IFS=$'\t' read -r SESSION_ID PROJECT TIMESTAMP DISPLAY_CMD; do
|
|
568
|
+
[ -z "$SESSION_ID" ] && continue
|
|
569
|
+
|
|
570
|
+
# Convert project path to directory name
|
|
571
|
+
PROJECT_DIR=$(_session_project_dir "$PROJECT")
|
|
572
|
+
JSONL_PATH="$CLAUDE_PROJECTS_DIR/$PROJECT_DIR/$SESSION_ID.jsonl"
|
|
573
|
+
|
|
574
|
+
# Skip if file does not exist on disk (older/pruned sessions, different machine)
|
|
575
|
+
if [ ! -f "$JSONL_PATH" ]; then
|
|
576
|
+
SESSION_MISSING_COUNT=$((SESSION_MISSING_COUNT + 1))
|
|
577
|
+
continue
|
|
578
|
+
fi
|
|
579
|
+
|
|
580
|
+
# Verify actual command via first <command-name> XML tag in JSONL
|
|
581
|
+
# Pipeline commands are stored with double-slash prefix: //work-on, //orchestrate, etc.
|
|
582
|
+
# Read only the first 5 lines (enough to find the command tag — it appears in the first message)
|
|
583
|
+
ACTUAL_CMD=$(head -5 "$JSONL_PATH" 2>/dev/null | \
|
|
584
|
+
grep -oP '(?<=<command-name>)//[^<]+(?=</command-name>)' | head -1)
|
|
585
|
+
|
|
586
|
+
# Strip the double-slash prefix for comparison
|
|
587
|
+
ACTUAL_CMD_CLEAN="${ACTUAL_CMD#//}"
|
|
588
|
+
|
|
589
|
+
# Verify the actual command matches a pipeline command
|
|
590
|
+
if ! echo "$ACTUAL_CMD_CLEAN" | grep -qP "^($PIPELINE_COMMANDS)$"; then
|
|
591
|
+
SESSION_FP_COUNT=$((SESSION_FP_COUNT + 1))
|
|
592
|
+
continue # False positive — display said pipeline command but JSONL ran something else
|
|
593
|
+
fi
|
|
594
|
+
|
|
595
|
+
# Determine subagent directory (JSONL files for agent sub-calls within this session)
|
|
596
|
+
# Sub-agent JSONL files share the same project dir but have different session IDs
|
|
597
|
+
# The parent session's own JSONL is the primary; subagents appear as sibling files
|
|
598
|
+
SUBAGENT_DIR="$CLAUDE_PROJECTS_DIR/$PROJECT_DIR"
|
|
599
|
+
|
|
600
|
+
SESSION_JSONL_FILES+=("$SESSION_ID $ACTUAL_CMD_CLEAN $TIMESTAMP $JSONL_PATH $SUBAGENT_DIR")
|
|
601
|
+
SESSION_VERIFIED_COUNT=$((SESSION_VERIFIED_COUNT + 1))
|
|
602
|
+
|
|
603
|
+
done <<< "$DEDUPED_SESSIONS"
|
|
604
|
+
|
|
605
|
+
SESSION_TOTAL=${#SESSION_JSONL_FILES[@]}
|
|
606
|
+
|
|
607
|
+
echo "Phase 2M: session discovery complete"
|
|
608
|
+
echo " Verified pipeline sessions: $SESSION_TOTAL"
|
|
609
|
+
echo " Missing on disk (pruned): $SESSION_MISSING_COUNT"
|
|
610
|
+
echo " False positives filtered: $SESSION_FP_COUNT"
|
|
611
|
+
echo ""
|
|
612
|
+
echo "SESSION_JSONL_FILES contains $SESSION_TOTAL entries — available for transcript analytics phases"
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
**Output format** (each element in `SESSION_JSONL_FILES` is a tab-separated record):
|
|
616
|
+
|
|
617
|
+
| Field | Content | Example |
|
|
618
|
+
|-------|---------|---------|
|
|
619
|
+
| `sessionId` | Claude Code session UUID | `abc123-def456-...` |
|
|
620
|
+
| `command` | Actual pipeline command run | `work-on` |
|
|
621
|
+
| `timestamp` | Session start (from history.jsonl) | `2026-05-28T14:23:01Z` |
|
|
622
|
+
| `jsonlPath` | Absolute path to JSONL file | `~/.claude/projects/-home-.../abc123.jsonl` |
|
|
623
|
+
| `subagentDir` | Directory containing sibling JSONL files | `~/.claude/projects/-home-.../` |
|
|
624
|
+
|
|
625
|
+
**Performance characteristics** (from investigation #348 empirical measurement):
|
|
626
|
+
- `history.jsonl` parse + filter (25K entries): **<100ms**
|
|
627
|
+
- mtime scan of 900 disk files: **<2ms**
|
|
628
|
+
- Head-5 JSONL read for XML tag: **negligible** (<1ms per file)
|
|
629
|
+
- Total Phase 2M runtime: **<5s** for 25K history entries with 300 pipeline sessions
|
|
630
|
+
|
|
631
|
+
**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`.
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
### JSONL Parser Utility
|
|
636
|
+
|
|
637
|
+
<!-- FORGE:JSONL_PARSER_UTIL -->
|
|
638
|
+
|
|
639
|
+
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.
|
|
640
|
+
|
|
641
|
+
**Schema source**: #346 (investigate: map Claude Code JSONL transcript schema) — 8 message types mapped across 979 records.
|
|
642
|
+
|
|
643
|
+
**Privacy contract**: Raw conversation content is NEVER emitted. Only structural metadata is extracted: tool names, skill identifiers, agent descriptions, timing data, and session metadata.
|
|
644
|
+
|
|
645
|
+
**Usage** (phases that call this parser pass a JSONL file path):
|
|
646
|
+
```bash
|
|
647
|
+
# Parse a single JSONL file and emit one structured event per line (NDJSON)
|
|
648
|
+
JSONL_FILE="~/.claude/projects/{project}/{sessionId}.jsonl"
|
|
649
|
+
python3 - "$JSONL_FILE" << 'PYEOF'
|
|
650
|
+
import json, sys, os
|
|
651
|
+
|
|
652
|
+
jsonl_file = sys.argv[1] if len(sys.argv) > 1 else None
|
|
653
|
+
if not jsonl_file:
|
|
654
|
+
print(json.dumps({"error": "No JSONL file path provided"}), flush=True)
|
|
655
|
+
sys.exit(1)
|
|
656
|
+
|
|
657
|
+
def parse_jsonl_file(path):
|
|
658
|
+
"""Parse a Claude Code JSONL session file. Emits one JSON event per line."""
|
|
659
|
+
try:
|
|
660
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
661
|
+
lines = f.readlines()
|
|
662
|
+
except (OSError, IOError) as e:
|
|
663
|
+
print(json.dumps({"error": f"Cannot open {path}: {e}"}), flush=True)
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
for lineno, raw in enumerate(lines, 1):
|
|
667
|
+
raw = raw.strip()
|
|
668
|
+
if not raw:
|
|
669
|
+
continue
|
|
670
|
+
try:
|
|
671
|
+
msg = json.loads(raw)
|
|
672
|
+
except json.JSONDecodeError:
|
|
673
|
+
# Malformed line — skip silently (schema resilience rule)
|
|
674
|
+
continue
|
|
675
|
+
|
|
676
|
+
msg_type = msg.get("type", "")
|
|
677
|
+
timestamp = msg.get("timestamp", "")
|
|
678
|
+
session_id = msg.get("sessionId", "")
|
|
679
|
+
is_subagent = bool(msg.get("isSidechain", False))
|
|
680
|
+
agent_id = msg.get("agentId")
|
|
681
|
+
|
|
682
|
+
# Base event skeleton — only structural fields, no content
|
|
683
|
+
base = {
|
|
684
|
+
"timestamp": timestamp,
|
|
685
|
+
"session_id": session_id,
|
|
686
|
+
"is_subagent": is_subagent,
|
|
687
|
+
"lineno": lineno,
|
|
688
|
+
}
|
|
689
|
+
if agent_id:
|
|
690
|
+
base["agent_id"] = agent_id
|
|
691
|
+
|
|
692
|
+
if msg_type == "assistant":
|
|
693
|
+
# Extract tool_use blocks from assistant message content
|
|
694
|
+
content = msg.get("message", {}).get("content", [])
|
|
695
|
+
if not isinstance(content, list):
|
|
696
|
+
content = []
|
|
697
|
+
for block in content:
|
|
698
|
+
if not isinstance(block, dict):
|
|
699
|
+
continue
|
|
700
|
+
block_type = block.get("type", "")
|
|
701
|
+
if block_type == "tool_use":
|
|
702
|
+
tool_name = block.get("name", "")
|
|
703
|
+
tool_input = block.get("input", {}) or {}
|
|
704
|
+
|
|
705
|
+
if tool_name == "Skill":
|
|
706
|
+
# Skill invocation — extract skill name and args (structural only)
|
|
707
|
+
event = {**base,
|
|
708
|
+
"event_type": "skill",
|
|
709
|
+
"tool_name": "Skill",
|
|
710
|
+
"skill_name": tool_input.get("skill", ""),
|
|
711
|
+
"skill_args": tool_input.get("args", ""),
|
|
712
|
+
}
|
|
713
|
+
elif tool_name == "Agent":
|
|
714
|
+
# Agent spawn — extract description and model (no prompt content)
|
|
715
|
+
event = {**base,
|
|
716
|
+
"event_type": "agent_spawn",
|
|
717
|
+
"tool_name": "Agent",
|
|
718
|
+
"agent_desc": tool_input.get("description", ""),
|
|
719
|
+
"agent_model": tool_input.get("model", ""),
|
|
720
|
+
}
|
|
721
|
+
else:
|
|
722
|
+
# All other tool calls — name only, no input content
|
|
723
|
+
event = {**base,
|
|
724
|
+
"event_type": "tool_use",
|
|
725
|
+
"tool_name": tool_name,
|
|
726
|
+
}
|
|
727
|
+
print(json.dumps(event), flush=True)
|
|
728
|
+
# text and thinking blocks: skip (raw content — privacy rule)
|
|
729
|
+
|
|
730
|
+
elif msg_type == "system":
|
|
731
|
+
subtype = msg.get("subtype", "")
|
|
732
|
+
if subtype == "turn_duration":
|
|
733
|
+
# Authoritative phase timing signal — use durationMs, NOT timestamp arithmetic
|
|
734
|
+
event = {**base,
|
|
735
|
+
"event_type": "turn_duration",
|
|
736
|
+
"duration_ms": msg.get("durationMs", 0),
|
|
737
|
+
"parent_uuid": msg.get("parentUuid", ""),
|
|
738
|
+
}
|
|
739
|
+
print(json.dumps(event), flush=True)
|
|
740
|
+
elif subtype == "compact_boundary":
|
|
741
|
+
# Context compaction marker
|
|
742
|
+
compact_meta = msg.get("compactMetadata", {}) or {}
|
|
743
|
+
event = {**base,
|
|
744
|
+
"event_type": "compact",
|
|
745
|
+
"pre_tokens": compact_meta.get("preTokens", 0),
|
|
746
|
+
"trigger": compact_meta.get("trigger", ""),
|
|
747
|
+
}
|
|
748
|
+
print(json.dumps(event), flush=True)
|
|
749
|
+
# local_command and other system subtypes: skip (content-bearing)
|
|
750
|
+
|
|
751
|
+
elif msg_type == "user":
|
|
752
|
+
# user messages: only emit session-start marker for first message (parentUuid=null)
|
|
753
|
+
if msg.get("parentUuid") is None and not is_subagent:
|
|
754
|
+
event = {**base,
|
|
755
|
+
"event_type": "session_start",
|
|
756
|
+
"is_meta": bool(msg.get("isMeta", False)),
|
|
757
|
+
}
|
|
758
|
+
print(json.dumps(event), flush=True)
|
|
759
|
+
|
|
760
|
+
elif msg_type == "progress":
|
|
761
|
+
# Agent progress — only structural signal (agentId, parentToolUseID)
|
|
762
|
+
data = msg.get("data", {}) or {}
|
|
763
|
+
if data.get("type") == "agent_progress":
|
|
764
|
+
event = {**base,
|
|
765
|
+
"event_type": "agent_progress",
|
|
766
|
+
"progress_agent_id": data.get("agentId", ""),
|
|
767
|
+
"parent_tool_use_id": msg.get("parentToolUseID", ""),
|
|
768
|
+
}
|
|
769
|
+
print(json.dumps(event), flush=True)
|
|
770
|
+
# hook_progress: skip
|
|
771
|
+
|
|
772
|
+
elif msg_type in ("queue-operation", "pr-link", "last-prompt", "file-history-snapshot"):
|
|
773
|
+
# Known non-analytics types — skip silently
|
|
774
|
+
pass
|
|
775
|
+
|
|
776
|
+
else:
|
|
777
|
+
# Unknown type — emit as unknown_type for forward-compatibility diagnostics
|
|
778
|
+
event = {**base,
|
|
779
|
+
"event_type": "unknown_type",
|
|
780
|
+
"type": msg_type,
|
|
781
|
+
}
|
|
782
|
+
print(json.dumps(event), flush=True)
|
|
783
|
+
|
|
784
|
+
parse_jsonl_file(jsonl_file)
|
|
785
|
+
PYEOF
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
**Calling convention**: Pass the JSONL file path as the first argument:
|
|
789
|
+
```bash
|
|
790
|
+
python3 -c "..." "$JSONL_FILE" # inline version (for short scripts)
|
|
791
|
+
python3 - "$JSONL_FILE" << 'PYEOF' # heredoc version — '-' reads from stdin, arg follows
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
**Output format** (NDJSON — one JSON object per line):
|
|
795
|
+
|
|
796
|
+
| `event_type` | Fields emitted | Source |
|
|
797
|
+
|---|---|---|
|
|
798
|
+
| `tool_use` | `tool_name`, `timestamp`, `session_id`, `is_subagent` | `assistant` content `tool_use` blocks |
|
|
799
|
+
| `skill` | `tool_name="Skill"`, `skill_name`, `skill_args`, `timestamp`, `is_subagent` | `assistant` content where `name=="Skill"` |
|
|
800
|
+
| `agent_spawn` | `tool_name="Agent"`, `agent_desc`, `agent_model`, `timestamp`, `is_subagent` | `assistant` content where `name=="Agent"` |
|
|
801
|
+
| `turn_duration` | `duration_ms`, `parent_uuid`, `timestamp` | `system {subtype: "turn_duration"}` |
|
|
802
|
+
| `compact` | `pre_tokens`, `trigger`, `timestamp` | `system {subtype: "compact_boundary"}` |
|
|
803
|
+
| `session_start` | `is_meta`, `timestamp` | first `user` message (`parentUuid=null`) |
|
|
804
|
+
| `agent_progress` | `progress_agent_id`, `parent_tool_use_id`, `timestamp` | `progress {data.type: "agent_progress"}` |
|
|
805
|
+
| `unknown_type` | `type` | any unrecognized `type` value |
|
|
806
|
+
|
|
807
|
+
**Schema resilience rules**:
|
|
808
|
+
1. `msg.get('field')` throughout — absent fields return `None`, never crash
|
|
809
|
+
2. Malformed JSON lines are silently skipped (try/except per line)
|
|
810
|
+
3. Unknown `type` values emit `unknown_type` event — not silently dropped
|
|
811
|
+
4. Both `usage` variants handled (parser doesn't touch `usage` — structural extraction only)
|
|
812
|
+
5. `is_subagent` populated from `isSidechain` field, not filename heuristic
|
|
813
|
+
|
|
814
|
+
**Subagent detection**:
|
|
815
|
+
- `isSidechain: true` → `is_subagent: true` in all events from that file
|
|
816
|
+
- `agentId` field present → `agent_id` field in output
|
|
817
|
+
- Subagent files located at: `~/.claude/projects/{project}/{sessionId}/subagents/agent-{agentId}.jsonl`
|
|
818
|
+
|
|
819
|
+
**Worktree session detection** (for session discovery phases):
|
|
820
|
+
```bash
|
|
821
|
+
# Project directory name regex to detect worktree sessions
|
|
822
|
+
echo "$PROJECT_DIR" | grep -qP '^-.*--claude-worktrees-.*$' && echo "worktree" || echo "main"
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
---
|
|
826
|
+
|
|
827
|
+
### 2N: Transcript flow metrics (session efficiency signal)
|
|
828
|
+
|
|
829
|
+
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.
|
|
830
|
+
|
|
831
|
+
**Prerequisite**: Phase 2M must have run. If `SESSION_TOTAL == 0`, skip this phase and set `TRANSCRIPT_DATA_AVAILABLE=false`.
|
|
832
|
+
|
|
833
|
+
**This phase reads local disk only — no GitHub API calls.**
|
|
834
|
+
|
|
835
|
+
**Step 1 — Check prerequisite and select files to parse**:
|
|
836
|
+
|
|
837
|
+
```bash
|
|
838
|
+
if [ "${SESSION_TOTAL:-0}" -eq 0 ]; then
|
|
839
|
+
echo "Phase 2N: SESSION_TOTAL=0 (Phase 2M found no sessions or history.jsonl missing) — skipping"
|
|
840
|
+
TRANSCRIPT_DATA_AVAILABLE=false
|
|
841
|
+
else
|
|
842
|
+
TRANSCRIPT_DATA_AVAILABLE=true
|
|
843
|
+
|
|
844
|
+
# Performance guard: cap at 50 sessions to bound parse time.
|
|
845
|
+
# If SESSION_TOTAL > 50: select 30 most recent + 10 largest (by JSONL file size) + 10 random from remainder.
|
|
846
|
+
# Deduplication is implicit — most-recent and largest sets are drawn from the same array without replacement.
|
|
847
|
+
if [ "${SESSION_TOTAL}" -gt 50 ]; then
|
|
848
|
+
echo "Phase 2N: $SESSION_TOTAL sessions found — sampling 50 (30 most recent + 10 largest + 10 random) for performance"
|
|
849
|
+
|
|
850
|
+
# SESSION_JSONL_FILES is a bash array; each element: sessionId\tcommand\ttimestamp\tjsonlPath\tsubagentDir
|
|
851
|
+
# Step A: sort by timestamp (field 3) descending → pick first 30
|
|
852
|
+
SORTED_BY_TIME=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}" | sort -t$'\t' -k3,3 -r)
|
|
853
|
+
RECENT_30=$(echo "$SORTED_BY_TIME" | head -30)
|
|
854
|
+
RECENT_IDS=$(echo "$RECENT_30" | cut -f1)
|
|
855
|
+
|
|
856
|
+
# Step B: from remaining entries (not in RECENT_IDS), sort by JSONL file size → pick 10 largest
|
|
857
|
+
REMAINDER=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}" | grep -vFf <(echo "$RECENT_IDS") 2>/dev/null || true)
|
|
858
|
+
LARGEST_10=""
|
|
859
|
+
if [ -n "$REMAINDER" ]; then
|
|
860
|
+
LARGEST_10=$(echo "$REMAINDER" | while IFS=$'\t' read -r SID CMD TS JP SD; do
|
|
861
|
+
SZ=$(stat -c%s "$JP" 2>/dev/null || echo 0)
|
|
862
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$SZ" "$SID" "$CMD" "$TS" "$JP" "$SD"
|
|
863
|
+
done | sort -rn | head -10 | cut -f2-)
|
|
864
|
+
LARGEST_IDS=$(echo "$LARGEST_10" | cut -f1)
|
|
865
|
+
fi
|
|
866
|
+
|
|
867
|
+
# Step C: from entries not yet selected, pick 10 at random
|
|
868
|
+
EXCLUDED=$(printf '%s\n%s\n' "$RECENT_IDS" "${LARGEST_IDS:-}")
|
|
869
|
+
POOL=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}" | grep -vFf <(echo "$EXCLUDED") 2>/dev/null || true)
|
|
870
|
+
RANDOM_10=""
|
|
871
|
+
if [ -n "$POOL" ]; then
|
|
872
|
+
RANDOM_10=$(echo "$POOL" | shuf | head -10)
|
|
873
|
+
fi
|
|
874
|
+
|
|
875
|
+
# Combine and rebuild SESSION_JSONL_FILES (deduplicated)
|
|
876
|
+
COMBINED=$(printf '%s\n%s\n%s\n' "$RECENT_30" "${LARGEST_10:-}" "${RANDOM_10:-}" | grep -v '^$' | sort -u)
|
|
877
|
+
SESSION_TOTAL_ORIGINAL=$SESSION_TOTAL
|
|
878
|
+
SESSION_JSONL_FILES=()
|
|
879
|
+
while IFS= read -r ENTRY; do
|
|
880
|
+
[ -z "$ENTRY" ] && continue
|
|
881
|
+
SESSION_JSONL_FILES+=("$ENTRY")
|
|
882
|
+
done <<< "$COMBINED"
|
|
883
|
+
SESSION_TOTAL=${#SESSION_JSONL_FILES[@]}
|
|
884
|
+
echo "Phase 2N: sampled $SESSION_TOTAL of $SESSION_TOTAL_ORIGINAL sessions (30 most recent + 10 largest + 10 random)"
|
|
885
|
+
else
|
|
886
|
+
echo "Phase 2N: parsing $SESSION_TOTAL session JSONL files for flow metrics"
|
|
887
|
+
SESSION_TOTAL_ORIGINAL=$SESSION_TOTAL
|
|
888
|
+
fi
|
|
889
|
+
fi
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
**Step 2 — Parse each session and aggregate metrics (inline Python)**:
|
|
893
|
+
|
|
894
|
+
**IMPORTANT**: Run as a single Python script. JSONL files can be large (500+ lines, 100KB+). Budget: 2 minutes total.
|
|
895
|
+
|
|
896
|
+
```python
|
|
897
|
+
import json, sys, os
|
|
898
|
+
from collections import defaultdict
|
|
899
|
+
|
|
900
|
+
# Read SESSION_JSONL_FILES from Phase 2M environment
|
|
901
|
+
# Each entry is a tab-separated record: sessionId \t command \t timestamp \t jsonlPath \t subagentDir
|
|
902
|
+
session_records_raw = os.environ.get('SESSION_JSONL_FILES_RAW', '')
|
|
903
|
+
|
|
904
|
+
# Aggregate accumulators
|
|
905
|
+
all_durations_ms = [] # from turn_duration events
|
|
906
|
+
total_tool_counts = defaultdict(int) # tool_name → call count
|
|
907
|
+
total_compactions = 0 # compact_boundary events
|
|
908
|
+
total_stall_turns = 0 # turns > 120000 ms
|
|
909
|
+
total_rate_limit_turns = 0 # turns 60000–120000 ms (possible rate-limit)
|
|
910
|
+
parent_session_count = 0
|
|
911
|
+
subagent_session_count = 0
|
|
912
|
+
parse_errors = 0
|
|
913
|
+
|
|
914
|
+
def process_jsonl_file(jsonl_path, is_subagent=False):
|
|
915
|
+
"""Parse one JSONL file using the JSONL Parser Utility event model."""
|
|
916
|
+
global total_compactions, total_stall_turns, total_rate_limit_turns, parse_errors
|
|
917
|
+
|
|
918
|
+
try:
|
|
919
|
+
with open(jsonl_path, encoding='utf-8', errors='replace') as f:
|
|
920
|
+
for line in f:
|
|
921
|
+
line = line.strip()
|
|
922
|
+
if not line:
|
|
923
|
+
continue
|
|
924
|
+
try:
|
|
925
|
+
msg = json.loads(line)
|
|
926
|
+
except json.JSONDecodeError:
|
|
927
|
+
parse_errors += 1
|
|
928
|
+
continue
|
|
929
|
+
|
|
930
|
+
msg_type = msg.get('type', '')
|
|
931
|
+
file_is_subagent = bool(msg.get('isSidechain', False)) or is_subagent
|
|
932
|
+
|
|
933
|
+
if msg_type == 'assistant':
|
|
934
|
+
# Tool call frequency
|
|
935
|
+
content = msg.get('message', {}).get('content', [])
|
|
936
|
+
if isinstance(content, list):
|
|
937
|
+
for block in content:
|
|
938
|
+
if isinstance(block, dict) and block.get('type') == 'tool_use':
|
|
939
|
+
total_tool_counts[block.get('name', 'unknown')] += 1
|
|
940
|
+
|
|
941
|
+
elif msg_type == 'system':
|
|
942
|
+
subtype = msg.get('subtype', '')
|
|
943
|
+
if subtype == 'turn_duration':
|
|
944
|
+
dur = msg.get('durationMs')
|
|
945
|
+
if isinstance(dur, (int, float)) and dur > 0:
|
|
946
|
+
all_durations_ms.append(dur)
|
|
947
|
+
if dur > 120000:
|
|
948
|
+
total_stall_turns += 1
|
|
949
|
+
elif dur > 60000:
|
|
950
|
+
total_rate_limit_turns += 1
|
|
951
|
+
elif subtype == 'compact_boundary':
|
|
952
|
+
total_compactions += 1
|
|
953
|
+
|
|
954
|
+
# Unknown types: silently skip (schema resilience)
|
|
955
|
+
|
|
956
|
+
except (IOError, OSError):
|
|
957
|
+
parse_errors += 1
|
|
958
|
+
|
|
959
|
+
# Process each session from SESSION_JSONL_FILES_RAW
|
|
960
|
+
for record in session_records_raw.strip().split('\n'):
|
|
961
|
+
if not record.strip():
|
|
962
|
+
continue
|
|
963
|
+
parts = record.split('\t')
|
|
964
|
+
if len(parts) < 4:
|
|
965
|
+
continue
|
|
966
|
+
session_id, command, timestamp, jsonl_path = parts[0], parts[1], parts[2], parts[3]
|
|
967
|
+
subagent_dir = parts[4] if len(parts) > 4 else ''
|
|
968
|
+
|
|
969
|
+
# Expand ~ in path
|
|
970
|
+
jsonl_path = os.path.expanduser(jsonl_path)
|
|
971
|
+
if not os.path.isfile(jsonl_path):
|
|
972
|
+
continue
|
|
973
|
+
|
|
974
|
+
parent_session_count += 1
|
|
975
|
+
process_jsonl_file(jsonl_path, is_subagent=False)
|
|
976
|
+
|
|
977
|
+
# Also parse subagent files in the same session directory
|
|
978
|
+
if subagent_dir:
|
|
979
|
+
sub_dir = os.path.expanduser(os.path.join(os.path.dirname(jsonl_path), session_id, 'subagents'))
|
|
980
|
+
if os.path.isdir(sub_dir):
|
|
981
|
+
for fname in os.listdir(sub_dir):
|
|
982
|
+
if fname.startswith('agent-') and fname.endswith('.jsonl'):
|
|
983
|
+
subagent_session_count += 1
|
|
984
|
+
process_jsonl_file(os.path.join(sub_dir, fname), is_subagent=True)
|
|
985
|
+
|
|
986
|
+
# Output aggregated metrics
|
|
987
|
+
total_turns = len(all_durations_ms)
|
|
988
|
+
avg_duration_ms = sum(all_durations_ms) / len(all_durations_ms) if all_durations_ms else 0
|
|
989
|
+
max_duration_ms = max(all_durations_ms) if all_durations_ms else 0
|
|
990
|
+
stall_rate = (total_stall_turns / total_turns * 100) if total_turns > 0 else 0
|
|
991
|
+
|
|
992
|
+
print(f"\n=== Phase 2N: Transcript Flow Metrics ===")
|
|
993
|
+
print(f"Pipeline sessions parsed: {parent_session_count} parent, {subagent_session_count} subagent")
|
|
994
|
+
print(f"Total turns with timing: {total_turns}")
|
|
995
|
+
print(f"Avg turn duration: {avg_duration_ms/1000:.1f}s")
|
|
996
|
+
print(f"Max turn duration: {max_duration_ms/1000:.1f}s")
|
|
997
|
+
print(f"Stall turns (>120s): {total_stall_turns} ({stall_rate:.1f}% of turns) — target: < 10%")
|
|
998
|
+
print(f"Rate-limit indicator turns (60–120s): {total_rate_limit_turns}")
|
|
999
|
+
print(f"Context compaction events: {total_compactions}")
|
|
1000
|
+
print()
|
|
1001
|
+
print("Tool call frequency (top 10):")
|
|
1002
|
+
for tool, count in sorted(total_tool_counts.items(), key=lambda x: -x[1])[:10]:
|
|
1003
|
+
print(f" {tool}: {count}")
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
To run this step:
|
|
1007
|
+
|
|
1008
|
+
```bash
|
|
1009
|
+
if [ "$TRANSCRIPT_DATA_AVAILABLE" = "true" ]; then
|
|
1010
|
+
# Serialize SESSION_JSONL_FILES array to newline-separated string for Python
|
|
1011
|
+
export SESSION_JSONL_FILES_RAW
|
|
1012
|
+
SESSION_JSONL_FILES_RAW=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}")
|
|
1013
|
+
PHASE_2N_OUTPUT=$(python3 - << 'PYEOF'
|
|
1014
|
+
# [paste the Python script above here at runtime]
|
|
1015
|
+
PYEOF
|
|
1016
|
+
)
|
|
1017
|
+
echo "$PHASE_2N_OUTPUT"
|
|
1018
|
+
|
|
1019
|
+
# Extract bash variables from Phase 2N output for use in Phase 3C health score
|
|
1020
|
+
# "Stall turns (>120s): N (X.Y% of turns) — target: < 10%"
|
|
1021
|
+
STALL_TURN_RATE=$(echo "$PHASE_2N_OUTPUT" | grep -oP 'Stall turns \(>120s\): \d+ \(\K[\d.]+(?=% of turns)')
|
|
1022
|
+
STALL_TURN_RATE=${STALL_TURN_RATE:-0}
|
|
1023
|
+
# "Context compaction events: N"
|
|
1024
|
+
COMPACTION_TOTAL=$(echo "$PHASE_2N_OUTPUT" | grep -oP 'Context compaction events: \K\d+')
|
|
1025
|
+
COMPACTION_TOTAL=${COMPACTION_TOTAL:-0}
|
|
1026
|
+
export STALL_TURN_RATE COMPACTION_TOTAL
|
|
1027
|
+
echo "Phase 2N exports: STALL_TURN_RATE=$STALL_TURN_RATE COMPACTION_TOTAL=$COMPACTION_TOTAL"
|
|
1028
|
+
fi
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
**Metrics produced**:
|
|
1032
|
+
- **Pipeline sessions in window**: sessions confirmed in Phase 2M that actually ran a pipeline command (`work-on`, `orchestrate`, `review-pr`, etc.)
|
|
1033
|
+
- **Avg turn duration (ms)**: mean of all `system.turn_duration.durationMs` values. Uses `durationMs` directly — NOT timestamp arithmetic (timestamps are non-monotonic).
|
|
1034
|
+
- **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.
|
|
1035
|
+
- **Rate-limit indicator turns**: turns 60–120s — possible but not certain rate-limit waits. Informational.
|
|
1036
|
+
- **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.
|
|
1037
|
+
- **Tool call frequency**: which tools dominate. Reveals whether agents are read-heavy (Bash/Read/Grep) vs coordination-heavy (Skill/Agent).
|
|
1038
|
+
|
|
1039
|
+
**Skip condition**: If `TRANSCRIPT_DATA_AVAILABLE` is false, emit:
|
|
1040
|
+
> `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.`
|
|
1041
|
+
|
|
1042
|
+
---
|
|
1043
|
+
|
|
1044
|
+
### 2O: Per-session metric extraction (structured session metrics)
|
|
1045
|
+
|
|
1046
|
+
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.
|
|
1047
|
+
|
|
1048
|
+
**Prerequisite**: Phase 2N must have run. If `TRANSCRIPT_DATA_AVAILABLE != true`, skip this phase.
|
|
1049
|
+
|
|
1050
|
+
**This phase reads local disk only — no GitHub API calls.**
|
|
1051
|
+
|
|
1052
|
+
**Metrics per session (HIGH and MEDIUM actionability only per #347)**:
|
|
1053
|
+
- `total_duration_ms` — sum of all `system.turn_duration.durationMs` values (active computation time; NOT wall-clock)
|
|
1054
|
+
- `tool_call_counts` — dict of tool_name → count for all tool_use blocks
|
|
1055
|
+
- `skill_invocations` — list of `{phase_name, skill_name, skill_args}` in call order
|
|
1056
|
+
- `agent_spawns` — list of `{agent_desc, model}` for each Agent tool call
|
|
1057
|
+
- `model_distribution` — dict of model_name → count (from `message.model` on assistant messages; proxy for sonnet/opus fallback pressure)
|
|
1058
|
+
- `compaction_count` — count of `system {subtype: compact_boundary}` events
|
|
1059
|
+
|
|
1060
|
+
**NOT included** (SKIP tier from #347): wall-clock duration, rate-limit event detection.
|
|
1061
|
+
|
|
1062
|
+
**Step 1 — Check prerequisite**:
|
|
1063
|
+
|
|
1064
|
+
```bash
|
|
1065
|
+
if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" != "true" ]; then
|
|
1066
|
+
echo "Phase 2O: TRANSCRIPT_DATA_AVAILABLE=false (Phase 2N skipped or no sessions) — skipping"
|
|
1067
|
+
PER_SESSION_METRICS_RAW=""
|
|
1068
|
+
else
|
|
1069
|
+
echo "Phase 2O: extracting per-session metrics from $SESSION_TOTAL sessions"
|
|
1070
|
+
fi
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
**Step 2 — Parse each session and emit structured NDJSON (inline Python)**:
|
|
1074
|
+
|
|
1075
|
+
```python
|
|
1076
|
+
import json, sys, os
|
|
1077
|
+
from collections import defaultdict
|
|
1078
|
+
|
|
1079
|
+
# Skill name → pipeline phase name mapping
|
|
1080
|
+
SKILL_PHASE_MAP = {
|
|
1081
|
+
'work-on': 'work-on',
|
|
1082
|
+
'quality-gate': 'build:validate',
|
|
1083
|
+
'review-pr': 'review',
|
|
1084
|
+
'review-pr-staging':'review',
|
|
1085
|
+
'cleanup': 'close',
|
|
1086
|
+
'orchestrate': 'orchestrate',
|
|
1087
|
+
'frontend-design': 'build:implement',
|
|
1088
|
+
'validate': 'validate',
|
|
1089
|
+
'audit': 'audit',
|
|
1090
|
+
'pipeline-health': 'pipeline-health',
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
def parse_session_jsonl(jsonl_path):
|
|
1094
|
+
"""Parse one JSONL file and return per-session metric dict."""
|
|
1095
|
+
metrics = {
|
|
1096
|
+
'total_duration_ms': 0,
|
|
1097
|
+
'tool_call_counts': defaultdict(int),
|
|
1098
|
+
'skill_invocations': [], # [{phase_name, skill_name, skill_args}]
|
|
1099
|
+
'agent_spawns': [], # [{agent_desc, model}]
|
|
1100
|
+
'model_distribution': defaultdict(int), # model_name → count
|
|
1101
|
+
'compaction_count': 0,
|
|
1102
|
+
'parse_errors': 0,
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
try:
|
|
1106
|
+
with open(jsonl_path, encoding='utf-8', errors='replace') as f:
|
|
1107
|
+
for line in f:
|
|
1108
|
+
line = line.strip()
|
|
1109
|
+
if not line:
|
|
1110
|
+
continue
|
|
1111
|
+
try:
|
|
1112
|
+
msg = json.loads(line)
|
|
1113
|
+
except json.JSONDecodeError:
|
|
1114
|
+
metrics['parse_errors'] += 1
|
|
1115
|
+
continue
|
|
1116
|
+
|
|
1117
|
+
msg_type = msg.get('type', '')
|
|
1118
|
+
|
|
1119
|
+
if msg_type == 'assistant':
|
|
1120
|
+
# Track model distribution (SAFE field per #347)
|
|
1121
|
+
model = msg.get('message', {}).get('model', '')
|
|
1122
|
+
if model:
|
|
1123
|
+
metrics['model_distribution'][model] += 1
|
|
1124
|
+
|
|
1125
|
+
# Extract tool_use blocks
|
|
1126
|
+
content = msg.get('message', {}).get('content', [])
|
|
1127
|
+
if not isinstance(content, list):
|
|
1128
|
+
content = []
|
|
1129
|
+
for block in content:
|
|
1130
|
+
if not isinstance(block, dict):
|
|
1131
|
+
continue
|
|
1132
|
+
if block.get('type') != 'tool_use':
|
|
1133
|
+
continue
|
|
1134
|
+
tool_name = block.get('name', 'unknown')
|
|
1135
|
+
tool_input = block.get('input', {}) or {}
|
|
1136
|
+
|
|
1137
|
+
# Count all tool calls by name
|
|
1138
|
+
metrics['tool_call_counts'][tool_name] += 1
|
|
1139
|
+
|
|
1140
|
+
if tool_name == 'Skill':
|
|
1141
|
+
# Map skill name to pipeline phase
|
|
1142
|
+
skill_name = tool_input.get('skill', '')
|
|
1143
|
+
skill_args = tool_input.get('args', '')
|
|
1144
|
+
phase_name = SKILL_PHASE_MAP.get(skill_name, 'other')
|
|
1145
|
+
metrics['skill_invocations'].append({
|
|
1146
|
+
'phase_name': phase_name,
|
|
1147
|
+
'skill_name': skill_name,
|
|
1148
|
+
'skill_args': str(skill_args)[:80], # truncate — no raw content
|
|
1149
|
+
})
|
|
1150
|
+
elif tool_name == 'Agent':
|
|
1151
|
+
# Extract description and model only — NOT prompt content (UNSAFE per #347)
|
|
1152
|
+
metrics['agent_spawns'].append({
|
|
1153
|
+
'agent_desc': str(tool_input.get('description', ''))[:120],
|
|
1154
|
+
'model': tool_input.get('model', ''),
|
|
1155
|
+
})
|
|
1156
|
+
|
|
1157
|
+
elif msg_type == 'system':
|
|
1158
|
+
subtype = msg.get('subtype', '')
|
|
1159
|
+
if subtype == 'turn_duration':
|
|
1160
|
+
dur = msg.get('durationMs')
|
|
1161
|
+
if isinstance(dur, (int, float)) and dur > 0:
|
|
1162
|
+
metrics['total_duration_ms'] += dur
|
|
1163
|
+
elif subtype == 'compact_boundary':
|
|
1164
|
+
metrics['compaction_count'] += 1
|
|
1165
|
+
|
|
1166
|
+
# All other message types: skip (content-bearing or irrelevant)
|
|
1167
|
+
|
|
1168
|
+
except (IOError, OSError):
|
|
1169
|
+
metrics['parse_errors'] += 1
|
|
1170
|
+
|
|
1171
|
+
return metrics
|
|
1172
|
+
|
|
1173
|
+
# Read SESSION_JSONL_FILES from Phase 2M
|
|
1174
|
+
session_records_raw = os.environ.get('SESSION_JSONL_FILES_RAW', '')
|
|
1175
|
+
|
|
1176
|
+
session_results = []
|
|
1177
|
+
total_sessions = 0
|
|
1178
|
+
print(f"\n=== Phase 2O: Per-Session Metric Extraction ===")
|
|
1179
|
+
|
|
1180
|
+
for record in session_records_raw.strip().split('\n'):
|
|
1181
|
+
if not record.strip():
|
|
1182
|
+
continue
|
|
1183
|
+
parts = record.split('\t')
|
|
1184
|
+
if len(parts) < 4:
|
|
1185
|
+
continue
|
|
1186
|
+
session_id = parts[0]
|
|
1187
|
+
command = parts[1]
|
|
1188
|
+
timestamp = parts[2]
|
|
1189
|
+
jsonl_path = os.path.expanduser(parts[3])
|
|
1190
|
+
|
|
1191
|
+
if not os.path.isfile(jsonl_path):
|
|
1192
|
+
continue
|
|
1193
|
+
|
|
1194
|
+
total_sessions += 1
|
|
1195
|
+
m = parse_session_jsonl(jsonl_path)
|
|
1196
|
+
|
|
1197
|
+
# Serialize to JSON-safe dict (convert defaultdicts)
|
|
1198
|
+
session_record = {
|
|
1199
|
+
'session_id': session_id,
|
|
1200
|
+
'command': command,
|
|
1201
|
+
'timestamp': timestamp,
|
|
1202
|
+
'total_duration_ms': m['total_duration_ms'],
|
|
1203
|
+
'tool_call_counts': dict(m['tool_call_counts']),
|
|
1204
|
+
'skill_invocations': m['skill_invocations'],
|
|
1205
|
+
'agent_spawns': m['agent_spawns'],
|
|
1206
|
+
'model_distribution': dict(m['model_distribution']),
|
|
1207
|
+
'compaction_count': m['compaction_count'],
|
|
1208
|
+
'parse_errors': m['parse_errors'],
|
|
1209
|
+
}
|
|
1210
|
+
session_results.append(session_record)
|
|
1211
|
+
# Emit as NDJSON line (consumed by downstream phases via PER_SESSION_METRICS_RAW)
|
|
1212
|
+
print(json.dumps(session_record), flush=True)
|
|
1213
|
+
|
|
1214
|
+
# Print summary table
|
|
1215
|
+
print(f"\nParsed {total_sessions} sessions.")
|
|
1216
|
+
print(f"{'Session':<20} {'Command':<16} {'DurationS':>9} {'Tools':>6} {'Skills':>7} {'Agents':>7} {'Compact':>8} {'Sonnet/Opus':>12}")
|
|
1217
|
+
print("-" * 92)
|
|
1218
|
+
for s in session_results:
|
|
1219
|
+
dur_s = s['total_duration_ms'] / 1000
|
|
1220
|
+
tools = sum(s['tool_call_counts'].values())
|
|
1221
|
+
skills = len(s['skill_invocations'])
|
|
1222
|
+
agents = len(s['agent_spawns'])
|
|
1223
|
+
compact = s['compaction_count']
|
|
1224
|
+
sonnet = sum(v for k, v in s['model_distribution'].items() if 'sonnet' in k.lower())
|
|
1225
|
+
opus = sum(v for k, v in s['model_distribution'].items() if 'opus' in k.lower())
|
|
1226
|
+
sid_short = s['session_id'][:18]
|
|
1227
|
+
cmd_short = s['command'][:14]
|
|
1228
|
+
print(f"{sid_short:<20} {cmd_short:<16} {dur_s:>9.1f} {tools:>6} {skills:>7} {agents:>7} {compact:>8} {sonnet:>5}/{opus:<5}")
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
To run this step:
|
|
1232
|
+
|
|
1233
|
+
```bash
|
|
1234
|
+
if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" = "true" ]; then
|
|
1235
|
+
export SESSION_JSONL_FILES_RAW
|
|
1236
|
+
SESSION_JSONL_FILES_RAW=$(printf '%s\n' "${SESSION_JSONL_FILES[@]}")
|
|
1237
|
+
PER_SESSION_METRICS_RAW=$(python3 - << 'PYEOF'
|
|
1238
|
+
# [paste the Python script above here at runtime]
|
|
1239
|
+
PYEOF
|
|
1240
|
+
)
|
|
1241
|
+
echo "$PER_SESSION_METRICS_RAW"
|
|
1242
|
+
export PER_SESSION_METRICS_RAW
|
|
1243
|
+
fi
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
**Metrics produced per session**:
|
|
1247
|
+
- **`total_duration_ms`**: sum of `system.turn_duration.durationMs` — active agent compute time, excludes human idle. Use this; NOT wall-clock timestamps.
|
|
1248
|
+
- **`tool_call_counts`**: dict of every tool name called and how many times. Bash:Edit ratio reveals investigator vs builder behaviour.
|
|
1249
|
+
- **`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.
|
|
1250
|
+
- **`agent_spawns`**: list of Agent tool calls with model used. Reveals subagent spawning patterns and model choices.
|
|
1251
|
+
- **`model_distribution`**: sonnet vs opus request count. Proxy for API fallback pressure — a session with many opus requests may have experienced rate-limiting.
|
|
1252
|
+
- **`compaction_count`**: per-session context compaction events. >1 = session spanned multiple context windows.
|
|
1253
|
+
|
|
1254
|
+
**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.
|
|
1255
|
+
|
|
1256
|
+
**Skip condition**: If `TRANSCRIPT_DATA_AVAILABLE` is false, emit:
|
|
1257
|
+
> `Phase 2O: Per-session metrics unavailable — Phase 2N did not produce session data. SESSION_TOTAL must be > 0 for this phase to run.`
|
|
1258
|
+
|
|
1259
|
+
---
|
|
1260
|
+
|
|
1261
|
+
### 2P: Subagent flow analysis (role-mapped efficiency signal)
|
|
1262
|
+
|
|
1263
|
+
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.
|
|
1264
|
+
|
|
1265
|
+
**Prerequisite**: Phase 2N must have run. If `TRANSCRIPT_DATA_AVAILABLE` is false, skip this phase.
|
|
1266
|
+
|
|
1267
|
+
**This phase reads local disk only — no GitHub API calls.**
|
|
1268
|
+
|
|
1269
|
+
**Role mapping rules** (applied to `agent_desc` from `agent_spawn` events):
|
|
1270
|
+
|
|
1271
|
+
| Pattern | Mapped Role |
|
|
1272
|
+
|---------|-------------|
|
|
1273
|
+
| `^(Investigate|investigate)\s+` | `investigate` |
|
|
1274
|
+
| `^(Work on|work on|Resume #\d+ build|Continue #\d+ build)\s+` | `build` |
|
|
1275
|
+
| `^(Review|review|Resume.*review|Continue.*review)\s*` | `review` |
|
|
1276
|
+
| `^Review\+Close\s+` | `review` |
|
|
1277
|
+
| `^(Resume #\d+ close|Continue #\d+ close)\s*` | `close` |
|
|
1278
|
+
| `^Quality gate\s*` | `quality-gate` |
|
|
1279
|
+
| `^(Orchestrate|orchestrate)\s+` | `orchestrate` |
|
|
1280
|
+
| domain agent names (e.g., `API bug hunter`, `Billing.*review`, `Security.*review`, `Frontend.*quality`, `General security`, `Auth.*review`, `Infra.*review`) | `review` |
|
|
1281
|
+
| No pattern matches | `other` |
|
|
1282
|
+
|
|
1283
|
+
**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).
|
|
1284
|
+
|
|
1285
|
+
**Step 1 — Check prerequisite**:
|
|
1286
|
+
|
|
1287
|
+
```bash
|
|
1288
|
+
if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" != "true" ]; then
|
|
1289
|
+
echo "Phase 2P: TRANSCRIPT_DATA_AVAILABLE=false — skipping subagent flow analysis"
|
|
1290
|
+
SUBAGENT_DATA_AVAILABLE=false
|
|
1291
|
+
else
|
|
1292
|
+
echo "Phase 2P: analyzing subagent flow for $SESSION_TOTAL sessions"
|
|
1293
|
+
SUBAGENT_DATA_AVAILABLE=true
|
|
1294
|
+
fi
|
|
1295
|
+
```
|
|
1296
|
+
|
|
1297
|
+
**Step 2 — Parse agent_spawn events and subagent files (inline Python)**:
|
|
1298
|
+
|
|
1299
|
+
**IMPORTANT**: Run as a single Python script. Budget: 2 minutes total.
|
|
1300
|
+
|
|
1301
|
+
```python
|
|
1302
|
+
import json, sys, os, re
|
|
1303
|
+
from collections import defaultdict
|
|
1304
|
+
|
|
1305
|
+
# Role mapping: ordered list of (regex_pattern, role_name) tuples
|
|
1306
|
+
# Evaluated in order; first match wins. Domain review agents match last (broad patterns).
|
|
1307
|
+
ROLE_PATTERNS = [
|
|
1308
|
+
(re.compile(r'^(Investigate|investigate)\s+', re.I), 'investigate'),
|
|
1309
|
+
(re.compile(r'^(Work on|work on)\s+', re.I), 'build'),
|
|
1310
|
+
(re.compile(r'^Resume #\d+ build', re.I), 'build'),
|
|
1311
|
+
(re.compile(r'^Continue #\d+ build', re.I), 'build'),
|
|
1312
|
+
(re.compile(r'^forge#\d+\b', re.I), 'build'),
|
|
1313
|
+
(re.compile(r'^Review\+Close\s+', re.I), 'review'),
|
|
1314
|
+
(re.compile(r'^Resume.*close', re.I), 'close'),
|
|
1315
|
+
(re.compile(r'^Continue.*close', re.I), 'close'),
|
|
1316
|
+
(re.compile(r'^Quality gate', re.I), 'quality-gate'),
|
|
1317
|
+
(re.compile(r'^(Orchestrate|orchestrate)\s+', re.I), 'orchestrate'),
|
|
1318
|
+
(re.compile(r'^(Review|review)\s+', re.I), 'review'),
|
|
1319
|
+
(re.compile(r'^Resume.*review', re.I), 'review'),
|
|
1320
|
+
(re.compile(r'^Continue.*review', re.I), 'review'),
|
|
1321
|
+
# Domain review agent patterns (broad — must come after specific patterns)
|
|
1322
|
+
(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'),
|
|
1323
|
+
(re.compile(r'General security', re.I), 'review'),
|
|
1324
|
+
]
|
|
1325
|
+
|
|
1326
|
+
def map_role(agent_desc):
|
|
1327
|
+
"""Map agent_desc string to a pipeline role name."""
|
|
1328
|
+
for pattern, role in ROLE_PATTERNS:
|
|
1329
|
+
if pattern.search(agent_desc):
|
|
1330
|
+
return role
|
|
1331
|
+
return 'other'
|
|
1332
|
+
|
|
1333
|
+
# Read SESSION_JSONL_FILES_RAW from Phase 2M/2N environment
|
|
1334
|
+
session_records_raw = os.environ.get('SESSION_JSONL_FILES_RAW', '')
|
|
1335
|
+
|
|
1336
|
+
# Per-role accumulators
|
|
1337
|
+
# role → list of agent records: {session_id, desc, model, duration_ms, tool_count, compact_count, is_retry}
|
|
1338
|
+
role_agents = defaultdict(list)
|
|
1339
|
+
|
|
1340
|
+
def compute_subagent_metrics(subagent_path):
|
|
1341
|
+
"""Return (duration_ms, tool_count, compact_count) for a subagent JSONL file."""
|
|
1342
|
+
duration_ms = 0
|
|
1343
|
+
tool_count = 0
|
|
1344
|
+
compact_count = 0
|
|
1345
|
+
try:
|
|
1346
|
+
with open(subagent_path, encoding='utf-8', errors='replace') as f:
|
|
1347
|
+
for line in f:
|
|
1348
|
+
line = line.strip()
|
|
1349
|
+
if not line:
|
|
1350
|
+
continue
|
|
1351
|
+
try:
|
|
1352
|
+
msg = json.loads(line)
|
|
1353
|
+
except json.JSONDecodeError:
|
|
1354
|
+
continue
|
|
1355
|
+
msg_type = msg.get('type', '')
|
|
1356
|
+
if msg_type == 'assistant':
|
|
1357
|
+
content = msg.get('message', {}).get('content', [])
|
|
1358
|
+
if isinstance(content, list):
|
|
1359
|
+
for block in content:
|
|
1360
|
+
if isinstance(block, dict) and block.get('type') == 'tool_use':
|
|
1361
|
+
tool_count += 1
|
|
1362
|
+
elif msg_type == 'system':
|
|
1363
|
+
subtype = msg.get('subtype', '')
|
|
1364
|
+
if subtype == 'turn_duration':
|
|
1365
|
+
dur = msg.get('durationMs')
|
|
1366
|
+
if isinstance(dur, (int, float)) and dur > 0:
|
|
1367
|
+
duration_ms += dur
|
|
1368
|
+
elif subtype == 'compact_boundary':
|
|
1369
|
+
compact_count += 1
|
|
1370
|
+
except (IOError, OSError):
|
|
1371
|
+
pass
|
|
1372
|
+
return duration_ms, tool_count, compact_count
|
|
1373
|
+
|
|
1374
|
+
# Process each parent session
|
|
1375
|
+
for record in session_records_raw.strip().split('\n'):
|
|
1376
|
+
if not record.strip():
|
|
1377
|
+
continue
|
|
1378
|
+
parts = record.split('\t')
|
|
1379
|
+
if len(parts) < 4:
|
|
1380
|
+
continue
|
|
1381
|
+
session_id, command, timestamp, jsonl_path = parts[0], parts[1], parts[2], parts[3]
|
|
1382
|
+
jsonl_path = os.path.expanduser(jsonl_path)
|
|
1383
|
+
if not os.path.isfile(jsonl_path):
|
|
1384
|
+
continue
|
|
1385
|
+
|
|
1386
|
+
# Collect agent_spawn events from the parent JSONL (structural only)
|
|
1387
|
+
spawned_agents = [] # list of {desc, model}
|
|
1388
|
+
try:
|
|
1389
|
+
with open(jsonl_path, encoding='utf-8', errors='replace') as f:
|
|
1390
|
+
for line in f:
|
|
1391
|
+
line = line.strip()
|
|
1392
|
+
if not line:
|
|
1393
|
+
continue
|
|
1394
|
+
try:
|
|
1395
|
+
msg = json.loads(line)
|
|
1396
|
+
except json.JSONDecodeError:
|
|
1397
|
+
continue
|
|
1398
|
+
if msg.get('type') != 'assistant':
|
|
1399
|
+
continue
|
|
1400
|
+
content = msg.get('message', {}).get('content', [])
|
|
1401
|
+
if not isinstance(content, list):
|
|
1402
|
+
continue
|
|
1403
|
+
for block in content:
|
|
1404
|
+
if not isinstance(block, dict):
|
|
1405
|
+
continue
|
|
1406
|
+
if block.get('type') == 'tool_use' and block.get('name') == 'Agent':
|
|
1407
|
+
inp = block.get('input', {}) or {}
|
|
1408
|
+
agent_desc = inp.get('description', '')
|
|
1409
|
+
agent_model = inp.get('model', 'unknown')
|
|
1410
|
+
spawned_agents.append({'desc': agent_desc, 'model': agent_model})
|
|
1411
|
+
except (IOError, OSError):
|
|
1412
|
+
continue
|
|
1413
|
+
|
|
1414
|
+
if not spawned_agents:
|
|
1415
|
+
continue
|
|
1416
|
+
|
|
1417
|
+
# Subagent JSONL directory: dirname(jsonl_path)/{session_id}/subagents/
|
|
1418
|
+
sub_dir = os.path.join(os.path.dirname(jsonl_path), session_id, 'subagents')
|
|
1419
|
+
|
|
1420
|
+
# Track roles seen in this session for retry detection (positional)
|
|
1421
|
+
session_role_counts = defaultdict(int) # role → spawn count in this session
|
|
1422
|
+
spawn_counter = 0 # overall counter across all roles (positional file index)
|
|
1423
|
+
|
|
1424
|
+
for spawn in spawned_agents:
|
|
1425
|
+
role = map_role(spawn['desc'])
|
|
1426
|
+
session_role_counts[role] += 1
|
|
1427
|
+
# Positional retry: second+ spawn of same role in same session = retry
|
|
1428
|
+
is_retry = (session_role_counts[role] > 1)
|
|
1429
|
+
|
|
1430
|
+
# Best-effort: associate this spawn with a subagent file by position (sorted order)
|
|
1431
|
+
# Cannot map tool_use id to agent file hash — take file at overall spawn position
|
|
1432
|
+
duration_ms = 0
|
|
1433
|
+
tool_count = 0
|
|
1434
|
+
compact_count = 0
|
|
1435
|
+
|
|
1436
|
+
if os.path.isdir(sub_dir):
|
|
1437
|
+
agent_files = sorted([
|
|
1438
|
+
f for f in os.listdir(sub_dir)
|
|
1439
|
+
if f.startswith('agent-') and f.endswith('.jsonl')
|
|
1440
|
+
])
|
|
1441
|
+
# Use overall spawn position (across all roles) as index into sorted file list
|
|
1442
|
+
if spawn_counter < len(agent_files):
|
|
1443
|
+
fpath = os.path.join(sub_dir, agent_files[spawn_counter])
|
|
1444
|
+
duration_ms, tool_count, compact_count = compute_subagent_metrics(fpath)
|
|
1445
|
+
|
|
1446
|
+
spawn_counter += 1
|
|
1447
|
+
role_agents[role].append({
|
|
1448
|
+
'session_id': session_id,
|
|
1449
|
+
'desc': spawn['desc'],
|
|
1450
|
+
'model': spawn['model'],
|
|
1451
|
+
'duration_ms': duration_ms,
|
|
1452
|
+
'tool_count': tool_count,
|
|
1453
|
+
'compact_count': compact_count,
|
|
1454
|
+
'is_retry': is_retry,
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
# Aggregate metrics by role
|
|
1458
|
+
print("\n=== Phase 2P: Subagent Flow Analysis ===")
|
|
1459
|
+
print()
|
|
1460
|
+
|
|
1461
|
+
all_roles = sorted(role_agents.keys())
|
|
1462
|
+
model_distribution = defaultdict(int)
|
|
1463
|
+
|
|
1464
|
+
for role in all_roles:
|
|
1465
|
+
agents = role_agents[role]
|
|
1466
|
+
total = len(agents)
|
|
1467
|
+
retried = sum(1 for a in agents if a.get('is_retry', False))
|
|
1468
|
+
retry_rate = (retried / total * 100) if total > 0 else 0
|
|
1469
|
+
|
|
1470
|
+
durations = [a['duration_ms'] for a in agents if a['duration_ms'] > 0]
|
|
1471
|
+
avg_dur_s = (sum(durations) / len(durations) / 1000) if durations else 0
|
|
1472
|
+
max_dur_s = (max(durations) / 1000) if durations else 0
|
|
1473
|
+
|
|
1474
|
+
tool_counts = [a['tool_count'] for a in agents]
|
|
1475
|
+
avg_tools = (sum(tool_counts) / len(tool_counts)) if tool_counts else 0
|
|
1476
|
+
|
|
1477
|
+
compacts = sum(a['compact_count'] for a in agents)
|
|
1478
|
+
|
|
1479
|
+
for a in agents:
|
|
1480
|
+
model_distribution[a['model']] += 1
|
|
1481
|
+
|
|
1482
|
+
print(f"Role: {role} ({total} agents, {retried} retried — retry rate: {retry_rate:.0f}%)")
|
|
1483
|
+
print(f" Avg duration: {avg_dur_s:.1f}s Max: {max_dur_s:.1f}s")
|
|
1484
|
+
print(f" Avg tool calls: {avg_tools:.1f} Total compactions: {compacts}")
|
|
1485
|
+
print()
|
|
1486
|
+
|
|
1487
|
+
print("Model distribution (across all roles):")
|
|
1488
|
+
for model, count in sorted(model_distribution.items(), key=lambda x: -x[1]):
|
|
1489
|
+
print(f" {model}: {count} agents")
|
|
1490
|
+
|
|
1491
|
+
total_agents = sum(len(v) for v in role_agents.values())
|
|
1492
|
+
total_retried = sum(sum(1 for a in v if a.get('is_retry', False)) for v in role_agents.values())
|
|
1493
|
+
print()
|
|
1494
|
+
print(f"Total subagents analyzed: {total_agents}")
|
|
1495
|
+
print(f"Total retried agents: {total_retried} ({(total_retried/total_agents*100) if total_agents else 0:.1f}%)")
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
To run this step:
|
|
1499
|
+
|
|
1500
|
+
```bash
|
|
1501
|
+
if [ "$SUBAGENT_DATA_AVAILABLE" = "true" ]; then
|
|
1502
|
+
export SESSION_JSONL_FILES_RAW
|
|
1503
|
+
python3 - << 'PYEOF'
|
|
1504
|
+
# [paste the Python script above here at runtime]
|
|
1505
|
+
PYEOF
|
|
1506
|
+
fi
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
**Metrics produced**:
|
|
1510
|
+
- **Per-role agent count**: how many subagents handled each pipeline role (investigate, build, review, quality-gate, close, orchestrate, other) in the window.
|
|
1511
|
+
- **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.
|
|
1512
|
+
- **Avg duration by role (seconds)**: mean of all `turn_duration.durationMs` values summed per subagent. Uses `durationMs` — NOT timestamp arithmetic.
|
|
1513
|
+
- **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.
|
|
1514
|
+
- **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.
|
|
1515
|
+
- **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.
|
|
1516
|
+
|
|
1517
|
+
**Interpretation guide**:
|
|
1518
|
+
- **Retry rate > 20% for a role** → that pipeline phase is fragile: agents stall, produce errors, or get externally resumed more than expected.
|
|
1519
|
+
- **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.
|
|
1520
|
+
- **Opus count > 10% of total** → significant rate-limit fallback occurring — consider spreading pipeline sessions or reducing prompt sizes.
|
|
1521
|
+
- **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.
|
|
1522
|
+
|
|
1523
|
+
**Skip condition**: If `SUBAGENT_DATA_AVAILABLE` is false, emit:
|
|
1524
|
+
> `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.`
|
|
1525
|
+
|
|
1526
|
+
---
|
|
1527
|
+
|
|
1528
|
+
### 2Q: Privacy-safe aggregation and harmful-takeaway guardrails
|
|
1529
|
+
|
|
1530
|
+
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.
|
|
1531
|
+
|
|
1532
|
+
**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=""`.
|
|
1533
|
+
|
|
1534
|
+
**This phase reads only `PER_SESSION_METRICS_RAW` (in-memory) — no disk reads, no GitHub API calls.**
|
|
1535
|
+
|
|
1536
|
+
**Field whitelist** (deny by default — only these fields are read from each session record):
|
|
1537
|
+
- `command` — pipeline command name (safe: structural)
|
|
1538
|
+
- `total_duration_ms` — active compute time (safe: timing only)
|
|
1539
|
+
- `tool_call_counts` — dict of tool_name → count (safe: tool names are structural)
|
|
1540
|
+
- `skill_invocations[].skill_name` — skill name only (safe: structural); `skill_args` is NOT read
|
|
1541
|
+
- `model_distribution` — model_name → count dict (safe: model names only); authoritative source for sonnet/opus split
|
|
1542
|
+
- `compaction_count` — integer (safe: count only)
|
|
1543
|
+
|
|
1544
|
+
**Fields explicitly excluded** (even though present in Phase 2O output):
|
|
1545
|
+
- `session_id` — opaque UUID; not useful for pipeline improvement; excluded from all output
|
|
1546
|
+
- `skill_invocations[].skill_args` — may contain issue numbers (safe) but also file paths or branch names (contextual); excluded to keep the whitelist strict
|
|
1547
|
+
- `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 -->
|
|
1548
|
+
- `agent_spawns[].agent_desc` — may contain file paths or code context; excluded per #347 privacy classification
|
|
1549
|
+
|
|
1550
|
+
**Step 1 — Prerequisite guard**:
|
|
1551
|
+
|
|
1552
|
+
```bash
|
|
1553
|
+
if [ "${TRANSCRIPT_DATA_AVAILABLE:-false}" != "true" ] || [ -z "${PER_SESSION_METRICS_RAW:-}" ]; then
|
|
1554
|
+
echo "Phase 2Q: skipping — TRANSCRIPT_DATA_AVAILABLE=false or PER_SESSION_METRICS_RAW empty"
|
|
1555
|
+
AGGREGATED_TRANSCRIPT_METRICS=""
|
|
1556
|
+
TRANSCRIPT_AGGREGATION_AVAILABLE=false
|
|
1557
|
+
else
|
|
1558
|
+
echo "Phase 2Q: aggregating privacy-safe metrics from PER_SESSION_METRICS_RAW"
|
|
1559
|
+
TRANSCRIPT_AGGREGATION_AVAILABLE=true
|
|
1560
|
+
fi
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
**Step 2 — Aggregate and sanitize (inline Python)**:
|
|
1564
|
+
|
|
1565
|
+
```python
|
|
1566
|
+
import json, sys, os, statistics
|
|
1567
|
+
from collections import defaultdict
|
|
1568
|
+
|
|
1569
|
+
raw = os.environ.get('PER_SESSION_METRICS_RAW', '')
|
|
1570
|
+
if not raw.strip():
|
|
1571
|
+
print('Phase 2Q: PER_SESSION_METRICS_RAW is empty — nothing to aggregate')
|
|
1572
|
+
sys.exit(0)
|
|
1573
|
+
|
|
1574
|
+
# Accumulators (whitelisted fields only — no session_id, no skill_args, no agent_desc)
|
|
1575
|
+
durations_ms = [] # total_duration_ms per session
|
|
1576
|
+
tool_freq = defaultdict(int) # tool_name → total call count across all sessions
|
|
1577
|
+
skill_freq = defaultdict(int) # skill_name → invocation count across all sessions
|
|
1578
|
+
model_freq = defaultdict(int) # model_name → message count across all sessions
|
|
1579
|
+
compaction_counts = [] # compaction_count per session
|
|
1580
|
+
command_buckets = defaultdict(list) # command → list of duration_ms (for task-type normalization)
|
|
1581
|
+
sessions_parsed = 0
|
|
1582
|
+
sessions_skipped = 0
|
|
1583
|
+
|
|
1584
|
+
for line in raw.strip().split('\n'):
|
|
1585
|
+
line = line.strip()
|
|
1586
|
+
if not line:
|
|
1587
|
+
continue
|
|
1588
|
+
try:
|
|
1589
|
+
rec = json.loads(line)
|
|
1590
|
+
except json.JSONDecodeError:
|
|
1591
|
+
sessions_skipped += 1
|
|
1592
|
+
continue
|
|
1593
|
+
|
|
1594
|
+
sessions_parsed += 1
|
|
1595
|
+
|
|
1596
|
+
# session_id: intentionally NOT read — stripped by omission
|
|
1597
|
+
command = str(rec.get('command', 'unknown'))[:32] # safe: command name only
|
|
1598
|
+
|
|
1599
|
+
dur = rec.get('total_duration_ms', 0)
|
|
1600
|
+
if isinstance(dur, (int, float)) and dur >= 0:
|
|
1601
|
+
durations_ms.append(dur)
|
|
1602
|
+
command_buckets[command].append(dur)
|
|
1603
|
+
|
|
1604
|
+
# Tool call counts: tool names only (structural)
|
|
1605
|
+
for tool_name, count in (rec.get('tool_call_counts') or {}).items():
|
|
1606
|
+
if isinstance(count, int):
|
|
1607
|
+
tool_freq[str(tool_name)[:64]] += count
|
|
1608
|
+
|
|
1609
|
+
# Skill invocations: skill_name only — skill_args intentionally NOT read
|
|
1610
|
+
for inv in (rec.get('skill_invocations') or []):
|
|
1611
|
+
if isinstance(inv, dict):
|
|
1612
|
+
sk = inv.get('skill_name', '')
|
|
1613
|
+
if sk:
|
|
1614
|
+
skill_freq[str(sk)[:32]] += 1
|
|
1615
|
+
|
|
1616
|
+
# Model distribution: model name → count (authoritative source for sonnet/opus split)
|
|
1617
|
+
# agent_spawns[].model is intentionally NOT included here — it counts spawn intent, not
|
|
1618
|
+
# actual response messages, and overlaps with model_distribution in all realistic sessions.
|
|
1619
|
+
for m, cnt in (rec.get('model_distribution') or {}).items():
|
|
1620
|
+
if isinstance(cnt, int):
|
|
1621
|
+
model_freq[str(m)[:64]] += cnt
|
|
1622
|
+
|
|
1623
|
+
cc = rec.get('compaction_count', 0)
|
|
1624
|
+
if isinstance(cc, int):
|
|
1625
|
+
compaction_counts.append(cc)
|
|
1626
|
+
|
|
1627
|
+
# Distribution helpers (no numpy — pure stdlib)
|
|
1628
|
+
def _median(lst):
|
|
1629
|
+
if not lst: return 0
|
|
1630
|
+
s = sorted(lst)
|
|
1631
|
+
n = len(s)
|
|
1632
|
+
return s[n // 2] if n % 2 == 1 else (s[n//2 - 1] + s[n//2]) / 2
|
|
1633
|
+
|
|
1634
|
+
def _p25(lst):
|
|
1635
|
+
if not lst: return 0
|
|
1636
|
+
s = sorted(lst)
|
|
1637
|
+
return s[max(0, len(s)//4 - 1)]
|
|
1638
|
+
|
|
1639
|
+
def _p75(lst):
|
|
1640
|
+
if not lst: return 0
|
|
1641
|
+
s = sorted(lst)
|
|
1642
|
+
return s[min(len(s)-1, (3*len(s))//4)]
|
|
1643
|
+
|
|
1644
|
+
n = sessions_parsed
|
|
1645
|
+
dur_median_s = _median(durations_ms) / 1000 if durations_ms else 0
|
|
1646
|
+
dur_p25_s = _p25(durations_ms) / 1000 if durations_ms else 0
|
|
1647
|
+
dur_p75_s = _p75(durations_ms) / 1000 if durations_ms else 0
|
|
1648
|
+
compact_median = _median(compaction_counts) if compaction_counts else 0
|
|
1649
|
+
compact_p25 = _p25(compaction_counts) if compaction_counts else 0
|
|
1650
|
+
compact_p75 = _p75(compaction_counts) if compaction_counts else 0
|
|
1651
|
+
|
|
1652
|
+
# Top tools (top 8 by frequency)
|
|
1653
|
+
top_tools = sorted(tool_freq.items(), key=lambda x: -x[1])[:8]
|
|
1654
|
+
# Top skills
|
|
1655
|
+
top_skills = sorted(skill_freq.items(), key=lambda x: -x[1])[:6]
|
|
1656
|
+
# Model split
|
|
1657
|
+
total_model_msgs = sum(model_freq.values()) or 1
|
|
1658
|
+
sonnet_msgs = sum(v for k, v in model_freq.items() if 'sonnet' in k.lower())
|
|
1659
|
+
opus_msgs = sum(v for k, v in model_freq.items() if 'opus' in k.lower())
|
|
1660
|
+
|
|
1661
|
+
print(f"\n=== Phase 2Q: Privacy-Safe Aggregated Transcript Metrics ===")
|
|
1662
|
+
print(f"Sessions aggregated: {n} (skipped/malformed: {sessions_skipped})")
|
|
1663
|
+
print(f"Session IDs: NOT INCLUDED (stripped by whitelist)")
|
|
1664
|
+
print()
|
|
1665
|
+
print(f"Active compute duration (agent time, excludes human idle):")
|
|
1666
|
+
print(f" Median: {dur_median_s:.0f}s [P25: {dur_p25_s:.0f}s — P75: {dur_p75_s:.0f}s] (across {n} pipeline runs)")
|
|
1667
|
+
print()
|
|
1668
|
+
print(f"Context compaction events per session:")
|
|
1669
|
+
print(f" Median: {compact_median:.1f} [P25: {compact_p25:.1f} — P75: {compact_p75:.1f}]")
|
|
1670
|
+
print()
|
|
1671
|
+
print("Tool call distribution (top 8, all sessions combined):")
|
|
1672
|
+
for tool, cnt in top_tools:
|
|
1673
|
+
print(f" {tool}: {cnt}")
|
|
1674
|
+
print()
|
|
1675
|
+
print("Skill invocations (top 6):")
|
|
1676
|
+
for skill, cnt in top_skills:
|
|
1677
|
+
print(f" {skill}: {cnt}")
|
|
1678
|
+
print()
|
|
1679
|
+
print(f"Model distribution: sonnet={sonnet_msgs} ({100*sonnet_msgs//total_model_msgs}%) opus={opus_msgs} ({100*opus_msgs//total_model_msgs}%)")
|
|
1680
|
+
print()
|
|
1681
|
+
print("Command-type duration buckets (median active time per command):")
|
|
1682
|
+
for cmd, dlist in sorted(command_buckets.items()):
|
|
1683
|
+
med = _median(dlist)/1000
|
|
1684
|
+
p25 = _p25(dlist)/1000
|
|
1685
|
+
p75 = _p75(dlist)/1000
|
|
1686
|
+
print(f" {cmd}: {med:.0f}s median [P25: {p25:.0f}s — P75: {p75:.0f}s] (n={len(dlist)})")
|
|
1687
|
+
```
|
|
1688
|
+
|
|
1689
|
+
To run this step:
|
|
1690
|
+
|
|
1691
|
+
```bash
|
|
1692
|
+
if [ "${TRANSCRIPT_AGGREGATION_AVAILABLE:-false}" = "true" ]; then
|
|
1693
|
+
AGGREGATED_TRANSCRIPT_METRICS=$(python3 - << 'PYEOF'
|
|
1694
|
+
# [paste the Python script above here at runtime]
|
|
1695
|
+
PYEOF
|
|
1696
|
+
)
|
|
1697
|
+
echo "$AGGREGATED_TRANSCRIPT_METRICS"
|
|
1698
|
+
export AGGREGATED_TRANSCRIPT_METRICS
|
|
1699
|
+
|
|
1700
|
+
# Extract OPUS_PCT from Phase 2Q output for use in Phase 3C health score
|
|
1701
|
+
# "Model distribution: sonnet=N (X%) opus=M (Y%)"
|
|
1702
|
+
OPUS_PCT=$(echo "$AGGREGATED_TRANSCRIPT_METRICS" | grep -oP 'opus=\d+ \(\K\d+(?=%\))')
|
|
1703
|
+
OPUS_PCT=${OPUS_PCT:-0}
|
|
1704
|
+
export OPUS_PCT
|
|
1705
|
+
echo "Phase 2Q exports: OPUS_PCT=$OPUS_PCT"
|
|
1706
|
+
fi
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
**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`.
|
|
1710
|
+
|
|
1711
|
+
**Guardrail footnotes** — embed these verbatim in the Phase 5A report section (select ≥3; all 5 apply when transcript data is available):
|
|
1712
|
+
|
|
1713
|
+
> ⚠️ **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.
|
|
1714
|
+
|
|
1715
|
+
> ⚠️ **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.
|
|
1716
|
+
|
|
1717
|
+
> ⚠️ **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.
|
|
1718
|
+
|
|
1719
|
+
> ⚠️ **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.
|
|
1720
|
+
|
|
1721
|
+
> ⚠️ **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.
|
|
1722
|
+
|
|
1723
|
+
**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.
|
|
1724
|
+
|
|
1725
|
+
**Skip condition**: If `TRANSCRIPT_DATA_AVAILABLE` is false or `PER_SESSION_METRICS_RAW` is empty, emit:
|
|
1726
|
+
> `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.`
|
|
1727
|
+
|
|
1728
|
+
---
|
|
1729
|
+
|