cctx-cli 1.6.0__tar.gz → 1.8.0__tar.gz
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.
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/CHANGELOG.md +71 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/PKG-INFO +1 -1
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/__init__.py +1 -1
- cctx_cli-1.8.0/cctx/diagnostician/__init__.py +190 -0
- cctx_cli-1.8.0/cctx/diagnostician/patterns/fan_out.py +175 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/exporters/jsonl.py +10 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/models.py +15 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/recommender/claude_md.py +10 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/renderers/templates/autopsy.html.j2 +15 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/renderers/terminal.py +22 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/pyproject.toml +1 -1
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/exporters/test_jsonl.py +37 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/renderers/test_report.py +23 -0
- cctx_cli-1.8.0/tests/test_diagnostician_subagents.py +298 -0
- cctx_cli-1.8.0/tests/test_fanout_classifier.py +344 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_harvest_emit.py +2 -1
- cctx_cli-1.6.0/cctx/diagnostician/__init__.py +0 -89
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/.github/workflows/ci.yml +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/.github/workflows/publish.yml +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/.github/workflows/release.yml +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/.gitignore +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/CLAUDE.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/DESIGN.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/PRODUCT.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/README.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/action.yml +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/agents.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/cli.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/aggregate.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/inflection.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/patterns/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/patterns/dead_end.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/patterns/project_specific.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/patterns/retry_loop.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/patterns/scope_creep.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/patterns/stale_context.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/diagnostician/patterns/tool_thrash.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/discovery.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/exporters/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/exporters/csv.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/exporters/json.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/harvest.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/parsers/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/parsers/claude_code.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/pricing.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/recommender/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/recommender/evidence.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/renderers/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/renderers/github.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/renderers/report.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/renderers/trace_tui.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/tokenizer.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx/watcher.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/cctx-project-brief.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/demo.gif +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/demo.tape +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/health-reviews/2026-05-15-deep-review-summary.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/health-reviews/2026-05-15-health-review.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/product-reviews/2026-05-15-product-review.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/product-reviews/2026-06-09-product-review.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/plans/2026-05-12-claude-code-parser.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/plans/2026-05-14-autopsy-v0.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/plans/2026-05-16-readme-pypi-release.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/plans/2026-05-17-harvest-check-depth.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/plans/2026-05-17-project-pattern-detection.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/plans/2026-05-19-claude-agents-live-integration.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-05-12-claude-code-parser-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-05-14-autopsy-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-05-14-harvest-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-05-14-trace-tui-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-05-16-readme-pypi-release-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-05-17-harvest-check-depth-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-05-17-project-pattern-detection-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-05-19-claude-agents-live-integration-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/docs/superpowers/specs/2026-06-09-cross-agent-emit-design.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/conftest.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/conftest.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/test_dead_end.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/test_inflection.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/test_orchestrator.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/test_project_specific.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/test_retry_loop.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/test_scope_creep.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/test_stale_context.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/diagnostician/test_tool_thrash.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/exporters/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/exporters/test_csv.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/README.md +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/short-clean/short-clean.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a0b4c2cf1dde0ca56.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a116ae34b1b09c332.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1c4c417b35658c9e.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a1e41a901de38f1b5.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a338f8d0c74612a24.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a34f6f3c0e7094186.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a5a5a0cff4d13308b.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a6b0a3da6a0484db5.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f73f1790b02cde5.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a7f7c17c38a9d8788.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a853259e2cd7bbe8a.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-a8d9aedb0d0c6e12d.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aa778bc1d59e4a441.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aba869dedee4a12ba.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-ada2746d9774b94db.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea0132068c64d2dd.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-aea215eff50874d5f.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments/subagents/agent-afee21f2b3852a4a0.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-attachments/with-attachments.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a051d9c9a6b2f5cc3.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a171f16f4e65cfe75.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a1b77fea2c0a2269b.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a20da4c01a54acca8.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a3c82739b1383fb14.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a49e8539611c5fe12.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a7bb58f3fff2b3e8d.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-a92b48c0331195aac.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ab96c4264099694a9.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-acb2895c5e34ffec0.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-adb2302769938fb3f.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-ae585eca15cb93b9c.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction/subagents/agent-aec9c917feb903d67.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-compaction/with-compaction.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-a1a3a21aeb76bb0a9.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-aaa1d6ecc05a78442.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents/subagents/agent-af3c545ccd30036d2.meta.json +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/btwp2bzro.txt +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents/tool-results/byqjbgy4b.txt +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-subagents/with-subagents.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results/tool-results/bosbkda0h.txt +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/claude_code/with-tool-results/with-tool-results.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/scrub.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/synthetic/bookkeeping_only.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/synthetic/malformed_middle.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/synthetic/truncated_final_line.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/synthetic/unknown_attachment_shape.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/fixtures/synthetic/unknown_type.jsonl +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/parsers/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/parsers/test_claude_code.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/parsers/test_claude_code_integration.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/recommender/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/recommender/test_claude_md.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/recommender/test_evidence.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/renderers/__init__.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/renderers/test_terminal_renderer_full.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_agents.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_aggregate.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_cli.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_cli_export.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_discovery.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_github_summary.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_harvest.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_harvest_check.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_models.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_models_project_pattern.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_recommender.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_smoke.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_terminal_renderer.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_tokenizer.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_trace_tui.py +0 -0
- {cctx_cli-1.6.0 → cctx_cli-1.8.0}/tests/test_watcher.py +0 -0
|
@@ -2,6 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v1.8.0 (2026-06-11)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Add _FANOUT_WASTE_DIFF template so MANAGED_HEADINGS stays in sync
|
|
10
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
11
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
12
|
+
|
|
13
|
+
- Ruff lint — move imports to top, break long lines in test file
|
|
14
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
15
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- Add FindingKind.FANOUT_WASTE + KIND_LABEL + MANAGED_HEADINGS
|
|
20
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
21
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
22
|
+
|
|
23
|
+
- Fan-out waste classifier (M16 #89) ([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
24
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
25
|
+
|
|
26
|
+
- Fan_out classifier — Signal A (overlap) + Signal B (retry)
|
|
27
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
28
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
29
|
+
|
|
30
|
+
- Wire fan_out classifier into diagnostician, add _patch_fanout_costs
|
|
31
|
+
([#110](https://github.com/jacquardlabs/cctx/pull/110),
|
|
32
|
+
[`0123588`](https://github.com/jacquardlabs/cctx/commit/01235884bde2158307441c71f38d4cf96a2d8481))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## v1.7.0 (2026-06-11)
|
|
36
|
+
|
|
37
|
+
### Bug Fixes
|
|
38
|
+
|
|
39
|
+
- Jsonl exporter — subagent_costs in dict literal; fix import sort
|
|
40
|
+
([#109](https://github.com/jacquardlabs/cctx/pull/109),
|
|
41
|
+
[`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
|
|
42
|
+
|
|
43
|
+
- Remove unused ToolResult import in test_diagnostician_subagents
|
|
44
|
+
([#109](https://github.com/jacquardlabs/cctx/pull/109),
|
|
45
|
+
[`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
|
|
46
|
+
|
|
47
|
+
### Documentation
|
|
48
|
+
|
|
49
|
+
- Restore billing-rate explanation in _compute_own_cost
|
|
50
|
+
([#109](https://github.com/jacquardlabs/cctx/pull/109),
|
|
51
|
+
[`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
|
|
52
|
+
|
|
53
|
+
### Features
|
|
54
|
+
|
|
55
|
+
- Diagnostician — inclusive cost + per-subagent attribution
|
|
56
|
+
([#109](https://github.com/jacquardlabs/cctx/pull/109),
|
|
57
|
+
[`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
|
|
58
|
+
|
|
59
|
+
- HTML report + JSON exporter — subagent_costs output
|
|
60
|
+
([#109](https://github.com/jacquardlabs/cctx/pull/109),
|
|
61
|
+
[`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
|
|
62
|
+
|
|
63
|
+
- Per-subagent cost attribution in autopsy (#88)
|
|
64
|
+
([#109](https://github.com/jacquardlabs/cctx/pull/109),
|
|
65
|
+
[`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
|
|
66
|
+
|
|
67
|
+
- SubagentAttribution model + Diagnosis.subagent_costs field
|
|
68
|
+
([#109](https://github.com/jacquardlabs/cctx/pull/109),
|
|
69
|
+
[`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
|
|
70
|
+
|
|
71
|
+
- Terminal renderer — subagent cost table in autopsy output
|
|
72
|
+
([#109](https://github.com/jacquardlabs/cctx/pull/109),
|
|
73
|
+
[`d1bc0fa`](https://github.com/jacquardlabs/cctx/commit/d1bc0fa039070ecfa671f38f5c43c864ed17e61a))
|
|
74
|
+
|
|
75
|
+
|
|
5
76
|
## v1.6.0 (2026-06-10)
|
|
6
77
|
|
|
7
78
|
### Bug Fixes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Autopsy diagnostician — public entry point.
|
|
2
|
+
|
|
3
|
+
run(trace) -> Diagnosis
|
|
4
|
+
Runs all pattern classifiers, detects inflection turn,
|
|
5
|
+
patches stale_context cost attribution, and returns
|
|
6
|
+
a Diagnosis with patches=[] and subagent_costs populated.
|
|
7
|
+
|
|
8
|
+
The Recommender (cctx.recommender.claude_md) populates patches.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from cctx.diagnostician import inflection
|
|
17
|
+
from cctx.diagnostician.patterns import (
|
|
18
|
+
dead_end,
|
|
19
|
+
fan_out,
|
|
20
|
+
retry_loop,
|
|
21
|
+
scope_creep,
|
|
22
|
+
stale_context,
|
|
23
|
+
tool_thrash,
|
|
24
|
+
)
|
|
25
|
+
from cctx.models import Diagnosis, Finding, FindingKind, SubagentAttribution
|
|
26
|
+
from cctx.pricing import price_per_tok as _price_per_tok
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from cctx.models import SessionTrace
|
|
30
|
+
|
|
31
|
+
UTC = timezone.utc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _patch_costs(findings: list[Finding], model: str | None) -> list[Finding]:
|
|
35
|
+
price = _price_per_tok(model)
|
|
36
|
+
result = []
|
|
37
|
+
for f in findings:
|
|
38
|
+
if f.kind is FindingKind.STALE_CONTEXT:
|
|
39
|
+
tt = f.evidence.get("total_token_turns", 0)
|
|
40
|
+
f = dataclasses.replace(f, cost_usd=round(tt * price, 4))
|
|
41
|
+
result.append(f)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _patch_fanout_costs(
|
|
46
|
+
findings: list[Finding],
|
|
47
|
+
subagent_costs: list[SubagentAttribution],
|
|
48
|
+
) -> list[Finding]:
|
|
49
|
+
"""Fill cost_usd on FANOUT_WASTE findings from subagent attribution data.
|
|
50
|
+
|
|
51
|
+
For overlap findings: picks the cheaper of the two subagents as waste.
|
|
52
|
+
For retry findings: attributes the full cost of the failed subagent.
|
|
53
|
+
Populates evidence['subagent_session_ids'] so run()'s dedup pass works.
|
|
54
|
+
"""
|
|
55
|
+
cost_map = {a.session_id: a.total_cost_usd for a in subagent_costs}
|
|
56
|
+
result: list[Finding] = []
|
|
57
|
+
for f in findings:
|
|
58
|
+
if f.kind is FindingKind.FANOUT_WASTE:
|
|
59
|
+
signal = f.evidence.get("signal")
|
|
60
|
+
if signal == "overlap":
|
|
61
|
+
pair = [sid for sid in f.evidence.get("overlap_pair", []) if sid is not None]
|
|
62
|
+
if pair:
|
|
63
|
+
cheaper_cost, cheaper_sid = min(
|
|
64
|
+
(cost_map.get(sid, 0.0), sid) for sid in pair
|
|
65
|
+
)
|
|
66
|
+
f = dataclasses.replace(
|
|
67
|
+
f,
|
|
68
|
+
cost_usd=round(cheaper_cost, 4),
|
|
69
|
+
evidence={**f.evidence, "subagent_session_ids": [cheaper_sid]},
|
|
70
|
+
)
|
|
71
|
+
elif signal == "retry":
|
|
72
|
+
failed_sid = f.evidence.get("failed_session_id")
|
|
73
|
+
if failed_sid is not None:
|
|
74
|
+
cost = cost_map.get(failed_sid, 0.0)
|
|
75
|
+
f = dataclasses.replace(
|
|
76
|
+
f,
|
|
77
|
+
cost_usd=round(cost, 4),
|
|
78
|
+
evidence={**f.evidence, "subagent_session_ids": [failed_sid]},
|
|
79
|
+
)
|
|
80
|
+
result.append(f)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _compute_own_cost(trace: SessionTrace, model: str | None) -> float:
|
|
85
|
+
"""Parent-turns-only cost — does not recurse into subagents.
|
|
86
|
+
|
|
87
|
+
Billing rates relative to base input price:
|
|
88
|
+
cache_read: ×0.10 (read from prompt cache)
|
|
89
|
+
cache_write: ×1.25 (write to prompt cache, both 5-min and 1-hr TTLs)
|
|
90
|
+
"""
|
|
91
|
+
price = _price_per_tok(model)
|
|
92
|
+
total = 0.0
|
|
93
|
+
for turn in trace.turns:
|
|
94
|
+
if turn.usage is not None:
|
|
95
|
+
total += turn.usage.input_tokens * price
|
|
96
|
+
total += turn.usage.cache_read * price * 0.1
|
|
97
|
+
cache_writes = turn.usage.cache_creation_5m + turn.usage.cache_creation_1h
|
|
98
|
+
total += cache_writes * price * 1.25
|
|
99
|
+
return round(total, 4)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _compute_inclusive_cost(trace: SessionTrace) -> float:
|
|
103
|
+
"""Recursive cost: own turns + all subagent turns at every depth."""
|
|
104
|
+
own = _compute_own_cost(trace, trace.primary_model)
|
|
105
|
+
return own + sum(_compute_inclusive_cost(sa) for sa in trace.subagents)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_label_map(trace: SessionTrace) -> dict[str, str]:
|
|
109
|
+
"""Map child session_id → display label from the parent's Agent ToolUse inputs."""
|
|
110
|
+
label_map: dict[str, str] = {}
|
|
111
|
+
for turn in trace.turns:
|
|
112
|
+
for tu in turn.tool_uses:
|
|
113
|
+
if tu.subagent_session_id:
|
|
114
|
+
ti = tu.tool_input
|
|
115
|
+
label_map[tu.subagent_session_id] = (
|
|
116
|
+
ti.get("description")
|
|
117
|
+
or (ti.get("prompt") or "")[:80]
|
|
118
|
+
or tu.subagent_session_id[:12]
|
|
119
|
+
)
|
|
120
|
+
return label_map
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _collect_attributions(
|
|
124
|
+
trace: SessionTrace,
|
|
125
|
+
depth: int = 1,
|
|
126
|
+
label_map: dict[str, str] | None = None,
|
|
127
|
+
) -> list[SubagentAttribution]:
|
|
128
|
+
"""Flat DFS list of SubagentAttribution, one per subagent at every depth."""
|
|
129
|
+
if label_map is None:
|
|
130
|
+
label_map = _build_label_map(trace)
|
|
131
|
+
result: list[SubagentAttribution] = []
|
|
132
|
+
for child in trace.subagents:
|
|
133
|
+
label = label_map.get(child.session_id, child.session_id[:12])
|
|
134
|
+
cost = _compute_inclusive_cost(child)
|
|
135
|
+
result.append(SubagentAttribution(
|
|
136
|
+
session_id=child.session_id,
|
|
137
|
+
label=label,
|
|
138
|
+
total_cost_usd=round(cost, 4),
|
|
139
|
+
depth=depth,
|
|
140
|
+
model=child.primary_model,
|
|
141
|
+
))
|
|
142
|
+
result.extend(_collect_attributions(child, depth + 1, None))
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run(trace: SessionTrace) -> Diagnosis:
|
|
147
|
+
"""Diagnose a single SessionTrace. Returns Diagnosis with patches=[]."""
|
|
148
|
+
findings: list[Finding] = [
|
|
149
|
+
*retry_loop.classify(trace),
|
|
150
|
+
*scope_creep.classify(trace),
|
|
151
|
+
*stale_context.classify(trace),
|
|
152
|
+
*tool_thrash.classify(trace),
|
|
153
|
+
*dead_end.classify(trace),
|
|
154
|
+
*fan_out.classify(trace),
|
|
155
|
+
]
|
|
156
|
+
findings.sort(key=lambda f: f.first_turn)
|
|
157
|
+
|
|
158
|
+
inflection_turn = inflection.detect(findings)
|
|
159
|
+
findings = _patch_costs(findings, trace.primary_model)
|
|
160
|
+
|
|
161
|
+
# Fan-out cost patching requires attributions first.
|
|
162
|
+
subagent_costs = _collect_attributions(trace)
|
|
163
|
+
findings = _patch_fanout_costs(findings, subagent_costs)
|
|
164
|
+
|
|
165
|
+
total_cost = round(_compute_inclusive_cost(trace), 4)
|
|
166
|
+
|
|
167
|
+
# Deduplicate fan-out waste: a subagent flagged by both overlap AND retry
|
|
168
|
+
# must not be double-counted. Collect unique wasted session IDs, sum once.
|
|
169
|
+
cost_map = {a.session_id: a.total_cost_usd for a in subagent_costs}
|
|
170
|
+
wasted_sids: set[str] = set()
|
|
171
|
+
for f in findings:
|
|
172
|
+
if f.kind is FindingKind.FANOUT_WASTE:
|
|
173
|
+
wasted_sids.update(f.evidence.get("subagent_session_ids", []))
|
|
174
|
+
fanout_waste = sum(cost_map.get(sid, 0.0) for sid in wasted_sids)
|
|
175
|
+
other_waste = sum(
|
|
176
|
+
f.cost_usd for f in findings
|
|
177
|
+
if f.cost_usd is not None and f.kind is not FindingKind.FANOUT_WASTE
|
|
178
|
+
)
|
|
179
|
+
waste_cost = min(other_waste + fanout_waste, total_cost)
|
|
180
|
+
|
|
181
|
+
return Diagnosis(
|
|
182
|
+
session_id=trace.session_id,
|
|
183
|
+
findings=findings,
|
|
184
|
+
inflection_turn=inflection_turn,
|
|
185
|
+
patches=[],
|
|
186
|
+
total_cost_usd=total_cost,
|
|
187
|
+
waste_cost_usd=round(waste_cost, 4),
|
|
188
|
+
analysed_at=datetime.now(UTC),
|
|
189
|
+
subagent_costs=subagent_costs,
|
|
190
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Fan-out waste classifier.
|
|
2
|
+
|
|
3
|
+
classify(trace) -> list[Finding]
|
|
4
|
+
|
|
5
|
+
Signal A — OVERLAP: Two Agent calls with Jaccard >= 0.65 on word 3-grams,
|
|
6
|
+
both prompts >= 50 words.
|
|
7
|
+
Signal B — RETRY: Agent ToolResult is_error=True followed by the next Agent
|
|
8
|
+
call with Jaccard >= 0.50 on word 3-grams, both prompts >= 30 words.
|
|
9
|
+
|
|
10
|
+
Signal C (unused-result) is deferred — the 6-gram approach fires false
|
|
11
|
+
positives on paraphrased references and is not ship-ready.
|
|
12
|
+
|
|
13
|
+
cost_usd is set to None here; _patch_fanout_costs() in diagnostician/__init__.py
|
|
14
|
+
fills it in from SubagentAttribution data after run() collects attributions.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from cctx.models import Confidence, Finding, FindingKind, Severity
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from cctx.models import SessionTrace, ToolUse
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Thresholds — documented here, not tuned at runtime
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
OVERLAP_JACCARD: float = 0.65 # minimum Jaccard on word 3-grams for overlap
|
|
30
|
+
OVERLAP_MIN_WORDS: int = 50 # both prompts must be this long
|
|
31
|
+
|
|
32
|
+
RETRY_JACCARD: float = 0.50 # minimum Jaccard for failed-retry detection
|
|
33
|
+
RETRY_MIN_WORDS: int = 30 # both prompts must be this long
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# N-gram helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def _word_ngrams(text: str, n: int) -> set[tuple[str, ...]]:
|
|
41
|
+
words = text.lower().split()
|
|
42
|
+
if len(words) < n:
|
|
43
|
+
return set()
|
|
44
|
+
return {tuple(words[i : i + n]) for i in range(len(words) - n + 1)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _jaccard(a: set, b: set) -> float:
|
|
48
|
+
if not a or not b:
|
|
49
|
+
return 0.0
|
|
50
|
+
return len(a & b) / len(a | b)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_prompt(tu: ToolUse) -> str:
|
|
54
|
+
return tu.tool_input.get("prompt") or tu.tool_input.get("description") or ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Signal A — Overlapping subagent prompts
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _signal_overlap(agent_calls: list[tuple[int, ToolUse]]) -> list[Finding]:
|
|
62
|
+
findings: list[Finding] = []
|
|
63
|
+
for i in range(len(agent_calls)):
|
|
64
|
+
turn_i, tu_i = agent_calls[i]
|
|
65
|
+
p_i = _get_prompt(tu_i)
|
|
66
|
+
words_i = p_i.split()
|
|
67
|
+
if len(words_i) < OVERLAP_MIN_WORDS:
|
|
68
|
+
continue
|
|
69
|
+
ng_i = _word_ngrams(p_i, 3)
|
|
70
|
+
for j in range(i + 1, len(agent_calls)):
|
|
71
|
+
turn_j, tu_j = agent_calls[j]
|
|
72
|
+
p_j = _get_prompt(tu_j)
|
|
73
|
+
words_j = p_j.split()
|
|
74
|
+
if len(words_j) < OVERLAP_MIN_WORDS:
|
|
75
|
+
continue
|
|
76
|
+
ng_j = _word_ngrams(p_j, 3)
|
|
77
|
+
score = _jaccard(ng_i, ng_j)
|
|
78
|
+
if score < OVERLAP_JACCARD:
|
|
79
|
+
continue
|
|
80
|
+
findings.append(Finding(
|
|
81
|
+
kind=FindingKind.FANOUT_WASTE,
|
|
82
|
+
severity=Severity.MEDIUM,
|
|
83
|
+
confidence=Confidence.MEDIUM,
|
|
84
|
+
first_turn=min(turn_i, turn_j),
|
|
85
|
+
last_turn=max(turn_i, turn_j),
|
|
86
|
+
evidence={
|
|
87
|
+
"signal": "overlap",
|
|
88
|
+
"overlap_pair": [tu_i.subagent_session_id, tu_j.subagent_session_id],
|
|
89
|
+
"jaccard": round(score, 3),
|
|
90
|
+
"prompt_a": p_i[:80],
|
|
91
|
+
"prompt_b": p_j[:80],
|
|
92
|
+
"subagent_session_ids": [], # filled by _patch_fanout_costs
|
|
93
|
+
},
|
|
94
|
+
cost_usd=None,
|
|
95
|
+
summary=f"Overlapping subagent prompts (Jaccard {score:.2f})",
|
|
96
|
+
))
|
|
97
|
+
return findings
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Signal B — Failed subagent re-spawned with similar prompt
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _signal_retry(
|
|
105
|
+
agent_calls: list[tuple[int, ToolUse]],
|
|
106
|
+
result_map: dict[str, tuple[bool, str]], # tool_use_id -> (is_error, content)
|
|
107
|
+
) -> list[Finding]:
|
|
108
|
+
findings: list[Finding] = []
|
|
109
|
+
for k, (turn_k, tu_k) in enumerate(agent_calls):
|
|
110
|
+
is_error, _content = result_map.get(tu_k.tool_use_id, (False, ""))
|
|
111
|
+
if not is_error:
|
|
112
|
+
continue
|
|
113
|
+
# Only check the immediate next Agent call (by list order = turn order)
|
|
114
|
+
if k + 1 >= len(agent_calls):
|
|
115
|
+
continue
|
|
116
|
+
turn_next, tu_next = agent_calls[k + 1]
|
|
117
|
+
p_failed = _get_prompt(tu_k)
|
|
118
|
+
p_retry = _get_prompt(tu_next)
|
|
119
|
+
if len(p_failed.split()) < RETRY_MIN_WORDS or len(p_retry.split()) < RETRY_MIN_WORDS:
|
|
120
|
+
continue
|
|
121
|
+
score = _jaccard(_word_ngrams(p_failed, 3), _word_ngrams(p_retry, 3))
|
|
122
|
+
if score < RETRY_JACCARD:
|
|
123
|
+
continue
|
|
124
|
+
findings.append(Finding(
|
|
125
|
+
kind=FindingKind.FANOUT_WASTE,
|
|
126
|
+
severity=Severity.HIGH,
|
|
127
|
+
confidence=Confidence.HIGH,
|
|
128
|
+
first_turn=turn_k,
|
|
129
|
+
last_turn=turn_next,
|
|
130
|
+
evidence={
|
|
131
|
+
"signal": "retry",
|
|
132
|
+
"failed_session_id": tu_k.subagent_session_id,
|
|
133
|
+
"jaccard": round(score, 3),
|
|
134
|
+
"failed_prompt": p_failed[:80],
|
|
135
|
+
"retry_prompt": p_retry[:80],
|
|
136
|
+
"subagent_session_ids": [], # filled by _patch_fanout_costs
|
|
137
|
+
},
|
|
138
|
+
cost_usd=None,
|
|
139
|
+
summary=f"Failed subagent re-spawned with similar prompt (Jaccard {score:.2f})",
|
|
140
|
+
))
|
|
141
|
+
return findings
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Public entry point
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _classify_impl(trace: SessionTrace) -> list[Finding]:
|
|
149
|
+
# Collect Agent ToolUse in turn order
|
|
150
|
+
agent_calls: list[tuple[int, ToolUse]] = []
|
|
151
|
+
result_map: dict[str, tuple[bool, str]] = {}
|
|
152
|
+
|
|
153
|
+
for turn in trace.turns:
|
|
154
|
+
for tu in turn.tool_uses:
|
|
155
|
+
if tu.tool_name == "Agent":
|
|
156
|
+
agent_calls.append((turn.turn_number, tu))
|
|
157
|
+
for tr in turn.tool_results:
|
|
158
|
+
if tr.tool_name == "Agent":
|
|
159
|
+
result_map[tr.tool_use_id] = (tr.is_error, tr.content)
|
|
160
|
+
|
|
161
|
+
if len(agent_calls) < 2:
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
findings: list[Finding] = [
|
|
165
|
+
*_signal_overlap(agent_calls),
|
|
166
|
+
*_signal_retry(agent_calls, result_map),
|
|
167
|
+
]
|
|
168
|
+
return findings
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def classify(trace: SessionTrace) -> list[Finding]:
|
|
172
|
+
try:
|
|
173
|
+
return _classify_impl(trace)
|
|
174
|
+
except Exception:
|
|
175
|
+
return []
|
|
@@ -50,6 +50,16 @@ def export_diagnosis(
|
|
|
50
50
|
"patches": patches,
|
|
51
51
|
"turn_count": len(trace.turns),
|
|
52
52
|
"model": trace.primary_model,
|
|
53
|
+
"subagent_costs": [
|
|
54
|
+
{
|
|
55
|
+
"session_id": a.session_id,
|
|
56
|
+
"label": a.label,
|
|
57
|
+
"cost_usd": a.total_cost_usd,
|
|
58
|
+
"depth": a.depth,
|
|
59
|
+
"model": a.model,
|
|
60
|
+
}
|
|
61
|
+
for a in diagnosis.subagent_costs
|
|
62
|
+
],
|
|
53
63
|
}
|
|
54
64
|
return json.dumps(obj)
|
|
55
65
|
|
|
@@ -172,6 +172,7 @@ class FindingKind(str, Enum):
|
|
|
172
172
|
STALE_CONTEXT = "stale_context"
|
|
173
173
|
TOOL_THRASH = "tool_thrash"
|
|
174
174
|
DEAD_END = "dead_end"
|
|
175
|
+
FANOUT_WASTE = "fanout_waste"
|
|
175
176
|
PROJECT_PATTERN = "project_pattern"
|
|
176
177
|
|
|
177
178
|
|
|
@@ -181,6 +182,7 @@ KIND_LABEL: dict[FindingKind, str] = {
|
|
|
181
182
|
FindingKind.STALE_CONTEXT: "STALE CONTEXT",
|
|
182
183
|
FindingKind.TOOL_THRASH: "TOOL THRASH",
|
|
183
184
|
FindingKind.DEAD_END: "DEAD END",
|
|
185
|
+
FindingKind.FANOUT_WASTE: "FANOUT WASTE",
|
|
184
186
|
FindingKind.PROJECT_PATTERN: "PROJECT PATTERN",
|
|
185
187
|
}
|
|
186
188
|
|
|
@@ -194,6 +196,7 @@ MANAGED_HEADINGS: dict[FindingKind, str] = {
|
|
|
194
196
|
FindingKind.STALE_CONTEXT: "## Context hygiene",
|
|
195
197
|
FindingKind.TOOL_THRASH: "## Tool-call discipline",
|
|
196
198
|
FindingKind.DEAD_END: "## Exploration discipline",
|
|
199
|
+
FindingKind.FANOUT_WASTE: "## Fan-out discipline",
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
# Project-specific patterns use a heading that embeds tool+key, so the managed
|
|
@@ -233,6 +236,17 @@ class Patch:
|
|
|
233
236
|
evidence_summary: str
|
|
234
237
|
|
|
235
238
|
|
|
239
|
+
@dataclass
|
|
240
|
+
class SubagentAttribution:
|
|
241
|
+
"""Cost attribution for a single subagent session."""
|
|
242
|
+
|
|
243
|
+
session_id: str
|
|
244
|
+
label: str # from Agent tool_input['description'], else prompt[:80]
|
|
245
|
+
total_cost_usd: float # inclusive: this subagent + its own children
|
|
246
|
+
depth: int # 1 = direct child, 2 = grandchild, …
|
|
247
|
+
model: str | None
|
|
248
|
+
|
|
249
|
+
|
|
236
250
|
@dataclass
|
|
237
251
|
class Diagnosis:
|
|
238
252
|
session_id: str
|
|
@@ -242,6 +256,7 @@ class Diagnosis:
|
|
|
242
256
|
total_cost_usd: float
|
|
243
257
|
waste_cost_usd: float
|
|
244
258
|
analysed_at: datetime
|
|
259
|
+
subagent_costs: list[SubagentAttribution] = field(default_factory=list)
|
|
245
260
|
|
|
246
261
|
@property
|
|
247
262
|
def verdict(self) -> str:
|
|
@@ -57,6 +57,15 @@ _DEAD_END_DIFF = """\
|
|
|
57
57
|
+approaches already ruled out, and pick a meaningfully different one. Sunk effort on a
|
|
58
58
|
+failing approach is not a reason to continue it."""
|
|
59
59
|
|
|
60
|
+
_FANOUT_WASTE_DIFF = """\
|
|
61
|
+
+## Fan-out discipline
|
|
62
|
+
+
|
|
63
|
+
+Before spawning multiple subagents in parallel, state what each one will return
|
|
64
|
+
+and verify the tasks don't overlap. After each subagent completes, confirm its
|
|
65
|
+
+result is actually consumed by the parent before spawning retries. Retry only
|
|
66
|
+
+after changing something meaningful about the task — identical re-spawns waste
|
|
67
|
+
+the full subagent cost with no new information."""
|
|
68
|
+
|
|
60
69
|
_TEMPLATES: dict[FindingKind, tuple[str, str, str]] = {
|
|
61
70
|
# kind → (description, diff_body, target_file)
|
|
62
71
|
FindingKind.RETRY_LOOP: ("Add retry discipline rule", _RETRY_LOOP_DIFF, "CLAUDE.md"),
|
|
@@ -64,6 +73,7 @@ _TEMPLATES: dict[FindingKind, tuple[str, str, str]] = {
|
|
|
64
73
|
FindingKind.STALE_CONTEXT: ("Add context hygiene rule", _STALE_CONTEXT_DIFF, "CLAUDE.md"),
|
|
65
74
|
FindingKind.TOOL_THRASH: ("Add tool-call discipline rule", _TOOL_THRASH_DIFF, "CLAUDE.md"),
|
|
66
75
|
FindingKind.DEAD_END: ("Add exploration discipline rule", _DEAD_END_DIFF, "CLAUDE.md"),
|
|
76
|
+
FindingKind.FANOUT_WASTE: ("Add fan-out discipline rule", _FANOUT_WASTE_DIFF, "CLAUDE.md"),
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
|
|
@@ -176,6 +176,21 @@ footer {
|
|
|
176
176
|
<dt>Waste attributed</dt><dd>~${{ "%.2f"|format(diag.waste_cost_usd) }}</dd>
|
|
177
177
|
{% if diag.inflection_turn is not none %}<dt>Inflection turn</dt><dd>{{ diag.inflection_turn }}</dd>{% endif %}
|
|
178
178
|
</dl>
|
|
179
|
+
{% if diag.subagent_costs %}
|
|
180
|
+
<details class="subagent-costs">
|
|
181
|
+
<summary>Subagents: {{ diag.subagent_costs | selectattr("depth", "equalto", 1) | list | length }} — ${{ "%.3f" % (diag.subagent_costs | selectattr("depth", "equalto", 1) | map(attribute="total_cost_usd") | sum) }}</summary>
|
|
182
|
+
<table>
|
|
183
|
+
<tr><th>Label</th><th>Depth</th><th>Cost</th></tr>
|
|
184
|
+
{% for a in diag.subagent_costs %}
|
|
185
|
+
<tr>
|
|
186
|
+
<td>{{ a.label | truncate(80) }}</td>
|
|
187
|
+
<td>{{ a.depth }}</td>
|
|
188
|
+
<td>${{ "%.3f" % a.total_cost_usd }}</td>
|
|
189
|
+
</tr>
|
|
190
|
+
{% endfor %}
|
|
191
|
+
</table>
|
|
192
|
+
</details>
|
|
193
|
+
{% endif %}
|
|
179
194
|
<p class="meta">Costs are estimates (~85–95% of actual billing; system framing not observable in JSONL)</p>
|
|
180
195
|
<p class="meta">Analysed {{ diag.analysed_at.strftime("%Y-%m-%d %H:%M UTC") }}</p>
|
|
181
196
|
</div>
|
|
@@ -52,7 +52,13 @@ def render_diagnosis(
|
|
|
52
52
|
verdict = diagnosis.verdict
|
|
53
53
|
verdict_style = "bold green" if not diagnosis.findings else "bold red"
|
|
54
54
|
con.print(Text(f"Verdict: {verdict}", style=verdict_style))
|
|
55
|
+
subagent_sum = sum(a.total_cost_usd for a in diagnosis.subagent_costs if a.depth == 1)
|
|
56
|
+
n_sub = len([a for a in diagnosis.subagent_costs if a.depth == 1])
|
|
55
57
|
cost_line = f"Session cost: ~${diagnosis.total_cost_usd:.2f}"
|
|
58
|
+
if n_sub:
|
|
59
|
+
cost_line += (
|
|
60
|
+
f" (includes {n_sub} subagent{'s' if n_sub != 1 else ''}: ~${subagent_sum:.2f})"
|
|
61
|
+
)
|
|
56
62
|
if diagnosis.waste_cost_usd > 0:
|
|
57
63
|
pct = (
|
|
58
64
|
diagnosis.waste_cost_usd / diagnosis.total_cost_usd * 100
|
|
@@ -65,6 +71,22 @@ def render_diagnosis(
|
|
|
65
71
|
"~85–95% of actual billing; system framing not observable in JSONL", style="dim"
|
|
66
72
|
))
|
|
67
73
|
|
|
74
|
+
if diagnosis.subagent_costs:
|
|
75
|
+
show_depth = any(a.depth > 1 for a in diagnosis.subagent_costs)
|
|
76
|
+
tbl = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
|
|
77
|
+
tbl.add_column("Subagent", no_wrap=False, max_width=48)
|
|
78
|
+
if show_depth:
|
|
79
|
+
tbl.add_column("Depth", justify="right", width=6)
|
|
80
|
+
tbl.add_column("Cost", justify="right", width=8)
|
|
81
|
+
for a in diagnosis.subagent_costs:
|
|
82
|
+
label = a.label if len(a.label) <= 45 else a.label[:44] + "…"
|
|
83
|
+
cost_cell = f"${a.total_cost_usd:.3f}"
|
|
84
|
+
if show_depth:
|
|
85
|
+
tbl.add_row(label, str(a.depth), cost_cell)
|
|
86
|
+
else:
|
|
87
|
+
tbl.add_row(label, cost_cell)
|
|
88
|
+
con.print(tbl)
|
|
89
|
+
|
|
68
90
|
if not diagnosis.findings:
|
|
69
91
|
con.print("\nNo findings — session looks clean.")
|
|
70
92
|
return
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cctx-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.8.0"
|
|
8
8
|
description = "Diagnose Claude Code sessions — find what went wrong, what it cost, and what to add to CLAUDE.md"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -229,6 +229,43 @@ def test_write_produces_one_line_per_session() -> None:
|
|
|
229
229
|
assert ids == {"sess-001", "sess-002"}
|
|
230
230
|
|
|
231
231
|
|
|
232
|
+
def test_export_diagnosis_includes_subagent_costs() -> None:
|
|
233
|
+
"""JSON export includes subagent_costs array with correct fields."""
|
|
234
|
+
import dataclasses
|
|
235
|
+
import json
|
|
236
|
+
|
|
237
|
+
from cctx.exporters.jsonl import export_diagnosis
|
|
238
|
+
from cctx.models import SubagentAttribution
|
|
239
|
+
|
|
240
|
+
diag = _make_diagnosis()
|
|
241
|
+
trace = _make_trace()
|
|
242
|
+
diag = dataclasses.replace(diag, subagent_costs=[
|
|
243
|
+
SubagentAttribution(
|
|
244
|
+
session_id="child-1",
|
|
245
|
+
label="My task",
|
|
246
|
+
total_cost_usd=0.020,
|
|
247
|
+
depth=1,
|
|
248
|
+
model="claude-sonnet-4",
|
|
249
|
+
)
|
|
250
|
+
])
|
|
251
|
+
data = json.loads(export_diagnosis(diag, trace))
|
|
252
|
+
assert "subagent_costs" in data
|
|
253
|
+
assert len(data["subagent_costs"]) == 1
|
|
254
|
+
assert data["subagent_costs"][0]["session_id"] == "child-1"
|
|
255
|
+
assert data["subagent_costs"][0]["cost_usd"] == pytest.approx(0.020)
|
|
256
|
+
assert data["subagent_costs"][0]["depth"] == 1
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_export_diagnosis_subagent_costs_empty_by_default() -> None:
|
|
260
|
+
"""JSON export has subagent_costs: [] when no subagents."""
|
|
261
|
+
from cctx.exporters.jsonl import export_diagnosis
|
|
262
|
+
|
|
263
|
+
diag = _make_diagnosis()
|
|
264
|
+
trace = _make_trace()
|
|
265
|
+
data = json.loads(export_diagnosis(diag, trace))
|
|
266
|
+
assert data["subagent_costs"] == []
|
|
267
|
+
|
|
268
|
+
|
|
232
269
|
def test_write_empty_list_produces_no_output() -> None:
|
|
233
270
|
"""write() with an empty list produces no output."""
|
|
234
271
|
from cctx.exporters.jsonl import write
|